الفيزياء لمطلق النار PvP المحمول وكيف اصنعنا صداقات مع ECS

مرحبا بالجميع! في هذه المقالة ، سوف نتحدث عن تجربة شخصية في العمل مع المحركات الفيزيائية لمطلق النار متعدد اللاعبين والتركيز بشكل أساسي على تفاعل الفيزياء و 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) { // 60 is the max angle when player move in wall and can shoot through the wall from weapon without target. const float maxAngleToWall = 60; if (movement.Velocity.IsEqual(Vector2.zero)) { if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } return; } movPhysicInfo.LastCollisionDirection = collisionDirection * -1f; if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { movPhysicInfo.TurnOnWall = false; movPhysicInfo.LastCollisionDirection = collisionDirection; } else { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } } // I can't believe we are using a physics engine and have to write such kludges private Vector2 CircleRayCastSpeedCorrection(VoltBody targetBody, float deltaSeconds, float rayCastRadius) { if (rayCastRadius <= 0) { return Vector2.zero; } var speed = targetBody.LinearVelocity; var position = targetBody.Position; var direction = speed * deltaSeconds; var rayCastResult = _physicsWorld.CircleCast(position + direction.normalized * 0.1f, direction, direction.magnitude, rayCastRadius, _collisionFilterDelegate, 0); if (rayCastResult.Body == null) { return Vector2.zero; } var magSpeed = speed.magnitude; if (rayCastResult.Distance > 0) { var penetratingDistance = magSpeed * deltaSeconds - rayCastResult.Distance; var sinVelocityEdge = Vector2.Dot(-speed.normalized, rayCastResult.Normal); var biasSpeed = penetratingDistance * sinVelocityEdge / deltaSeconds; var biasVector = rayCastResult.Normal * biasSpeed * 1.1f; var resultVelocity = speed + biasVector; if (magSpeed <= 0) { resultVelocity = Vector2.zero; } targetBody.LinearVelocity = resultVelocity; return rayCastResult.Normal; } var destination = rayCastResult.Body.Position; direction = destination - position; var rayCastResultToBody = _physicsWorld.RayCast(position, direction, direction.magnitude, _collisionFilterDelegate, 0); if (rayCastResultToBody.IsValid) targetBody.LinearVelocity = rayCastResultToBody.Normal * magSpeed * deltaSeconds; return rayCastResultToBody.Normal; } } 


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

اتضح أن هذا النهج في تحديث الفيزياء بقطع صغيرة من البيانات يتم التحكم فيها عن سرعة حركة الشخصية فعال للغاية في التعامل مع الاختلافات في الفيزياء على العميل والخادم. وبما أن فيزياءنا ليست حتمية - أي أنه مع نفس بيانات المدخلات ، قد تختلف نتيجة المحاكاة - كان هناك العديد من المناقشات حول ما إذا كان الأمر يستحق استخدامه على الإطلاق ، وما إذا كان أي شخص في الصناعة يفعل شيئًا مشابهًا ، وجود محرك البدني الحتمية في متناول اليد. لحسن الحظ ، وجدنا تقريرًا ممتازًا من مطوري NetherRealm Studios في مؤتمر مطوري الألعاب عن مكون الشبكة لألعابهم وأدركنا أن هذا النهج يحدث بالفعل. بعد تجميع النظام بالكامل وتشغيله في عدة اختبارات ، حصلنا على حوالي 50 تنبؤات خاطئة بـ 9000 علامة ، أي خلال المعركة التي استمرت خمس دقائق. يتم تسوية مثل هذا العدد من أخطاء التنبؤات بسهولة من خلال آلية المصالحة والاستيفاء البصري لموقف اللاعب. الأخطاء التي تحدث أثناء التحديثات اليدوية المتكررة للفيزياء باستخدام بياناتك الخاصة ليست ذات أهمية ، وبالتالي ، يمكن أن يحدث الاستيفاء البصري بسرعة - لا يلزم إلا حتى تحدث قفزة بصرية في نموذج الحرف.

للتحقق من تزامن حالات العميل والخادم ، استخدمنا فئة مكتوبة ذاتيًا من النموذج التالي:

عرض الكود
 using PS.Logs.Unity; /// <summary> /// Compares the same avatar in two states. Compares the values potentially /// affected by prediction. /// </summary> public sealed class GameStateComparer : IGameStateComparer { public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) { return false; } if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) { return false; } if (entity1 == null || entity2 == null) { LogManager.Debug("entity is different"); return false; } if (s1.Time != s2.Time) { LogManager.Warning(string.Format("Trying to compare states with different time! Predicted time: {0} Server time: {1}", s1.Time, s2.Time)); return false; } if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) { LogManager.Debug("Transform is different"); return false; } // ... some code ... return true; } } 


إذا لزم الأمر ، يمكن أن يكون آليا ، لكننا لم نفعل ذلك ، على الرغم من أننا فكرنا في ذلك في المستقبل.

تحويل رمز المقارنة:

عرض الكود
 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.

فيما يلي الكود الكامل لحساب مسار القنبلة والاصطدامات المرنة:

عرض الكود
 using System; // ... some code ... using Volatile; namespace Common.WorldState { public sealed class GrenadeMovementSystem : ExecutableSystem { private struct Projection { public float Min; public float Max; } private float _r; private readonly Vector2[] _vertices = new Vector2[4]; private readonly Vector2[] _verticesV = new Vector2[4]; private Vector2 _Vunit; private Vector2 _VTunit; private Projection _wallProj1; private Projection _wallProj2; private Projection _wallProj1V; private Projection _wallProj2V; private const float CollisionPrecision = 1e-3f; private static readonly float HalfSlope = Mathf.Cos(Mathf.PI / 4.0f); private readonly ContactPointList _contactPoints = new ContactPointList(3); public override void Execute(GameState gs) { var settings = gs.RuleBook.GrenadeConfig[1]; _r = settings.R; var floorDampeningPerTick = (float)Math.Pow(settings.FloorDampening, 1.0 / GameState.Hz); foreach (var grenade in gs.WorldState.GrenadeMovement) { // Gravity must take effect before collision // because contact with walls may and will adjust vertical velocity // and penetration will even move the ball up. grenade.Value.VerticalVelocity -= settings.Gravity * GameState.TickDurationSec; grenade.Value.Height += grenade.Value.VerticalVelocity * GameState.TickDurationSec; // prevent falling through floor if (grenade.Value.Height <= _r) { // slow down horizontal movement by floor friction // actually, friciton is simplified to just dampening coefficient var spdH = grenade.Value.Velocity.sqrMagnitude; var spdV = grenade.Value.VerticalVelocity; var cos = spdH / Mathf.Sqrt(spdH * spdH + spdV * spdV); grenade.Value.Velocity *= floorDampeningPerTick * cos; // slow down vertical movement grenade.Value.VerticalVelocity = settings.FloorRestitution * Math.Abs(grenade.Value.VerticalVelocity); // move up to the floor level grenade.Value.Height = _r; } // A collision will stop the ball and change its velocity. // Otherwise it will be moved by velocity PerformCollisionAndMovement(gs, grenade.Key, grenade.Value); } } private void PerformCollisionAndMovement(GameState gs, uint id, GrenadeMovement grenade) { var settings = gs.RuleBook.GrenadeConfig[1]; var velocity = grenade.Velocity * GameState.TickDurationSec; var trans = gs.WorldState.Transform[id]; var position = trans.Position; _Vunit = velocity.normalized; _VTunit = new Vector2(-_Vunit.y, _Vunit.x); _vertices[0] = position + _VTunit * _r; _vertices[1] = position - _VTunit * _r; _vertices[2] = _vertices[1] + velocity; _vertices[3] = _vertices[0] + velocity; _contactPoints.Reset(); int collisions = 0; var grenProj1V = ProjectCapsule(_Vunit, _vertices, position, velocity); var grenProj2V = ProjectCapsule(_VTunit, _vertices, position, velocity); collisions += CollideWithStaticBoxes(gs, id, position, velocity, grenade, grenProj1V, grenProj2V); collisions += CollideWithCircles(gs, gs.RuleBook.StaticCircleCollider, gs.RuleBook.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, (CollisionLayer)~0); collisions += CollideWithCircles(gs, gs.WorldState.DynamicCircleCollider, gs.WorldState.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, ~CollisionLayer.Character); if (collisions == 0) { trans.Position += velocity; } else { var contactSuperposition = CalculateContactSuperposition(); trans.Position += velocity * contactSuperposition.TravelDistance; var reflectedVelocity = grenade.Velocity - 2.0f * Vector2.Dot(grenade.Velocity, contactSuperposition.Normal) * contactSuperposition.Normal; reflectedVelocity *= settings.WallRestitution; #if DEBUG_GRENADES gs.Log.Debug("contact" + "\n\ttravel " + contactSuperposition.TravelDistance + "\n\tcontactNormal " + contactSuperposition.Normal.x + ":" + contactSuperposition.Normal.y + "\n\treflected V " + reflectedVelocity.x + ":" + reflectedVelocity.y); #endif grenade.Velocity = reflectedVelocity; } } private int CollideWithStaticBoxes( GameState gs, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; // TODO spatial query foreach (var collider in gs.RuleBook.StaticBoxCollider) { var wall = collider.Value; var transform = gs.RuleBook.Transform[collider.Key]; var colliderData = gs.RuleBook.PrecomputedColliderData[collider.Key]; // test projection to V _wallProj1V = ProjectPolygon(_Vunit, colliderData.Vertices); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectPolygon(_VTunit, colliderData.Vertices); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test projection to wall axis 1 _wallProj1 = ProjectPolygon(colliderData.Axis1, colliderData.Vertices); var grenProj1 = ProjectCapsule(colliderData.Axis1, _vertices, position, velocity); if (!Overlap(_wallProj1, grenProj1)) continue; // test projection to wall axis 2 _wallProj2 = ProjectPolygon(colliderData.Axis2, colliderData.Vertices); var grenProj2 = ProjectCapsule(colliderData.Axis2, _vertices, position, velocity); if (!Overlap(_wallProj2, grenProj2)) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { var localPV = WorldToBoxLocal(transform.Position, colliderData, position + velocity); #if DEBUG_GRENADES gs.Log.Debug("fall on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tlocal " + localPV.x + ":" + localPV.y + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (Math.Abs(localPV.x) < wall.Size.x * 0.5f || Math.Abs(localPV.y) < wall.Size.y * 0.5f) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckBoxCollision, colliderData, 0, position, velocity); var contactPoint = CalcBoxContactPoint(transform.Position, wall, colliderData, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with static box #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\twall pos " + transform.Position.x + ":" + transform.Position.y + " sz " + wall.Size.x + ":" + wall.Size.y + " angle " + transform.Angle + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\taxis1 " + colliderData.Axis1.x + ":" + colliderData.Axis1.y + "\n\tproj 1 w " + _wallProj1.Min + ":" + _wallProj1.Max + " g " + grenProj1.Min + ":" + grenProj1.Max + " overlap=" + Overlap(_wallProj1, grenProj1) + "\n\taxis2 " + colliderData.Axis2.x + ":" + colliderData.Axis2.y + "\n\tproj 2 w " + _wallProj2.Min + ":" + _wallProj2.Max + " g " + grenProj2.Min + ":" + grenProj2.Max + " overlap=" + Overlap(_wallProj2, grenProj2) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckBoxCollision(PrecomputedColliderData colliderData, int x, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test projection to wall axis 1 var grenProj1 = ProjectCapsule(colliderData.Axis1, _verticesV, position, velocity); if (!Overlap(_wallProj1, grenProj1)) return false; // test projection to wall axis 2 var grenProj2 = ProjectCapsule(colliderData.Axis2, _verticesV, position, velocity); if (!Overlap(_wallProj2, grenProj2)) return false; return true; } private int CollideWithCircles( GameState gs, Table<CircleCollider> colliderTable, Table<Transform> transformTable, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V, CollisionLayer collisionLayers) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; foreach (var collider in colliderTable) { if ((int)collisionLayers != ~0) { var body = gs.WorldState.PhysicsDynamicBody[collider.Key]; if (body != null && (body.CollisionLayer & collisionLayers) == 0) continue; } var wall = collider.Value; var transform = transformTable[collider.Key]; // test projection to V _wallProj1V = ProjectCircle(_Vunit, transform.Position, wall.Radius); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectCircle(_VTunit, transform.Position, wall.Radius); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test distance from the circle wall to semicircles on capsule ends var collisionDistance = (_r + wall.Radius) * (_r + wall.Radius); if ((position - transform.Position).sqrMagnitude > collisionDistance) continue; var distSqr = (position + velocity - transform.Position).sqrMagnitude; if (distSqr > collisionDistance) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { #if DEBUG_GRENADES gs.Log.Debug("grenade #" + id + " falls on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tdist " + Mathf.Sqrt(distSqr) + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (distSqr < wall.Radius * wall.Radius) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckCircleCollision, transform.Position, wall, position, velocity); var contactPoint = CalcCircleContactPoint(transform.Position, wall, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with circle #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\tcircle pos " + transform.Position.x + ":" + transform.Position.y + " r " + wall.Radius + "\n\tdist " + (transform.Position - (P + V * scaleV)).magnitude + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckCircleCollision(Vector2 wallCentre, CircleCollider wall, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test distance from the circle wall to the semicircle on the second capsule end var dSqr = (_r + wall.Radius) * (_r + wall.Radius); return (position + velocity - wallCentre).sqrMagnitude < dSqr; } private static float CalcTranslationScaleBeforeCollision<TData1, TData2>( Func<TData1, TData2, Vector2, Vector2, bool> collision, TData1 colliderData1, TData2 colliderData2, Vector2 position, Vector2 vector) { var min = 0.0f; var max = 1.0f; while (true) { var d = (max - min) * 0.5f; if (d < CollisionPrecision) break; var scale = min + d; if (collision(colliderData1, colliderData2, position, vector * scale)) { max = scale; } else { min = scale; } } return min; } private ContactPoint CalculateContactSuperposition() { ContactPoint contactSuperposition; _contactPoints.TryPopClosest(1000f, out contactSuperposition); ContactPoint contact; while (_contactPoints.TryPopClosest(contactSuperposition.TravelDistance, out contact)) { contactSuperposition.Normal += contact.Normal; } contactSuperposition.Normal = contactSuperposition.Normal.normalized; return contactSuperposition; } private static Projection ProjectPolygon(Vector2 axisNormalised, Vector2[] vertices) { Projection proj; var d = Vector2.Dot(axisNormalised, vertices[0]); proj.Min = d; proj.Max = d; for (var i = 1; i < vertices.Length; i++) { d = Vector2.Dot(axisNormalised, vertices[i]); proj.Min = Mathf.Min(proj.Min, d); proj.Max = Mathf.Max(proj.Max, d); } return proj; } private Projection ProjectCapsule(Vector2 axisNormalised, Vector2[] vertices, Vector2 p, Vector2 v) { var proj = ProjectPolygon(axisNormalised, vertices); proj = AddCircleProjection(proj, axisNormalised, p, _r); proj = AddCircleProjection(proj, axisNormalised, p + v, _r); return proj; } private static Projection AddCircleProjection(Projection proj, Vector2 axisNormalised, Vector2 centre, float r) { var c = Vector2.Dot(axisNormalised, centre); proj.Min = Mathf.Min(proj.Min, c - r); proj.Max = Mathf.Max(proj.Max, c + r); return proj; } private static Projection ProjectCircle(Vector2 axisNormalised, Vector2 centre, float r) { Projection proj; var c = Vector2.Dot(axisNormalised, centre); proj.Min = c - r; proj.Max = c + r; return proj; } private static bool Overlap(Projection p1, Projection p2) { return p1.Min < p2.Min ? p1.Max > p2.Min : p2.Max > p1.Min; } private static Vector2 WorldToBoxLocal(Vector2 wallCentre, PrecomputedColliderData colliderData, Vector2 position) { return new Vector2( Vector2.Dot(colliderData.Axis1, position) - Vector2.Dot(colliderData.Axis1, wallCentre), Vector2.Dot(colliderData.Axis2, position) - Vector2.Dot(colliderData.Axis2, wallCentre) ); } private static ContactPoint CalcBoxContactPoint(Vector2 wallCentre, BoxCollider wall, PrecomputedColliderData colliderData, Vector2 position) { var contactPoint = CaclBoxLocalContactPoint(wall.Size * 0.5f, WorldToBoxLocal(wallCentre, colliderData, position)); var worldAxisX = new Vector2(colliderData.Axis1.x, -colliderData.Axis1.y); var worldAxisY = new Vector2(colliderData.Axis1.y, colliderData.Axis1.x); contactPoint.Point = wallCentre + new Vector2(Vector2.Dot(worldAxisX, contactPoint.Point), Vector2.Dot(worldAxisY, contactPoint.Point)); contactPoint.Normal = new Vector2(Vector2.Dot(worldAxisX, contactPoint.Normal), Vector2.Dot(worldAxisY, contactPoint.Normal)); return contactPoint; } private static ContactPoint CaclBoxLocalContactPoint(Vector2 boxHalfSize, Vector2 localPosition) { ContactPoint localContactPoint = default(ContactPoint); // cases are numbered like numpad keys // 1, 2, 3 if (localPosition.y < -boxHalfSize.y) { // 1 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, -HalfSlope); } // 2, 3 else { // 3 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, -HalfSlope); } // 2 else { localContactPoint.Point = new Vector2(localPosition.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, -1.0f); } } } // 4, 6, 7, 8, 9 else { // 7, 8, 9 if (localPosition.y > boxHalfSize.y) { // 7 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, HalfSlope); } // 8, 9 else { // 9 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, HalfSlope); } // 8 else { localContactPoint.Point = new Vector2(localPosition.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, 1.0f); } } } // 4, 6 else { // 4 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(-1.0f, 0.0f); } // 6 else { localContactPoint.Point = new Vector2(boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(1.0f, 0.0f); } } } return localContactPoint; } private static ContactPoint CalcCircleContactPoint(Vector2 wallCentre, CircleCollider wall, Vector2 position) { ContactPoint contactPoint = default(ContactPoint); contactPoint.Normal = (position - wallCentre).normalized; contactPoint.Point = wallCentre + wall.Radius * contactPoint.Normal; return contactPoint; } } } 


. ما التالي؟


, , - , . ECS . , , JSON, ECS. :



, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .

- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .

- , 3D-, , .

, , , . , , ECS .

روابط مفيدة


:


:

Source: https://habr.com/ru/post/ar481880/


All Articles