إنشاء لعبة Asteroids بسيطة باستخدام الكيانات القائمة على المكونات
Arabic (العربية/عربي) translation by Adjatay Bashroh Aldad (you can also view the original English article)
في البرنامج التعليمي السابق ، أنشأنا نظام كيان قائم على مكونات خالية من العظام. الآن سنستخدم هذا النظام لإنشاء لعبة Asteroids بسيطة.
معاينة النتيجة النهائية
إليك لعبة الكويكبات البسيطة التي سننشئها في هذا البرنامج التعليمي. تمت كتابته باستخدام Flash و AS3 ، ولكن المفاهيم العامة تنطبق على معظم اللغات.
كود المصدر الكامل متاح على GitHub.
نظرة عامة على الفصل
هناك ستة فصول:
-
AsteroidsGame
، الذي يوسّع فئة اللعبة الأساسية ويضيف المنطق الخاص بـ space-em-up. -
السفينة
، وهذا هو الشيء الذي تسيطر عليه. -
الكويكب
، وهو الشيء الذي تطلق عليه النار. -
رصاصة
، وهو الشيء الذي قمت بإطلاقه. -
بندقية
، مما يخلق تلك الرصاصات. -
EnemyShip
، وهو غريب أجنبي من هناك فقط لإضافة القليل من التنوع للعبة.
دعونا نذهب من خلال هذه الأنواع الكيانات واحدا تلو الآخر.
فئة السفينة
سنبدأ بسفينة اللاعب:
package asteroids { import com.iainlobb.gamepad.Gamepad; import com.iainlobb.gamepad.KeyCode; import engine.Body; import engine.Entity; import engine.Game; import engine.Health; import engine.Physics; import engine.View; import flash.display.GraphicsPathWinding; import flash.display.Sprite; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class Ship extends Entity { protected var gamepad:Gamepad; public function Ship() { body = new Body(this); body.x = 400; body.y = 300; physics = new Physics(this); physics.drag = 0.9; view = new View(this); view.sprite = new Sprite(); view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]), Vector.<Number>([ -7.3, 10.3, -5.5, 10.3, -7, 0.6, -0.5, -2.8, 6.2, 0.3, 4.5, 10.3, 6.3, 10.3, 11.1, -1.4, -0.2, -9.6, -11.9, -1.3, -7.3, 10.3]), GraphicsPathWinding.NON_ZERO); health = new Health(this); health.hits = 5; health.died.add(onDied); weapon = new Gun(this); gamepad = new Gamepad(Game.stage, false); gamepad.fire1.mapKey(KeyCode.SPACEBAR); } override public function update():void { super.update(); body.angle += gamepad.x * 0.1; physics.thrust(-gamepad.y); if (gamepad.fire1.isPressed) weapon.fire(); } protected function onDied(entity:Entity):void { destroy(); } } }
هناك قدرا كبيرا من تنفيذ التفاصيل هنا، ولكن الشيء الرئيسي لإشعار أننا إنشاء مثيل في المنشئ وتكوين
مكونات
الجسم
، والفيزياء
، والصحة وعرض
والأسلحة. (إن عنصر السلاح
هو في الواقع مثال لـ Gun
بدلاً من فئة أساس الأسلحة.)
إنني أستخدم واجهات برمجة تطبيقات رسم الرسومات البيانية لإنشاء سفينتي (الخطوط من 29 إلى 32) ، ولكن يمكننا بسهولة استخدام صورة نقطية. أقوم أيضًا بإنشاء -- مثيل لفئة Gamepad الخاصة بي - هذه مكتبة مفتوحة المصدر كتبت قبل عامين لجعل إدخال لوحة المفاتيح أسهل في Flash.
لقد قمت أيضًا بإلغاء وظيفة
التحديث من الفئة الأساسية لإضافة بعض السلوك المخصص: بعد تشغيل كافة السلوك الافتراضي باستخدام super.update ()
نحن تدوير وتدفع السفينة على أساس إدخال لوحة المفاتيح ، وإطلاق النار إذا تم الضغط على مفتاح النار.
من خلال الاستماع إلى وفاة
إشارة المكون الصحي ، نقوم بتشغيل الوظيفة onDied
إذا نفد اللاعب من نقاط الضرب. عندما يحدث هذا نقول للسفينة أن تدمر نفسها.
فئة البند
قية
دعونا نطلق النار في ذلك
الفصل
package asteroids { import engine.Entity; import engine.Weapon; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class Gun extends Weapon { public function Gun(entity:Entity) { super(entity); } override public function fire():void { var bullet:Bullet = new Bullet(); bullet.targets = entity.targets; bullet.body.x = entity.body.x; bullet.body.y = entity.body.y; bullet.body.angle = entity.body.angle; bullet.physics.thrust(10); entity.entityCreated.dispatch(bullet); super.fire(); } } }
هذا هو واحد قصير لطيف! نحن فقط تجاوز الدالة fire ()
لإنشاء رمز نقطي
جديد كلما قام اللاعب بإشعال. بعد مطابقة وضع الطلقة وتناوبها على السفينة ، وتوجيهها في الاتجاه الصحيح ، نرسل الكيانخلق
بحيث يمكن إضافته إلى اللعبة.
الشيء العظيم في هذه الفئة
هو أنه يستخدم من قبل كل من اللاعب وسفن العدو.
فئة رصا
صة
ينشئ Gun
مثالًا على هذه الطبقة Bulle
t:
package asteroids { import engine.Body; import engine.Entity; import engine.Physics; import engine.View; import flash.display.Sprite; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class Bullet extends Entity { public var age:int; public function Bullet() { body = new Body(this); body.radius = 5; physics = new Physics(this); view = new View(this); view.sprite = new Sprite(); view.sprite.graphics.beginFill(0xFFFFFF); view.sprite.graphics.drawCircle(0, 0, body.radius); } override public function update():void { super.update(); for each (var target:Entity in targets) { if (body.testCollision(target)) { target.health.hit(1); destroy(); return; } } age++; if (age > 20) view.alpha -= 0.2; if (age > 25) destroy(); } } }
المنشئ instantiates وتكوين الجسم والفيزياء وطريقة العرض. في وظيفة التحديث ، يمكنك الآن رؤية قائمة تسمى الأهداف
تأتي في متناول اليدين ، حيث نقوم بتكرار جميع الأشياء التي نريد أن نصل إليها ونرى ما إذا كان أي منها يتقاطع مع الرمز النقطي.
لن يصلح نظام التصادم هذا لآلاف الرصاصات ، ولكنه جيد بالنسبة لمعظم الألعاب غير الرسمية.
إذا حصلت الطلقة على أكثر من 20 إطارًا ، فسنبدأ في التلاشي ، وإذا أقدمنا على 25 إطارًا ، فإننا ندمرها. كما هو الحال مع بندقية ، يتم استخدام Bullet
من قبل كل
من اللاعب والعدو - الحالات فقط لديها قائمة أهداف مختلفة.
بالحديث عن هذا الموضوع...
فئة EnemyShip
الآن دعونا ننظر إلى سفينة العدو هذه:
package asteroids { import engine.Body; import engine.Entity; import engine.Health; import engine.Physics; import engine.View; import flash.display.GraphicsPathWinding; import flash.display.Sprite; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class EnemyShip extends Entity { protected var turnDirection:Number = 1; public function EnemyShip() { body = new Body(this); body.x = 750; body.y = 550; physics = new Physics(this); physics.drag = 0.9; view = new View(this); view.sprite = new Sprite(); view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2]), Vector.<Number>([ 0, 10, 10, -10, 0, 0, -10, -10, 0, 10]), GraphicsPathWinding.NON_ZERO); health = new Health(this); health.hits = 5; health.died.add(onDied); weapon = new Gun(this); } override public function update():void { super.update(); if (Math.random() < 0.1) turnDirection = -turnDirection; body.angle += turnDirection * 0.1; physics.thrust(Math.random()); if (Math.random() < 0.05) weapon.fire(); } protected function onDied(entity:Entity):void { destroy(); } } }
كما ترون ، إنه يشبه إلى حد كبير فئة سفينة اللاعب. الفرق الحقيقي الوحيد هو أنه في وظيفة التحديث ()
، بدلاً من التحكم في المشغل عبر لوحة المفاتيح ، لدينا بعض 'الغباء الاصطناعي' لجعل السفينة تجول وتطلق النار عشوائياً.
فئة الكو
يكب
الكيان الآخر الذي يمكن للاعب أن يطلق عليه هو الكويكب نفسه:
package asteroids { import engine.Body; import engine.Entity; import engine.Health; import engine.Physics; import engine.View; import flash.display.Sprite; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class Asteroid extends Entity { public function Asteroid() { body = new Body(this); body.radius = 20; body.x = Math.random() * 800; body.y = Math.random() * 600; physics = new Physics(this); physics.velocityX = (Math.random() * 10) - 5; physics.velocityY = (Math.random() * 10) - 5; view = new View(this); view.sprite = new Sprite(); view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); view.sprite.graphics.drawCircle(0, 0, body.radius); health = new Health(this); health.hits = 3; health.hurt.add(onHurt); } override public function update():void { super.update(); for each (var target:Entity in targets) { if (body.testCollision(target)) { target.health.hit(1); destroy(); return; } } } protected function onHurt(entity:Entity):void { body.radius *= 0.75; view.scale *= 0.75; if (body.radius < 10) { destroy(); return; } var asteroid:Asteroid = new Asteroid(); asteroid.targets = targets; group.push(asteroid); asteroid.group = group; asteroid.body.x = body.x; asteroid.body.y = body.y; asteroid.body.radius = body.radius; asteroid.view.scale = view.scale; entityCreated.dispatch(asteroid); } } }
نأمل أن تعتاد على كيفية بحث فئات الكيان هذه الآن.
في المنشئ نقوم بتهيئة مكوناتنا وتعيين الموقع والسرعة بشكل عشوائي.
في وظيفة التحديث ()
نقوم بالتحقق من الاصطدامات بقائمة الأهداف الخاصة بنا - والتي في هذا المثال سيكون لها عنصر واحد فقط - سفينة اللاعب. إذا وجدنا اصطدام فإننا نلحق الضرر بالهدف ثم ندمر الكويكب. من ناحية أخرى ، إذا كان الكويكب نفسه قد تضرر (بمعنى أنه أصيب برصاصة من لاعب) ، فنحن نقلصه ونخلق كويكبًا ثانيًا ، مما يخلق الوهم بأنه تم تفجيره إلى قطعتين. نحن نعرف متى نفعل ذلك من خلال الاستماع إلى إشارة 'الأذى' للمكون الصحي.
فئة AsteroidsGame
أخيرًا ، لنلق نظرة على فئة AsteroidsGame التي تتحكم في العرض بالكامل:
package asteroids { import engine.Entity; import engine.Game; import flash.events.MouseEvent; import flash.filters.GlowFilter; import flash.text.TextField; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public class AsteroidsGame extends Game { public var players:Vector.<Entity> = new Vector.<Entity>(); public var enemies:Vector.<Entity> = new Vector.<Entity>(); public var messageField:TextField; public function AsteroidsGame() { } override protected function startGame():void { var asteroid:Asteroid; for (var i:int = 0; i < 10; i++) { asteroid = new Asteroid(); asteroid.targets = players; asteroid.group = enemies; enemies.push(asteroid); addEntity(asteroid); } var ship:Ship = new Ship(); ship.targets = enemies; ship.destroyed.add(onPlayerDestroyed); players.push(ship); addEntity(ship); var enemyShip:EnemyShip = new EnemyShip(); enemyShip.targets = players; enemyShip.group = enemies; enemies.push(enemyShip); addEntity(enemyShip); filters = [new GlowFilter(0xFFFFFF, 0.8, 6, 6, 1)]; update(); render(); isPaused = true; if (messageField) { addChild(messageField); } else { createMessage(); } stage.addEventListener(MouseEvent.MOUSE_DOWN, start); } protected function createMessage():void { messageField = new TextField(); messageField.selectable = false; messageField.textColor = 0xFFFFFF; messageField.width = 600; messageField.scaleX = 2; messageField.scaleY = 3; messageField.text = "CLICK TO START"; messageField.x = 400 - messageField.textWidth; messageField.y = 240; addChild(messageField); } protected function start(event:MouseEvent):void { stage.removeEventListener(MouseEvent.MOUSE_DOWN, start); isPaused = false; removeChild(messageField); stage.focus = stage; } protected function onPlayerDestroyed(entity:Entity):void { gameOver(); } protected function gameOver():void { addChild(messageField); isPaused = true; stage.addEventListener(MouseEvent.MOUSE_DOWN, restart); } protected function restart(event:MouseEvent):void { stopGame(); startGame(); stage.removeEventListener(MouseEvent.MOUSE_DOWN, restart); isPaused = false; removeChild(messageField); stage.focus = stage; } override protected function stopGame():void { super.stopGame(); players.length = 0; enemies.length = 0; } override protected function update():void { super.update(); for each (var entity:Entity in entities) { if (entity.body.x > 850) entity.body.x -= 900; if (entity.body.x < -50) entity.body.x += 900; if (entity.body.y > 650) entity.body.y -= 700; if (entity.body.y < -50) entity.body.y += 700; } if (enemies.length == 0) gameOver(); } } }
هذا الفصل طويل جدًا (أكثر من 100 سطر!) لأنه يفعل الكثير من الأشياء.
في startGame ()
يقوم بإنشاء وتكوين 10 كويكبات ، السفينة وسفينة العدو ، ويقوم أيضًا بإنشاء رسالة 'اضغط على بدء التشغيل'.
تقوم وظيفة start ()
بإيقاف اللعبة وإزالة الرسالة ، بينما تقوم وظيفة gameOver
بإيقاف اللعبة مؤقتًا مرة أخرى واستعادة الرسالة. تستمع الدالة restart ()
بنقرة ماوس على شاشة Game Over - عندما يحدث ذلك ، توقف اللعبة وتبدأ مرة أخرى.
تقوم وظيفة التحديث ()
بتكرار جميع الأعداء والاعوجاجات التي انجرفت عن الشاشة ، وكذلك التحقق من حالة الفوز ، وهي عدم وجود أعداء في قائمة الأعداء.
أخذ المزيد
هذا هو محرك عظام جميل ولعبة بسيطة ، لذلك دعونا الآن نفكر في طرق يمكننا توسيعه.
- يمكننا إضافة قيمة أولوية لكل كيان ، وفرز القائمة قبل كل تحديث ، حتى نتمكن من التأكد من أن بعض أنواع الكيانات يتم تحديثها دائمًا بعد الأنواع الأخرى.
- يمكننا استخدام تجميع الكائنات حتى نعيد استخدام الكائنات الميتة (مثل التعداد النقطي) بدلاً من إنشاء مئات من الكائنات الجديدة.
- يمكننا إضافة نظام كاميرا حتى يمكننا التمرير وتكبير المشهد. يمكننا تمديد مكونات الجسم والفيزياء لإضافة دعم ل Box2D أو محرك آخر الفيزياء.
- يمكننا إنشاء مكون مخزون ، بحيث يمكن للكيانات حمل العناصر.
بالإضافة إلى توسيع المكونات الفردية ، قد نحتاج في بعض الأحيان إلى توسيع واجهة IEntity
لإنشاء أنواع خاصة من الكيانات مع المكونات المتخصصة.
على سبيل المثال ، إذا كنا نصمم لعبة منصة ، ولدينا مكون جديد يتعامل مع جميع الأشياء المحددة جدًا التي تحتاج إليها شخصية لعبة النظام الأساسي - هل هي على الأرض ، هل هي تلامس الجدار ، كم من الوقت تم في الجو ، هل يمكنهم القفز المزدوج ، وما إلى ذلك - قد تحتاج الكيانات الأخرى أيضًا إلى الوصول إلى هذه المعلومات. ولكنها ليست جزءًا من واجهة برمجة التطبيقات الأساسية للكيان ، والتي يتم الاحتفاظ بها عن قصد بشكل عام جدًا. لذلك نحن بحاجة إلى تحديد واجهة جديدة ، والتي توفر الوصول إلى جميع مكونات الكيان القياسية ، ولكنها تضيف الوصول إلى مكون PlatformController
.
لهذا ، سنفعل شيء مثل:
package platformgame { import engine.IEntity; /** * ... * @author Iain Lobb - iainlobb@gmail.com */ public interface IPlatformEntity extends IEntity { function set platformController(value:PlatformController):void; function get platformController():PlatformController; } }
ثم يقوم أي كيان يحتاج إلى وظيفة 'المنصة' بتنفيذ هذه الواجهة ، مما يمكن الكيانات الأخرى من التفاعل مع مكون PlatformController
.
الاستنتاجات
حتى من خلال الجرأة على الكتابة عن بنية اللعبة ، أخشى أن أحرك عشًا في عش الدبابير - ولكن هذا (في الغالب) دائمًا شيء جيد ، وآمل على الأقل جعلتك تفكر في كيفية تنظيم الشفرة.
في النهاية ، لا أظن أنه ينبغي عليك التعلق بكيفية بناء الأشياء ؛ كل ما يناسبك من أجل إنجاز لعبتك هو أفضل استراتيجية. أعرف أن هناك أنظمة أكثر تطوراً بكثير ، تلك التي أصفها هنا ، والتي تحل مجموعة من القضايا بخلاف القضايا التي ناقشتها ، ولكنها قد تميل إلى البدء في البحث عن غير مألوف إذا كنت معتادًا على بنية تقليدية قائمة على الميراث.
أنا أحب النهج الذي اقترحته هنا لأنه يسمح بتنظيم الكود حسب الغرض ، إلى فصول صغيرة مركزة ، مع توفير واجهة قابلة للتوسع مطبوعًا بشكل ثابت وبدون الاعتماد على ميزات اللغة الديناميكية أو عمليات
بحث السلسلة. إذا كنت ترغب في تغيير سلوك مكون معين ، فيمكنك تمديد هذا المكون وتجاوز الأساليب التي تريد تغييرها. تميل الفصول الدراسية إلى البقاء قصيرة جدًا ، لذلك لا أجد نفسي أبدًا في التمرير عبر آلاف الأسطر للعثور على الرمز الذي أبحث عنه.
أفضل ما في الأمر ، أنا قادر على الحصول على محرك واحد مرن بما يكفي لاستخدامه في جميع الألعاب التي أقوم بها ، مما يوفر لي الكثير من الوقت.