مرحبا بالجميع! في هذه المقالة ، سوف نتحدث عن تجربة شخصية في العمل مع المحركات الفيزيائية لمطلق النار متعدد اللاعبين والتركيز بشكل أساسي على تفاعل الفيزياء و
ECS : ما نوع من أشعل النار الذي دخلنا فيه أثناء العمل ، وما تعلمناه ، ولماذا توصلنا إلى حلول محددة.

أولاً ، دعونا نتعرف على سبب الحاجة إلى محرك مادي. لا توجد إجابة عالمية: في كل لعبة تخدم غرضها. تستخدم بعض الألعاب محركات فعلية لمحاكاة سلوك الكائنات في العالم بشكل صحيح من أجل تحقيق تأثير غمر اللاعب. في حالات أخرى ، تُعد الفيزياء أساس اللعب - مثل ، على سبيل المثال ، الطيور الغاضبة و Red Faction. هناك أيضًا "صناديق رمل" تختلف فيها القوانين الفيزيائية عن القوانين المعتادة وبالتالي تجعل اللعب أكثر إثارة للاهتمام وغير عادي (Portal ، سرعة أبطأ للضوء).
من وجهة نظر البرمجة ، يتيح المحرك الفعلي تبسيط عملية محاكاة سلوك الكائنات في اللعبة. في الواقع ، إنها مكتبة تخزن أوصاف الخصائص المادية للكائنات. في وجود محرك مادي ، لا نحتاج إلى تطوير نظام للتفاعل بين الهيئات والقوانين العالمية التي سيعيش بها عالم اللعبة. هذا يوفر الكثير من الوقت وجهد التنمية.
يصف المخطط أعلاه جوهر المشغل ومكوناته وبياناته والأنظمة التي تعمل مع المشغل ومكوناته. الكائن الرئيسي في المخطط هو اللاعب: يمكنه التحرك في الفضاء - مكونات التحويل والحركة ، MoveSystem ؛ لديه بعض الصحة وقد يموت - مكون الصحة ، الأضرار ، DamageSystem ؛ بعد ظهور الموت عند نقطة الفك - مكون التحويل للموضع ، نظام RespawnSystem ؛ قد تكون معرضة للخطر - مكون لا يقهر.ما هي ميزات تنفيذ لعبة الفيزياء للرماة؟
لا توجد تفاعلات مادية معقدة في لعبتنا ، ولكن هناك عددًا من الأشياء التي لا تزال هناك حاجة إلى محرك مادي. في البداية ، خططنا لاستخدامه لتحريك شخصية في العالم وفقًا للقوانين المحددة مسبقًا. عادة ما يتم ذلك عن طريق إعطاء الجسم دفعة أو سرعة ثابتة ، وبعد ذلك ، باستخدام طريقة المحاكاة / التحديث في المكتبة ، يتم محاكاة جميع الهيئات المسجلة فيه خطوة واحدة إلى الأمام بالضبط.
في الرماة ، غالبًا ما تستخدم الفيزياء ثلاثية الأبعاد ليس فقط لمحاكاة حركات الشخصيات ، ولكن أيضًا للمعالجة الصحيحة للقذائف من الرصاص والصواريخ ، والقفزات ، وتفاعل الشخصيات مع بعضها البعض والبيئة. إذا ادعى مطلق النار أنه واقعي ويسعى إلى نقل الأحاسيس الحقيقية لعملية إطلاق النار ، فهو يحتاج فقط إلى محرك مادي. عندما يطلق اللاعب مسدسًا على هدف ، فإنه يتوقع أن يحصل على خبرة وتكون النتيجة أقرب ما تكون إلى النتيجة التي يعرفها بالفعل من لعبة طويلة المدى من الرماة - شيء جديد جذري على الأرجح سيفاجئه.
ولكن في حالة لعبتنا ، هناك عدد من القيود. نظرًا لأن مطلق النار لدينا هو جهاز محمول ، فإنه لا يعني تفاعلات معقدة من الشخصيات مع بعضها البعض ومع العالم المحيط بها ، لا يتطلب المقذوفات الجميلة والتدمير والقفز على سطح غير مستو. ولكن في نفس الوقت وللسبب نفسه ، هناك متطلبات مرورية صارمة للغاية. ستكون الفيزياء ثلاثية الأبعاد في هذه الحالة زائدة عن الحاجة: ستستخدم جزءًا صغيرًا فقط من موارد الحوسبة الخاصة بها وتولد بيانات غير ضرورية ، والتي في شبكة المحمول والتزامن المستمر للعميل مع الخادم عبر UDP ستشغل مساحة كبيرة جدًا. تجدر الإشارة هنا إلى أنه في نموذج شبكتنا لا تزال هناك أشياء مثل
التنبؤ والمصالحة ، والتي تنطوي أيضًا على تسويات على العميل. نتيجةً لذلك ، يجب أن تعمل الفيزياء لدينا بأسرع ما يمكن من أجل إطلاق الأجهزة المحمولة وتشغيلها بنجاح ، دون التدخل في أنظمة التقديم وأنظمة العميل الفرعية الأخرى.
لذلك ، لم تناسبنا الفيزياء ثلاثية الأبعاد. ولكن من الجدير أن نتذكر أنه حتى لو كانت اللعبة تبدو ثلاثية الأبعاد ، فليست حقيقة أن الفيزياء فيها ثلاثية الأبعاد أيضًا: كل شيء يحدد طبيعة تفاعل الأشياء مع بعضها البعض. غالبًا ما يتم تخصيص التأثيرات التي لا يمكن تغطيتها بواسطة فيزياء ثنائية الأبعاد - أي ، يتم كتابة منطق يشبه التفاعلات ثلاثية الأبعاد - أو يتم استبداله ببساطة بالتأثيرات المرئية التي لا تؤثر على طريقة اللعب. في لعبة Heroes of the Storm ، الدفاع عن القدماء ، League of Legends ، تستطيع الفيزياء ثنائية الأبعاد توفير جميع ميزات اللعب في اللعبة دون التأثير على جودة الصورة أو الشعور بالمصداقية التي أنشأها مصممو اللعبة وفناني العالم. لذلك ، على سبيل المثال ، في هذه الألعاب ، هناك شخصيات قفزة ، لكن ليس هناك شعور مادي في ذروة قفزتهم ، لذلك يعود الأمر إلى محاكاة ثنائية الأبعاد ووضع نوع من العلم مثل _isInTheAir عندما تكون الشخصية في الهواء - يتم أخذها في الاعتبار عند حساب المنطق.
لذلك تقرر استخدام الفيزياء 2D. نكتب اللعبة في Unity ، لكن الخادم يستخدم Unity-less .net ، وهو الأمر الذي لا يفهمه Unity. نظرًا لأن نصيب الأسد من رمز المحاكاة يتم تفتيشه بين العميل والخادم ، فقد بدأنا في البحث عن شيء ما عبر النظام الأساسي - أي مكتبة فعلية مكتوبة بلغة C # خالصة دون استخدام الكود الأصلي للتخلص من خطر تعطل الأنظمة الأساسية المحمولة. علاوة على ذلك ، مع الأخذ في الاعتبار
تفاصيل عمل الرماة ، وعلى وجه الخصوص ، الترجيع المستمر على الخادم من أجل تحديد مكان تصوير المشغل ، كان من المهم بالنسبة لنا أن تعمل المكتبة مع التاريخ - وهذا يعني أنه يمكننا رؤية موقف الهيئات N إطارات في الوقت المناسب. . وبالطبع ، يجب عدم التخلي عن المشروع: من المهم أن يدعمه المؤلف ويمكنه إصلاح الخلل بسرعة ، إن وجدت ، أثناء العملية.
كما اتضح فيما بعد ، في ذلك الوقت ، قلة قليلة من المكتبات يمكنها تلبية متطلباتنا. في الواقع ، كان واحد فقط مناسبة لنا -
VolatilePhysics .
تجدر الإشارة إلى أن المكتبة تعمل مع كل من Unity و Unity-less Solutions ، وتتيح لك أيضًا إجراء rakecasts في حالة الكائنات السابقة خارج الصندوق ، أي مناسبة لمنطق مطلق النار. بالإضافة إلى ذلك ، تكمن راحة المكتبة في حقيقة أن آلية التحكم في بدء محاكاة Simulate () تسمح لك بإنتاجها في أي وقت عندما يحتاج العميل إليها. وميزة أخرى - القدرة على كتابة بيانات إضافية إلى الجسم المادي. يمكن أن يكون ذلك مفيدًا عند معالجة كائن من محاكاة في نتائج reykast - ومع ذلك ، فإن هذا يقلل بشكل كبير من الأداء.
بعد إجراء بعض الاختبارات والتأكد من تفاعل العميل والخادم بشكل جيد مع VolatilePhysics دون تعطل ، اخترنا ذلك.
كيف دخلنا المكتبة إلى الطريقة المعتادة للعمل مع ECS وماذا جاء منها
الخطوة الأولى عند العمل مع VolatilePhysics هي إنشاء عالم مادي لـ VoltWorld. إنها فئة وكيل ، يتم بها العمل الرئيسي: ضبط ، محاكاة بيانات حول الكائنات ، reykast ، إلخ. قمنا بلفها في واجهة خاصة حتى نتمكن في المستقبل من تغيير تطبيق المكتبة إلى شيء آخر. يشبه رمز الواجهة هذا:
عرض الكودpublic sealed class PhysicsWorld { public const int HistoryLength = 32; private readonly VoltWorld _voltWorld; private readonly Dictionary<uint, VoltBody> _cache = new Dictionary<uint, VoltBody>(); public PhysicsWorld(float deltaTime) { _voltWorld = new VoltWorld(HistoryLength) { DeltaTime = deltaTime }; } public bool HasBody(uint tag) { return _cache.ContainsKey(tag); } public VoltBody GetBody(uint tag) { VoltBody body; _cache.TryGetValue(tag, out body); return body; } public VoltRayResult RayCast(Vector2 origin, Vector2 direction, float distance, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.RayCast(ref ray, ref result, filter, ticksBehind); return result; } public VoltRayResult CircleCast(Vector2 origin, Vector2 direction, float distance, float radius, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.CircleCast(ref ray, radius, ref result, filter, ticksBehind); return result; } public void Update() { _voltWorld.Update(); } public void Update(uint tag) { var body = _cache[tag]; _voltWorld.Update(body, true); } public void UpdateBody(uint tag, Vector2 position, float angle) { var body = _cache[tag]; body.Set(position, angle); } public void CreateStaticCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateStaticBody(origin, 0, shape); body.UserData = tag; } public void CreateDynamicCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateDynamicBody(origin, 0, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public void CreateStaticSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateStaticBody(origin, rotationAngle, shape); body.UserData = tag; } public void CreateDynamicSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateDynamicBody(origin, rotationAngle, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public IEnumerable<VoltBody> GetBodies() { return _voltWorld.Bodies; } private static bool StaticCollisionFilter(VoltBody a, VoltBody b) { return b.IsStatic; } }
عند إنشاء العالم ، يتم الإشارة إلى حجم السجل - عدد حالات العالم التي ستخزنها المكتبة. في حالتنا ، كان عددهم 32: 30 إطارًا في الثانية ، وسنحتاجها استنادًا إلى متطلبات تحديث المنطق واثنين آخرين في حال تجاوزنا حدود عملية تصحيح الأخطاء. يأخذ الرمز أيضًا في الحسبان طرق الصب الخارجية التي تولد الأجسام المادية وأنواع مختلفة من reykast.
كما نتذكر من
المقالات السابقة ، يدور عالم ECS بشكل أساسي حول مكالمة منتظمة لتنفيذ أساليب لجميع الأنظمة المضمنة فيه. في الأماكن المناسبة لكل نظام ، نستخدم المكالمات إلى الواجهة الخاصة بنا. في البداية ، لم نكتب أي مجموعة للطعن في المحرك الفعلي ، على الرغم من وجود مثل هذه الأفكار. داخل الواجهة ، تحدث دعوة إلى التحديث () للعالم المادي ، وتحاكي المكتبة جميع تفاعلات الكائنات التي حدثت في كل إطار.
وبالتالي ، فإن العمل مع الفيزياء يتلخص في مكونين: الحركة الموحدة للأجسام في الفضاء في إطار واحد والكثير من الراكاست اللازمة لإطلاق النار ، والتشغيل السليم للآثار وأشياء أخرى كثيرة. ريكاتس ذات صلة خاصة في تاريخ حالة الأجسام البدنية.
وفقًا لنتائج اختباراتنا ، سرعان ما أدركنا أن المكتبة تعمل بشكل سيء للغاية بسرعات مختلفة ، وبسرعة معينة ، تبدأ الأجسام بسهولة بالمرور عبر الجدران. لا توجد إعدادات مرتبطة بالكشف المستمر عن التصادم لحل هذه المشكلة في محركنا. ولكن لم تكن هناك بدائل لحلنا في السوق في ذلك الوقت ، لذلك كان عليّ أن أضع نسخة خاصة بي من الأجسام المتحركة حول العالم ومزامنة بيانات الفيزياء مع ECS. لذلك ، على سبيل المثال ، رمز نظام الحركة الخاص بنا كما يلي:
عرض الكود using System; ... using Volatile; public sealed class MovePhysicsSystem : ExecutableSystem { private readonly PhysicsWorld _physicsWorld; private readonly CollisionFilter _moveFilter; private readonly VoltBodyFilter _collisionFilterDelegate; public MovePhysicsSystem(PhysicsWorld physicsWorld) { _physicsWorld = physicsWorld; _moveFilter = new CollisionFilter(true, CollisionLayer.ExplosiveBarrel); _collisionFilterDelegate = _moveFilter.Filter; } public override void Execute(GameState gs) { _moveFilter.State = gs; foreach (var pair in gs.WorldState.Movement) { ExecuteMovement(gs, pair.Key, pair.Value); } _physicsWorld.Update(); foreach (var pair in gs.WorldState.PhysicsDynamicBody) { if(pair.Value.IsAlive) { ExecutePhysicsDynamicBody(gs, pair.Key); } } } public override void Execute(GameState gs, uint avatarId) { _moveFilter.State = gs; var movement = gs.WorldState.Movement[avatarId]; if (movement != null) { ExecuteMovement(gs, avatarId, movement); _physicsWorld.Update(avatarId); var physicsDynamicBody = gs.WorldState.PhysicsDynamicBody[avatarId]; if (physicsDynamicBody != null && physicsDynamicBody.IsAlive) ExecutePhysicsDynamicBody(gs, avatarId); } } private void ExecutePhysicsDynamicBody(GameState gs, uint entityId) { var body = _physicsWorld.GetBody(entityId); if (body != null) { var transform = gs.WorldState.Transform[entityId]; transform.Position = body.Position; } } private void ExecuteMovement(GameState gs, uint entityId, Movement movement) { var body = _physicsWorld.GetBody(entityId); if (body != null) { float raycastRadius; if (CalculateRadius(gs, entityId, out raycastRadius)) { return; } body.AngularVelocity = 0; body.LinearVelocity = movement.Velocity; var movPhysicInfo = gs.WorldState.MovementPhysicInfo[entityId]; var collisionDirection = CircleRayCastSpeedCorrection(body, GameState.TickDurationSec, raycastRadius); CheckMoveInWall(movement, movPhysicInfo, collisionDirection, gs.WorldState.Transform[entityId]); } } private static bool CalculateRadius(GameState gs, uint id, out float raycastRadius) { raycastRadius = 0; var circleShape = gs.WorldState.DynamicCircleCollider[id]; if (circleShape != null) { raycastRadius = circleShape.Radius; } else { var boxShape = gs.WorldState.DynamicBoxCollider[id]; if (boxShape != null) { raycastRadius = boxShape.RaycastRadius; } else { gs.Log.Error(string.Format("Physics body {0} doesn't contains shape!", id)); return true; } } return false; } private static void CheckMoveInWall(Movement movement, MovementPhysicInfo movPhysicInfo, Vector2 collisionDirection, Transform transform) {
الفكرة هي أنه قبل نقل كل حرف ، نقوم بعمل
CircleCast في اتجاه حركته لتحديد ما إذا كانت هناك عقبة أمامه. هناك حاجة إلى CircleCast لأن توقعات الشخصيات في اللعبة تمثل دائرة ، ونحن لا نريد أن تتعثر في الزوايا بين الأشكال الهندسية المختلفة. ثم نعتبر زيادة السرعة وتعيين هذه القيمة إلى كائن العالم المادي مثل سرعته في إطار واحد. والخطوة التالية هي استدعاء طريقة محاكاة تحديث المحرك الفعلي () ، والتي تنقل جميع الكائنات التي نحتاجها ، وتسجيل الحالة القديمة في التاريخ في وقت واحد. بعد اكتمال المحاكاة داخل المحرك ، نقرأ هذه البيانات المحاكاة ، ونسخها إلى مكون Transform في ECS ، ثم نواصل العمل معها ، على وجه الخصوص ، إرسالها عبر الشبكة.
اتضح أن هذا النهج في تحديث الفيزياء بقطع صغيرة من البيانات يتم التحكم فيها عن سرعة حركة الشخصية فعال للغاية في التعامل مع الاختلافات في الفيزياء على العميل والخادم. وبما أن فيزياءنا ليست حتمية - أي أنه مع نفس بيانات المدخلات ، قد تختلف نتيجة المحاكاة - كان هناك العديد من المناقشات حول ما إذا كان الأمر يستحق استخدامه على الإطلاق ، وما إذا كان أي شخص في الصناعة يفعل شيئًا مشابهًا ، وجود محرك البدني الحتمية في متناول اليد. لحسن الحظ ، وجدنا تقريرًا ممتازًا من مطوري NetherRealm Studios في مؤتمر مطوري الألعاب عن مكون الشبكة لألعابهم وأدركنا أن هذا النهج يحدث بالفعل. بعد تجميع النظام بالكامل وتشغيله في عدة اختبارات ، حصلنا على حوالي 50 تنبؤات خاطئة بـ 9000 علامة ، أي خلال المعركة التي استمرت خمس دقائق. يتم تسوية مثل هذا العدد من أخطاء التنبؤات بسهولة من خلال آلية المصالحة والاستيفاء البصري لموقف اللاعب. الأخطاء التي تحدث أثناء التحديثات اليدوية المتكررة للفيزياء باستخدام بياناتك الخاصة ليست ذات أهمية ، وبالتالي ، يمكن أن يحدث الاستيفاء البصري بسرعة - لا يلزم إلا حتى تحدث قفزة بصرية في نموذج الحرف.
للتحقق من تزامن حالات العميل والخادم ، استخدمنا فئة مكتوبة ذاتيًا من النموذج التالي:
إذا لزم الأمر ، يمكن أن يكون آليا ، لكننا لم نفعل ذلك ، على الرغم من أننا فكرنا في ذلك في المستقبل.
تحويل رمز المقارنة:
عرض الكود public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) { return true; } if ((object)a == null && (object)b != null) { return false; } if ((object)a != null && (object)b == null) { return false; } if (Math.Abs(a.Angle - b.Angle) > 0.01f) { return false; } if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) { return false; } return true; }
الصعوبات الأولى
لم تكن هناك مشاكل في محاكاة الحركة ، في حين أنه كان من الممكن إسقاطها على متن طائرة ثنائية الأبعاد - نجحت الفيزياء في مثل هذه الحالات بشكل جيد للغاية ، ولكن في وقت من الأوقات جاء مصممو اللعبة وقالوا: "نريد قنابل يدوية!" ، لماذا لا تحاكي الطيران ثلاثي الأبعاد للجسم المادي مع وجود بيانات ثنائية الأبعاد فقط متاحة.
وقدموا مفهوم الارتفاع لبعض الأشياء.
كيف يبدو قانون الارتفاع على مر الزمن بالنسبة لجسم مهجور ، يمر عبر دروس في الفيزياء في الصف الثامن ، لذلك اتضح أن القرار بشأن المقذوفات تافه. لكن الحل مع الاصطدامات لم يكن تافهاً بعد الآن. دعنا نتخيل هذه الحالة: يجب أن تصطدم قنبلة يدوية أثناء الطيران بجدار ، أو تطير فوقها اعتمادًا على ارتفاعها الحالي وارتفاع الجدار. لن نحل المشكلة إلا في العالم ثنائي الأبعاد ، حيث يتم تمثيل القنبلة بواسطة دائرة والجدار بواسطة مستطيل.
عرض هندسة الكائنات لحل المشكلة.بادئ ذي بدء ، أوقفنا تفاعل الجسم الديناميكي لقنبلة يدوية مع الهيئات الثابتة والديناميكية الأخرى. هذا ضروري من أجل التركيز على الهدف. في مهمتنا ، يجب أن تكون القنبلة قادرة على المرور عبر الأجسام الأخرى و "التحليق" بجدار عندما تتقاطع إسقاطاتها على متن طائرة ثنائية الأبعاد. في تفاعل طبيعي ، لا يمكن لكائنين المرور من خلال بعضهما البعض ، ولكن في حالة وجود قنبلة يدوية بمنطق وحركة مخصصة ، سمحنا له بذلك في ظل ظروف معينة.
قدمنا مكونًا منفصلاً GrenadeMovement للقنبلة ، حيث قدمنا مفهوم الارتفاع:
[Component] public class GrenadeMovement { public float Height; [DontPack] public Vector2 Velocity; [DontPack] public float VerticalVelocity; public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { } }
تحتوي القنبلة الآن على إحداثيات إرتفاع ، لكن هذه المعلومات لا تمنح بقية العالم أي شيء. لذلك ، قررنا الغش وأضفنا الحالة التالية: يمكن للقنبلة أن تطير فوق الجدران ، ولكن بارتفاع معين فقط. وهكذا ، جاء التعريف الكامل للتصادمات للتحقق من تصادمات الإسقاط ومقارنة ارتفاع الجدار مع قيمة حقل GrenadeMovement.Height. إذا كان ارتفاع رحلة القنبلة أقل ، فإنه يصطدم بالجدار ، وإلا فإنه يمكن أن يستمر في التحرك بهدوء على طول مساره ، بما في ذلك في الفضاء ثنائي الأبعاد.
في التكرار الأول ، سقطت القنبلة اليدوية ببساطة عند العثور على التقاطعات ، ولكن بعد ذلك أضفنا تصادمات مرنة ، وبدأت تتصرف بشكل لا يمكن تمييزه تقريبًا عن النتيجة التي حصلنا عليها في 3D.
فيما يلي الكود الكامل لحساب مسار القنبلة والاصطدامات المرنة:
. ما التالي؟
, , - , .
ECS . , , JSON, ECS. :

, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .
- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .
- , 3D-, , .
, , , . , , ECS .
روابط مفيدة
:
: