היי,
לאחרונה התחלתי לעבוד על הפיכת משחק offline שאני עובד עליו למשחק online.
בזמן העבודה גיליתי כמה דברים חדשים על יוניטי והופתעתי מחדש כשגיליתי עד כמה מנוע חוסך לך המון שורות קוד והמון זמן תכנות.
בתור אחד שעבד טיפה עם UDP/TCP בניסיון לבנות משחק רשת ב-XNA, זה היה שיפור משמעותי בנוחות ובמהירות הפיתוח.
בפוסט הזה אני אכתוב בבלוג על כמה "תגליות" שלי במהלך העבודה - מערכת הרשת של יוניטי: סינכרון אובייקטים, הפעלת פקודות מרחוק ועוד.
קודם כל, חובה לציין שהצוות של יוניטי עשה עבודה מעולה בעטיפה של פרוטוקולים (פרוטוקול הוא בעצם אוסף חוקים שמגדיר את הצורה שבה המידע בין שתי נקודות ישלח. לקריאה נוספת: ויקיפדיה) על מנת להקל על המפתחים.
גם מי שמעולם לא תכנת יישומים המצריכים שימוש בתקשורת בין מחשבים יוכל ללמוד בקלות כיצד להשתמש במערכת הרשת של יוניטי.
נתחיל מהסבר כללי על מערכת הרשת.
המודל של שרת ולקוח
על מנת ששני מחשבים או יותר יוכלו להשתתף באותו משחק, אחד מהמחשבים צריך להיות השרת (Server) ושאר המחשבים יהיו הקליינטים (Clients).
שרת - אחראי על קבלת בקשות התחברות של הקליינטים, אישור בקשות ההתחברות, קבלת מידע מהקליינטים ושליחת מידע אליהם.
קליינט - אחראי על התחברות לשרת, קבלת מידע ושליחת מידע.
הקליינטים לא מתחברים אחד לשני.
השרת מחובר לכל הקליינטים וכל קליינט מחובר לשרת בלבד, כך:
כאשר השרת מקבל מידע מקליינט מסויים הוא דואג לסנכרן אותו עם שאר הקליינטים.
*הערה ביוניטי המודל הזה שונה. מה שהולך מאחורי הקלעים עובד בדיוק כמו בתמונה, עם זאת נהוג ביוניטי ששרת מתארח אצל אחד הקליינטים.
תודה לעדיאל (adiel666) על ההערה.
תודה לעדיאל (adiel666) על ההערה.
העברת נתונים ברשת עם יוניטי:
על העברת הנתונים אחראי ה-component שנקרא Network View.
המאפיין העיקרי בו הוא ה-ID. הנתונים ברשת עוברים בין אובייקטים בעלי ID זהים.
כלומר אם בשרת יש אובייקט עם Network View שמספרו הסידורי הוא 2 וגם בקליינט קיים אובייקט כזה אך קיים גם אובייקט עם המספר הסידורי 1, ההודעות מהאובייקט שבשרת יגיעו לאובייקט מספר 2 בלבד.
על שאר המאפיינים של Network View אכתוב בהמשך.
קיימות שתי דרכים עיקריות להעברת נתונים ברשת כאשר מדובר ביוניטי:
State Synchronization:
בדרך זו נשלחים מאפיינים של component מסויים מ-Game Object אחד לשאר האובייקטים עם Network View ID זהה.
Remote Procedure Calls (RPCs):
בדרך זו נשלחת קריאה לפונקציה. ניתן גם לקרוא לפונקציה עם מאפיינים.
לדוגמה, נניח שיש לנו צ'אט. כאשר נשלחת הודעה בצ'אט תשלח קריאה לפונקציה שתוסיף את ההודעה לשאר ההודעות שכבר נשלחו על מנת שתופיע גם היא בחלון הצ'אט.
ב-RPCs הפונקציה תופעל רק באובייקטים בעלי Network View ID זהה.
לכל אחת משתי מהדרכים שימושים משלה וקל מאוד לעבוד איתן.
מחלקת Network
מחלקת Network אחראית על (כמה מפתיע) הרשת.
במחלקה קיימות פונקציות להתחברות והתנתקות מהשרת, יצירת שרת, בדיקת Ping, יצירת אובייקטים משותפים ברשת ועוד.
על מנת להתחיל תקשורת בין השרת לקליינט נצטרך לאתחל את השרת על מנת שיאשר בקשות התחברות ממשתמשים ולאחר מכן לשלוח, בעזרת פונקציה, בקשת התחברות מהקליינט לשרת.
לכן זוהי המחלקה הראשונה שנשתמש בה במהלך בניית משחקי רשת.
הפונקציה שמאתחלת את השרת היא:
Network.InitializeServer(int connections, int listenPort, bool useNat);
הפרמטרים:
connections - מספר החיבורים האפשריים (כלומר מהי הכמות המקסימלית של מחוברים).
listenPort - הפורט לו יאזין השרת. לפורט זה ישלחו בקשות ההתחברות של הקליינטים.
useNat - האם יהיה שימוש ב-NAT Punch Through (מאפשר תקשורת בין 2 מחשבים שנמצאים מאחורי ראוטר).
הפונקציה ששולחת בקשת התחברות מהקליינט לשרת היא:
Network.Connect(string IP, string remotePort, string password);
הפרמטרים:
IP - מחרוזת המייצגת את כתובת השרת (IP או דומיין).
remotePort - הפורט אליו הקליינט ינסה להתחבר שהוא listenPort שהוזן באתחול השרת.
password - (שדה אופציונלי) סיסמת השרת. אין צורך למלא אם לא הוגדרה כזאת סיסמה.
לאחר אתחול הסרבר יקרא האירוע OnServerInitialized().
לאחר שנוצר החיבור בין הקליינט לסרבר נקרא אירוע בהתאם.
בקליינט יקרא האירוע OnConnectedToServer() ובסרבר יקרא האירוע OnPlayerConnected().
ד"א אני ממליץ שברגע שנוצר חיבור תוודאו שהמשחק יעבוד גם כאשר החלון ממוזער. אחרת הקליינט לא יגיב לסרבר ולשאר השחקנים יראה כאילו הוא תקוע.
את זה ניתן לבצע באמצעות השורה הבאה:
Application.runInBackground = true;
אחד המשחקים שפיתחתי ביוניטי (המשחק שכתבתי עליו בתחילת הפוסט) כולל בתוכו GUI שבתחילת המשחק מאפשר לשחקן בחירה, האם להיות הקליינט או השרת. במידה והשחקן הוא קליינט, ה-GUI מקבל שם משתמש ו-IP ומתחבר לשרת. במידה והשחקן הוא שרת, ה-GUI מקבל שם משתמש בלבד ומאתחל את השרת.
השרת מתנהג כמו הקליינט, כלומר גם השחקן שהוא שרת משתתף במשחק ולכן הקוד של האתחול כמעט זהה.
הקוד:
public class PBClient : MonoBehaviour
{
public GameObject player;
public string ipString = "127.0.0.1";
public string name;
public int maxConnections = 1;
GameObject chat;
void Start()
{
name = "Guest" + Random.Range(0, 10000);
chat = GameObject.Find("Chat");
chat.active = false;
}
void OnConnectedToServer()
{
print("Connected!");
InitGame();
}
void OnServerInitialized()
{
print("Server is ON");
InitGame();
}
void InitGame()
{
//Init code here.
}
void OnGUI()
{
ipString = GUI.TextField(new Rect(10, 70, 200, 20), ipString, 25);
name = GUI.TextField(new Rect(10, 100, 200, 20), name, 25);
if (GUI.Button(new Rect(10, 10, 100, 20), "Start Server"))
{
Network.InitializeServer(maxConnections, 50000, false);
Application.runInBackground = true;
}
else if (GUI.Button(new Rect(10, 40, 100, 20), "Connect"))
{
Network.Connect(ipString, 50000);
Application.runInBackground = true;
}
}
}
{
public GameObject player;
public string ipString = "127.0.0.1";
public string name;
public int maxConnections = 1;
GameObject chat;
void Start()
{
name = "Guest" + Random.Range(0, 10000);
chat = GameObject.Find("Chat");
chat.active = false;
}
void OnConnectedToServer()
{
print("Connected!");
InitGame();
}
void OnServerInitialized()
{
print("Server is ON");
InitGame();
}
void InitGame()
{
//Init code here.
}
void OnGUI()
{
ipString = GUI.TextField(new Rect(10, 70, 200, 20), ipString, 25);
name = GUI.TextField(new Rect(10, 100, 200, 20), name, 25);
if (GUI.Button(new Rect(10, 10, 100, 20), "Start Server"))
{
Network.InitializeServer(maxConnections, 50000, false);
Application.runInBackground = true;
}
else if (GUI.Button(new Rect(10, 40, 100, 20), "Connect"))
{
Network.Connect(ipString, 50000);
Application.runInBackground = true;
}
}
}
אחרי שהבנתם (אני מקווה) איך ליצור חיבור, בין השרת לקליינט נעבור לסינכרון האובייקטים.
Network Views
כאשר מתכנתים משחקי רשת, חלק חשוב בתכנות הוא לקבוע (ולתכנת בהתאם כמובן) איזה מידע לסנכרן בין הקליינטים והשרת ואיך הוא יסונכרן.
ביוניטי קיים Component שמנהל את החלק הזה של התכנות והוא ה-Network View.
לכל Network View יש 3 מאפיינים:
- State Synchronization
- Observed
- View ID
שני המאפיינים הראשונים קשורים ל-State Synchronization ואסביר עליהם בקרוב.
המאפיין השלישי הוא בעצם המספר הסידורי של ה-View ID. מידע שנשלח מ-Network View במחשב אחד, יגיע ל-Network View בעל אותו View ID במחשב אחר.
אובייקט שתצרו בעורך יקבל באופן אוטומטי ID, שישמש אותו גם במהלך המשחק.
לדוגמא, אם יצרתם אובייקט צ'אט בעורך שמכיל Component מסוג Network View, תדעו שכל המידע שנשלח דרך האובייקט הזה, יגיע בדיוק לאותו אובייקט צ'אט בשאר הקליינטים.
*הערה: בדרך כלל ה-ID ייקבע אוטומטית כאשר תצרו את האובייקט אך יש מקרים מסויימים בהם תצטרכו לקבוע את ה-View ID, ע"י המתודה:
Network.AllocateViewID()
לקריאה נוספת:
Network.AllocateViewID
העברת הנתונים:
עד עכשיו למדנו על כל מה שמסביב. מודל שרת-לקוח, התחברות, Network Views, איך המידע מגיע ליעד וכו'.
בחלק הזה נלמד על העברת הנתונים בפועל - איך לממש את זה במנוע ובקוד.
נתחיל משיטת הסנכרון. בעזרתה ניתן לראות את התוצאות כמעט באופן מיידי וללא צורך בתכנות.
עם זאת, בשביל אפשרויות יותר מתקדמות מהאפשרויות הבסיסיות שהמנוע מציע יש צורך בתכנות.
State Synchronization
קודם כל, אם הספקתם לשכוח מה זה State Synchronization (מעכשיו אני אכתוב State Sync), כתבתי הסבר קצר בתחילת המדריך .
כשהסברתי על Network Views היו שני מאפיינים שלא פירטתי עליהם והם נוגעים לאילו נתונים ישלחו וכיצד כאשר מתבצע State Sync.
מחזור למאפיינים של Network View:
- State Synchronization
- Observed
View ID
את המאפיין השלישי מחקתי כי אתם כבר מכירים אותו (ובעיקר כי רציתי למצוא שימוש לקו האמצעי הזה P:).
המאפיין הראשון - State Synchronization קובע את צורת שליחת הנתונים.
ניתן לשלוח את הנתונים בשתי צורות:
Unreliable -
שליחה של כל הנתונים, לאחר תקופת זמן מסויימת (ברירת המחדל היא 15 פעמים לשנייה).
לא בטוח שכל החבילות יגיעו בסדר הנכון או יגיעו בכלל ליעד.
ReliableDeltaCompressed -
שליחה רק של הנתונים שהשתנו מאז השליחה האחרונה. במידה ואין נתונים שהשתנו, לא יישלח כלום.
השולח מוודא שהחבילה הגיעה ליעד. אם היא לא הגיעה ליעד החבילה תשלח שוב.
מהקריאה הראשונה נראה שהסוג השני יותר טוב מכל בחינה. אך כאשר מנתחים לעומק את היתרונות והחסרונות מתברר שלכל סוג יש תפקיד משלו.
הסוג הראשון מתאים מאוד לאובייקטים שהמידע שלהם משתנה כל הזמן. מפני שכך אפשר לדעת כמעט בודאות שכל שליחה תכיל נתונים שונים ולכן אין צורך בדחיסה שהסוג השני מציע. בנוסף, אם חבילה תגיע בסדר לא נכון או שלא תגיע בכלל, יתבצע תיקון כמעט מידי על ידי החבילות הבאות ולכן לא יווצר לאג כאשר אחד השחקנים מחכה לשליחה חוזרת של חבילה שלא הגיעה.
דוגמה טובה היא מיקום של מכונית (ומיקום בכלל) במשחק מרוץ מכוניות. המיקום של המכונית משתנה כמעט בכל פריים ולכן היתרונות של הסוג הראשון תקפים לגביה.
הסוג השני מתאים לנתונים שמשתנים בתדירות נמוכה יותר אך חובה שיגיעו ליעד.
לדוגמה אובייקט שמנהל את הציוד שנמצא על השחקן במשחקי MMORPG למיניהם.
אם לכל ציוד יש ID משלו, האובייקט יעדכן את ה-ID וישלח אותו לשרת.
שינוי של ציוד לא מתבצע כל פריים אלא לעיתים נדירות יחסית וחשוב שהמידע הזה יגיע לשרת כמו שהוא ובסדר הנכון.
ניתן גם לכבות לגמרי את הסכרון ע"י בחירה (או שינוי בקוד) באפשרות Off.
המאפיין השני הוא Observed, המאפיין הזה מכיל את ה-Component שעליו אחראי ה-Network View.
הוא יכול להכיל אחד מהבאים:
- Transform - יסנכרן את מיקום, סיבוב וגודל האובייקט.
- RigidBody - יסנכרן את המיקום בנוסף לתכונות פיזיקליות.
- Animation - יסנכרן את האנימציה של האובייקט.
- סקריפט - המתכנת קובע איזה מידע יסונכרן.
כדי לכתוב סקריפט שיסנכרן מידע ברשת, הסקריפט צריך להכיל את המתודה:
void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
BitStream - זרם ביטים. זהו אובייקט שכותב או קורא נתונים בהתאם למצב שלו.
NetworkMessageInfo - מידע בסיסי על ההודעה - שולח, Network View וחתימת זמן (בשניות).
הקוד הבא מסנכרן את המיקום של האובייקט (במצב כתיבה הוא שולח אותו ובמצב קריאה הוא מקבל אותו):
void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
//If the stream is in write mode
if (stream.isWriting)
{
Vector3 position = transform.position;
stream.Serialize(ref position); //Write data
}
//If the stream is in read mode
else
{
Vector3 position = Vector3.zero;
stream.Serialize(ref position); //Read data
transform.position = position;
}
}
{
//If the stream is in write mode
if (stream.isWriting)
{
Vector3 position = transform.position;
stream.Serialize(ref position); //Write data
}
//If the stream is in read mode
else
{
Vector3 position = Vector3.zero;
stream.Serialize(ref position); //Read data
transform.position = position;
}
}
לעוד מידע ודוגמאות:
RPCs
דרך נוספת לסינכרון אובייקטים היא קריאה לפונקציות מרחוק.
הקריאה לפונקציות מרחוק פשוטה מאוד, כמעט כמו קריאה לפונקציה רגילה.
זכרו שכמו בסנכרון, גם כאן ניתן לקרוא למתודות על אובייקטים בעלי NetworkViewID זהה.
ניקח דוגמה של צ'אט. בכל פעם שאחד השחקנים שולח הודעה תרצו לעדכן את שאר השחקנים בכך.
אם נשתמש בשיטה של סנכרון מידע, נצטרך לחבר את המחרוזת החדשה לכל הטקסט שכבר קיים בצ'אט ואז לסנכרן את המחרוזת הזאת עם כל שאר השחקנים. זה בזבוז מפני שנשלח המון מידע, הרבה יותר ממה שצריך להשלח (חוץ מזה שהסנכרון לא תומך במחרוזות, לפחות לא בפעם האחרונה שניסיתי).
היה הרבה יותר פשוט אם היינו קוראים לפונקציה "הוסף הודעה לצ'אט" שמקבלת כפרמטר את ההודעה שיש להוסיף לצ'אט.
לשם כך נוצרו ה-RPCs.
כדי להגדיר פונקציה להיות RPC צריך להוסיף לה מאפיין בהתאם: [RPC] (ב-JS זה התחביר: @RPC)
וכך זה נראה בקוד:
[RPC]
void ChatMessage(string message)
{
messages += message + "\n";
}
void ChatMessage(string message)
{
messages += message + "\n";
}
הערה: לא ניתן להשתמש בכל סוג משתנה כפרמטר של RPC, ה-types הנתמכים הם אלה בלבד:
- int
- float
- string
- NetworkPlayer
- NetworkViewID
- Vector3
- Quaternion
על מנת לקרוא למתודה מרחוק נשתמש במתודה RPC של המחלקה NetworkView.
המתודה היא:
networkView.RPC(string name, RPCMode mode, object[] params args);
name - שם המתודה שתרצו לקרוא לה.
mode - למי תשלח המתודה והאם היא תשמר ב-Buffer.
זהו enum שמכיל 5 אפשרויות:
- Server - המתודה תתבצע אצל הסרבר בלבד.
- All - המתודה תתבצע אצל כולם.
- Others - המתודה תתבצע אצל כולם מלבד השולח.
- AllBuffered - המתודה תתבצע אצל כולם, בנוסף היא תשמר ב-Buffer. לכל שחקן חדש שמתחבר, כל ה-RPCs נשלחים. לדוגמה שחקן זרק חפץ מהתיק שלו ושאר השחקנים יכולים לאסוף אותו. אם שחקן חדש יתחבר וה-RPC של זריקת החפץ לא ישמר ב-Buffer רק השחקנים שכבר התחברו יוכלו לראות את אותו החפץ. דוגמה נוספת היא טעינת שלב, בדרך כלל נרצה שכל השחקנים יטענו את השלב הנוכחי ברגע שהם מתחברים ולא רק כאשר הסרבר מכריז על שינוי שלב.
- OtherBuffered - המתודה תתבצע אצל כולם מלבד השולח ותשמר ב-Buffer.
args - הפרמטרים שהמתודה מקבלת.
אם נקח לדוגמה את ה-RPC של הצ'אט, הקריאה למתודה תראה כך:
networkView.RPC("ChatMessage", RPCMode.All, message);
הקריאה צריכה להעשות מתוך Component שמוצמד לצ'אט. לצ'אט צריך להיות מוצמד גם Component מסוג Network View.
הקוד ניגש ל-Network View שמוצמד לצ'אט, וקורא לRPC דרכו. שם הפונקציה הוא ChatMessage, הפרמטר הוא ההודעה (מסוג סטרינג) והוא ישלח RPC לכל המחוברים (כולל את עצמו).
אם תרצו שהצ'אט ישמור היסטוריה של ההודעות ויציג את כל ההודעות שנשלחו אי פעם לשחקנים, ניתן לשנות את RPCMode ל- AllBuffered.
נניח שבנוסף להודעה, נשלח פרמטר מסוג int שמגדיר את צבע ההודעה, במקרה כזה יש צורך לשנות את ה-RPC עצמו כך שיקבל שני פרמטרים והקריאה לפונקציה תראה ככה:
networkView.RPC("ChatMessage", RPCMode.All, message, color);
ניתן גם לשלוח RPC לשחקן ספציפי, במקום mode המתודה מקבלת כפרמטר NetworkPlayer.
אלו היו שתי הדרכים לשליחת מידע ברשת ואנחנו ממש לקראת הסוף.
עכשיו נעבור על כמה פונקציות שימושיות, סיכום ויאללה הביתה D:
חזרה למחלקת Network
קיימות מספר פונקציות שימושיות במחלקת Network שתצטרכו כמעט בכל משחק.
Network.Instantiate
יצירת אובייקט ברשת. האובייקט יכיל Network View ID זהה אצל כל המחוברים וחוסך המון עבודה.
בנוסף, המתודה מתנהגת כ-RPC שנשמר ב-buffer. כלומר שכל שחקן שיצטרף יראה את כל האובייקטים שנוצרו עד עכשיו.
כך נראית המתודה:
Network.Instantiate(Object prefab, Vector3 position, Quaternion rotation, int group);
group - הקבוצה אליה ה-RPC ישתייך. בהמשך תראו שניתן למחוק RPCs מה-buffer באמצעות הקבוצה שאליה הם שייכים.
*גם ל-Network View קיים group ID שנקבע עפ"י המאפיין group של האובייקט.
Network.Destroy
השמדת אובייקט ברשת.
Network.Destroy(GameObject gameObject);
Network.IntializeSecurity
המתודה מאתחלת את שכבת האבטחה של הסרבר.
יש לקרוא לה לפני אתחול הסרבר ואין לקרוא לה בקליינט.
לפרטים נוספים: http://unity3d.com/support/documentation/ScriptReference/Network.InitializeSecurity.html
Network.RemoveRPCsInGroup, Network.RemoveRPCs
המתודות מנקות את ה-buffer מ-RPCs.
ניתן למחוק RPCs מסויימים עפ"י מאפיינים שונים:
מנקה את כל ה-RPCs מקבוצה מסויימת.
Network.RemoveRPCsInGroup(int group);
מנקה את כל ה-RPCs שנשלחו משחקן מסויים.
Network.RemoveRPCs(NetworkPlayer playerID);
מנקה את כל ה-RPCs שנשלחו משחקן מסויים ונמצאים בקבוצה מסויימת.
Network.RemoveRPCs(NetworkPlayer playerID, int group);
מנקה את כל ה-RPCs שנשלחו מ-NetworkView בעל ID מסויים.
Network.RemoveRPCs(NetworkViewID viewID);
Network.incomingPassword
הסיסמה לשרת (מסוג string) אותה מזינים בפעולה Connect בקליינט.
כלומר במתודה הזאת:
Network.Connect(string IP, string remotePort, string password);
יש לי עוד המון מה לכתוב בנושא הזה אבל הפוסט מתחיל לתפוס נפח ולכן אני נאלץ להפסיק כאן.
סיכום:
המנוע יוניטי מאפשר לשלב רשת במשחק גם למי שמעולם לא נגע בתכנות הכולל את הנושא הזה.
קיימות שתי דרכים עיקריות להעברת נתונים:
-סינכרון - העברת נתונים עם דחיסה או בלי דחיסה.
-קריאה לפונקציות מרחוק.
הכל עטוף באופן שקריא ונוח למתכנת.
בנוסף קיימת אפשרות אבטחה מובנית, סיסמה, יצירת אובייקטים משותפים, הריסת אובייקטים משותפים ועוד...
לקריאה נוספת, פונקציות, מאפיינים ועוד:
ונושא שלא כתבתי עליו ויכול להיות שיעניין חלק מכם:
כמו תמיד, קצת מוזיקה. Trivium, אחת הלהקות המוצלחות שיצא לי אי פעם לשמוע. אף שיר שלהם (טוב נו, אולי השניים החדשים) לא אכזב אותי.
לילה טוב לכולם, ולכל התלמידים והסטונדנטים - שנת לימודים מוצלחת (גם במקצועות שהם לא מדעי המחשב ;) ).
תגובה זו הוסרה על ידי המחבר.
השבמחק