ضغط البيانات متعددة اللاعبين ومعالجة البتات
إن إنشاء لعبة متعددة اللاعبين في Unity ليس مهمة تافهة، ولكن بمساعدة حلول الطرف الثالث، مثل PUN 2، جعلت تكامل الشبكات أسهل بكثير.
وبدلاً من ذلك، إذا كنت تحتاج إلى مزيد من التحكم في إمكانات الشبكة الخاصة باللعبة، فيمكنك كتابة حل الشبكة الخاص بك باستخدام تقنية Socket (على سبيل المثال، تعدد اللاعبين الرسمي، حيث يتلقى الخادم مدخلات اللاعب فقط ثم يقوم بإجراء حساباته الخاصة لضمان أن يتصرف جميع اللاعبين بنفس الطريقة، وبالتالي تقليل حدوث القرصنة).
بغض النظر عما إذا كنت تكتب شبكاتك الخاصة أو تستخدم حلاً موجودًا، يجب أن تضع في اعتبارك الموضوع الذي سنناقشه في هذه المقالة، وهو ضغط البيانات.
أساسيات تعدد اللاعبين
في معظم الألعاب متعددة اللاعبين، هناك اتصال يحدث بين اللاعبين والخادم، على شكل دفعات صغيرة من البيانات (سلسلة من البايتات)، يتم إرسالها ذهابًا وإيابًا بمعدل محدد.
في Unity (و C# على وجه التحديد)، أنواع القيم الأكثر شيوعًا هي int, float, bool, و string (يجب أيضًا تجنب استخدام السلسلة عند إرسال قيم متغيرة بشكل متكرر، والاستخدام الأكثر قبولًا لهذا النوع هو رسائل الدردشة أو البيانات التي تحتوي على نص فقط).
- يتم تخزين جميع الأنواع المذكورة أعلاه في عدد محدد من البايتات:
int = 4 بايت
float = 4 بايت
bool = 1 بايت
string = (عدد البايتات المستخدمة تشفير حرف واحد، اعتمادًا على تنسيق التشفير) × (عدد الأحرف)
بمعرفة القيم، دعنا نحسب الحد الأدنى من البايتات المطلوبة لإرسالها إلى لعبة FPS متعددة اللاعبين (First-Person Shooter):
موضع اللاعب: Vector3 (3 عوامات × 4) = 12 بايت
دوران اللاعب: Quaternion (4 عوامات x 4) = 16 بايت
هدف مظهر اللاعب: Vector3 (3 عوامات x 4) = 12 بايت
لاعب إطلاق النار: منطقي = 1 بايت
لاعب في الهواء: منطقي = 1 بايت
لاعب رابض: منطقي = 1 بايت
تشغيل المشغل: منطقي = 1 بايت
إجمالي 44 بايت.
سنستخدم طرق الامتداد لحزم البيانات في مصفوفة من البايتات، والعكس صحيح:
- قم بإنشاء سكريبت جديد، وقم بتسميته SC_ByteMethods ثم الصق الكود أدناه بداخله:
SC_ByteMethods.cs
using System;
using System.Collections;
using System.Text;
public static class SC_ByteMethods
{
//Convert value types to byte array
public static byte[] toByteArray(this float value)
{
return BitConverter.GetBytes(value);
}
public static byte[] toByteArray(this int value)
{
return BitConverter.GetBytes(value);
}
public static byte toByte(this bool value)
{
return (byte)(value ? 1 : 0);
}
public static byte[] toByteArray(this string value)
{
return Encoding.UTF8.GetBytes(value);
}
//Convert byte array to value types
public static float toFloat(this byte[] bytes, int startIndex)
{
return BitConverter.ToSingle(bytes, startIndex);
}
public static int toInt(this byte[] bytes, int startIndex)
{
return BitConverter.ToInt32(bytes, startIndex);
}
public static bool toBool(this byte[] bytes, int startIndex)
{
return bytes[startIndex] == 1;
}
public static string toString(this byte[] bytes, int startIndex, int length)
{
return Encoding.UTF8.GetString(bytes, startIndex, length);
}
}
مثال لاستخدام الطرق المذكورة أعلاه:
- قم بإنشاء سكريبت جديد، وقم بتسميته SC_TestPackUnpack ثم قم بلصق الكود أدناه بداخله:
SC_TestPackUnpack.cs
using System;
using UnityEngine;
public class SC_TestPackUnpack : MonoBehaviour
{
//Example values
public Transform lookTarget;
public bool isFiring = false;
public bool inTheAir = false;
public bool isCrouching = false;
public bool isRunning = false;
//Data that can be sent over network
byte[] packedData = new byte[44]; //12 + 16 + 12 + 1 + 1 + 1 + 1
// Update is called once per frame
void Update()
{
//Part 1: Example of writing Data
//_____________________________________________________________________________
//Insert player position bytes
Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
//Insert player rotation bytes
Buffer.BlockCopy(transform.rotation.x.toByteArray(), 0, packedData, 12, 4); //X
Buffer.BlockCopy(transform.rotation.y.toByteArray(), 0, packedData, 16, 4); //Y
Buffer.BlockCopy(transform.rotation.z.toByteArray(), 0, packedData, 20, 4); //Z
Buffer.BlockCopy(transform.rotation.w.toByteArray(), 0, packedData, 24, 4); //W
//Insert look position bytes
Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 28, 4); //X
Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 32, 4); //Y
Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 36, 4); //Z
//Insert bools
packedData[40] = isFiring.toByte();
packedData[41] = inTheAir.toByte();
packedData[42] = isCrouching.toByte();
packedData[43] = isRunning.toByte();
//packedData ready to be sent...
//Part 2: Example of reading received data
//_____________________________________________________________________________
Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
print("Received Position: " + receivedPosition);
Quaternion receivedRotation = new Quaternion(packedData.toFloat(12), packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
print("Received Rotation: " + receivedRotation);
Vector3 receivedLookPos = new Vector3(packedData.toFloat(28), packedData.toFloat(32), packedData.toFloat(36));
print("Received Look Position: " + receivedLookPos);
print("Is Firing: " + packedData.toBool(40));
print("In The Air: " + packedData.toBool(41));
print("Is Crouching: " + packedData.toBool(42));
print("Is Running: " + packedData.toBool(43));
}
}
يقوم البرنامج النصي أعلاه بتهيئة مصفوفة البايت بطول 44 (وهو ما يتوافق مع مجموع البايتات لجميع القيم التي نريد إرسالها).
يتم بعد ذلك تحويل كل قيمة إلى صفائف بايت، ثم يتم تطبيقها على صفيف البيانات المعبأة باستخدام Buffer.BlockCopy.
لاحقًا يتم تحويل البيانات المعبأة مرة أخرى إلى قيم باستخدام طرق الامتداد من SC_ByteMethods.cs.
تقنيات ضغط البيانات
من الناحية الموضوعية، 44 بايت لا تمثل الكثير من البيانات، ولكن إذا لزم الأمر يتم إرسالها من 10 إلى 20 مرة في الثانية، فستبدأ حركة المرور في التزايد.
عندما يتعلق الأمر بالشبكات، فإن كل بايت له أهميته.
فكيف لتقليل كمية البيانات؟
الإجابة بسيطة، وذلك من خلال عدم إرسال القيم التي لا يُتوقع أن تتغير، ومن خلال تجميع أنواع القيم البسيطة في بايت واحد.
لا ترسل القيم التي لا يتوقع أن تتغير
في المثال أعلاه، قمنا بإضافة رباعي الدوران، والذي يتكون من 4 عوامات.
ومع ذلك، في حالة لعبة FPS، عادةً ما يدور اللاعب حول المحور Y فقط، مع العلم أنه لا يمكننا إضافة التدوير إلا حول Y، مما يقلل بيانات التدوير من 16 بايت إلى 4 بايت فقط.
Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
تكديس قيم منطقية متعددة في بايت واحد
البايت عبارة عن سلسلة من 8 بتات، لكل منها قيمة محتملة هي 0 و1.
من قبيل الصدفة، يمكن أن تكون القيمة المنطقية صحيحة أو خاطئة فقط. لذلك، باستخدام رمز بسيط، يمكننا ضغط ما يصل إلى 8 قيم منطقية في بايت واحد.
افتح SC_ByteMethods.cs ثم أضف الكود أدناه قبل قوس الإغلاق الأخير '}'
//Bit Manipulation
public static byte ToByte(this bool[] bools)
{
byte[] boolsByte = new byte[1];
if (bools.Length == 8)
{
BitArray a = new BitArray(bools);
a.CopyTo(boolsByte, 0);
}
return boolsByte[0];
}
//Get value of Bit in the byte by the index
public static bool GetBit(this byte b, int bitNumber)
{
//Check if specific bit of byte is 1 or 0
return (b & (1 << bitNumber)) != 0;
}
تم تحديث رمز SC_TestPackUnpack:
SC_TestPackUnpack.cs
using System;
using UnityEngine;
public class SC_TestPackUnpack : MonoBehaviour
{
//Example values
public Transform lookTarget;
public bool isFiring = false;
public bool inTheAir = false;
public bool isCrouching = false;
public bool isRunning = false;
//Data that can be sent over network
byte[] packedData = new byte[29]; //12 + 4 + 12 + 1
// Update is called once per frame
void Update()
{
//Part 1: Example of writing Data
//_____________________________________________________________________________
//Insert player position bytes
Buffer.BlockCopy(transform.position.x.toByteArray(), 0, packedData, 0, 4); //X
Buffer.BlockCopy(transform.position.y.toByteArray(), 0, packedData, 4, 4); //Y
Buffer.BlockCopy(transform.position.z.toByteArray(), 0, packedData, 8, 4); //Z
//Insert player rotation bytes
Buffer.BlockCopy(transform.localEulerAngles.y.toByteArray(), 0, packedData, 12, 4); //Local Y Rotation
//Insert look position bytes
Buffer.BlockCopy(lookTarget.position.x.toByteArray(), 0, packedData, 16, 4); //X
Buffer.BlockCopy(lookTarget.position.y.toByteArray(), 0, packedData, 20, 4); //Y
Buffer.BlockCopy(lookTarget.position.z.toByteArray(), 0, packedData, 24, 4); //Z
//Insert bools (Compact)
bool[] bools = new bool[8];
bools[0] = isFiring;
bools[1] = inTheAir;
bools[2] = isCrouching;
bools[3] = isRunning;
packedData[28] = bools.ToByte();
//packedData ready to be sent...
//Part 2: Example of reading received data
//_____________________________________________________________________________
Vector3 receivedPosition = new Vector3(packedData.toFloat(0), packedData.toFloat(4), packedData.toFloat(8));
print("Received Position: " + receivedPosition);
float receivedRotationY = packedData.toFloat(12);
print("Received Rotation Y: " + receivedRotationY);
Vector3 receivedLookPos = new Vector3(packedData.toFloat(16), packedData.toFloat(20), packedData.toFloat(24));
print("Received Look Position: " + receivedLookPos);
print("Is Firing: " + packedData[28].GetBit(0));
print("In The Air: " + packedData[28].GetBit(1));
print("Is Crouching: " + packedData[28].GetBit(2));
print("Is Running: " + packedData[28].GetBit(3));
}
}
باستخدام الطرق المذكورة أعلاه، قمنا بتقليل طول البيانات المعبأة من 44 إلى 29 بايت (تخفيض بنسبة 34%).