اذهب إلى المحتوى

مشروع لعبة منصة باستخدام جافاسكربت


أسامة دمراني
اقتباس

ما الواقع إلا لعبة كبيرة.

ـــ إيان بانكس Iain Banks، لاعب الألعاب The Player of Games

chapter_picture_16.jpg

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

سنتعلم هنا كيفية كتابة لعبة منصة صغيرة، وتُعَدّ لعب المنصات platform games -أو ألعاب "اقفز واركض"- ألعابًا تتوقع من اللاعب تحريك شخصية في عالم افتراضي يكون ثنائي الأبعاد غالبًا، كما يكون منظور اللعبة من الجانب مع القفز على عناصر وكائنات أو القفز خلالها.

اللعبة

ستبنى لعبتنا بصورة ما على Dark Blue التي كتبها توماس بالِف Thomas Palef، وقد اخترنا هذه اللعبة لأنها مسلية وصغيرة الحجم في نفس الوقت، كما يمكن بناؤها دون كتابة الكثير من الشيفرات وستبدو في النهاية هكذا:

darkblue.png

يمثَّل اللاعب بالصندوق الداكن الذي تكون مهمته جمع الصناديق الصفراء -أي العملات النقدية- مع تجنب الكائنات الحمراء -أي الحمم البركانية-، ويكتمل المستوى حين تُجمع كل العملات النقدية.

يستطيع اللاعب التحرك في اللعبة باستخدام أسهم اليمين واليسار، كما يستطيع القفز باستخدام زر السهم الأعلى، ويكون للقفز هنا ميزةً ليست واقعيةً لكنها تعطي اللاعب إحساس التحكم بالشخصية التي على الشاشة، وهي أن الشخصية تستطيع القفز لمسافة أكبر بعدة مرات من طولها، كما تستطيع تغيير اتجاهها في الهواء.

تتكون اللعبة من خلفية ثابتة توضع في هيئة شبكة خلفية، وتكون العناصر المتحركة فوق تلك الخلفية، كما يكون كل حقل في تلك الشبكة إما خاليًا أو مصمتًا أو يحوي حممًا؛ أما العناصر المتحركة فتكون اللاعب أو العملات أو أجزاء من الحمم، كما لا تتقيد مواضع تلك العناصر بالشبكة بل قد تكون إحداثياتها أعدادًا كسْريةً لتسمح لها بالحركة بنعومة.

التقنية

سنستخدم نموذج كائن المستند DOM الخاص بالمتصفح لعرض اللعبة، وسنقرأ مدخلات المستخدِم من خلال معالجة أحداث المفاتيح.

تمثِّل الشيفرة المتعلقة بالشاشة ولوحة المفاتيح جزءًا صغيرًا من العمل الذي علينا تنفيذه لبناء هذه اللعبة، ولن يكون رسم اللعبة صعبًا بما أنها تتكون من صناديق ملونة في الغالب، حيث سننشئ عناصر DOM وسنستخدم التنسيقات لنعطيها لون خلفية وحجمًا وموضعًا أيضًا.

تمثَّل الخلفية على أساس جدول بما أنها شبكة مربعات ثابتة؛ أما العناصر التي تتحرك بحرية فتمثَّل باستخدام عناصر مطلقة الموضع absolutely positioned، وبما أننا نريد تمثيل مدخلات اللاعب والتجاوب معها دون تأخير ملحوظ فستكون الكفاءة مهمة هنا، ورغم أن DOM لم يُصمم للرسوميات عالية الأداء، إلا أن أداءه أفضل مما هو متوقع، فقد رأينا بعض ذلك في مقال نموذج كائن المستند في جافاسكريبت، وسيكون أداء مثل هذه اللعبة ممتازًا على حاسوب حديث حتى لو لم نحسِّن أداءها كثيرًا.

لكن مع هذا فسننظر في تقنية أخرى للمتصفحات في المقال التالي، وهي وسم <canvas> الذي يوفر طريقةً تقليديةً أكثر لتصميم الرسوميات، إذ يتعامل مع الأشكال والبكسلات بدلًا من عناصر DOM.

المستويات

نريد طريقةً لتحديد مستويات اللعبة بحيث يسهل للمستخدِم قراءتها وتعديلها أيضًا، وبما أن كل شيء هنا يمكن إنشاؤه على شبكة، فسنستخدم سلاسل نصية طويلة يمثِّل كل محرف فيها عنصرًا، بحيث يكون إما جزءًا من شبكة الخلفية أو عنصرًا متحركًا، كما سيبدو السطح الذي يمثل أحد المستويات الصغيرة كما يلي:

let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;

تمثِّل النقاط المساحات الفارغة، وتمثِّل محارف الشباك # الحوائط؛ أما علامات الجمع فتمثِّل الحمم البركانية، ويكون موضع بدء اللاعب عند العلامة @، كما يمثِّل كل محرف O في المستوى هنا عملة نقدية، وتمثل علامة = كتلةً من الحمم تتحرك جيئة وذهابًا بصورة أفقية.

سنضيف نوعين آخرين من الحمم المتحركة، حيث سيمثِّل الأول محرف الأنبوب | للحمم المتحركة رأسيًا، ومحرف v للحمم المتساقطة، وهي حمم متحركة رأسيًا لا تتردد بين نقطتين، وإنما تتحرك للأسفل فقط قافزةً إلى نقطة بدايتها حين تصل إلى القاع.

تتكون اللعبة كلها من مستويات عدة يجب على اللاعب إكمالها، ويكتمل المستوى حين تُجمع كل العملات كما ذكرنا؛ أما إذا لمس اللاعب حممًا بركانيةً فسيعود المستوى الحالي إلى نقطة البداية ليحاول اللاعب مرةً أخرى.

قراءة المستوى

يخزن الصنف التالي كائن المستوى، وسيكون وسيطه هو السلسلة النصية التي تعرِّف المستوى.

class Level {
  constructor(plan) {
    let rows = plan.trim().split("\n").map(l => [...l]);
    this.height = rows.length;
    this.width = rows[0].length;
    this.startActors = [];

    this.rows = rows.map((row, y) => {
      return row.map((ch, x) => {
        let type = levelChars[ch];
        if (typeof type == "string") return type;
        this.startActors.push(
          type.create(new Vec(x, y), ch));
        return "empty";
      });
    });
  }
}

يُستخدَم التابع trim لحذف المسافات الفارغة في بداية ونهاية السلسلة النصية لسطح المستوى level plan، وهذا يسمح للسطح في مثالنا أن يبدأ بسطر جديد كي تكون جميع الأسطر تحت بعضها مباشرةً، ثم تقسَّم السلسلة الباقية بمحارف أسطر جديدة، وينتشر كل سطر في مصفوفة لتكون عندنا مصفوفات من المحارف، وبناءً عليه تحمل rows مصفوفةً من مصفوفات المحارف تكون هي صفوف سطح المستوى، كما نستطيع أخذ عرض المستوى وطوله منها، لكن لا زال علينا فصل العناصر المتحركة من شبكة الخلفية.

سنسمي العناصر المتحركة باسم الكائنات الفاعلة أو actors، والتي ستخزَّن في مصفوفة من الكائنات؛ أما الخلفية فستكون مصفوفةً من مصفوفات سلاسل نصية تحمل أنواعًا من الحقول مثل "empty" أو "wall" أو "lava".

سنمر على الصفوف ثم على محتوياتها من أجل إنشاء تلك المصفوفات، وتذكَّر أنّ map تمرِّر فهرس المصفوفة على أنه الوسيط الثاني إلى دالة الربط mapping function التي تخبرنا إحداثيات x وy لأي عنصر، كما ستخزَّن المواضع في اللعبة على أساس أزواج من الإحداثيات بحيث يكون الزوج الأعلى إلى اليسار هو 0,0، ثم يكون عرض وارتفاع كل مربع في الخلفية هو وحدة واحدة.

يستخدِم الباني level كائن levelChars لاعتراض الكائنات في سطح المستوى، وهو يربط عناصر الخلفية بالسلاسل، ويربط المحارف الفاعلة actor characters بالأصناف. وحين يكون type صنفَ كائن فاعل actor، فسيُستخدم التابع الساكن create الخاص به لإنشاء كائن يضاف إلى startActors، ثم تعيد دالة الربط "empty" لمربع الخلفية ذاك.

يخزَّن موضع الكائن الفاعل على أساس كائن Vec الذي هو متجه ثنائي الأبعاد، أي كائن له خصائص x وy كما رأينا في مقال الحياة السرية للكائنات في جافاسكريبت.

ستتغير مواضع الكائنات الفاعلة مع تشغيل اللعبة لتكون في أماكن مختلفة أو حتى تختفي تمامًا كما في حال العملات عند جمعها، ولهذا سنستخدم الصنف State لتتبّع حالة اللعبة أثناء عملها.

class State {
  constructor(level, actors, status) {
    this.level = level;
    this.actors = actors;
    this.status = status;
  }

  static start(level) {
    return new State(level, level.startActors, "playing");
  }

  get player() {
    return this.actors.find(a => a.type == "player");
  }
}

ستتغير الخاصية status لتكون "lost" أو "won" عند نهاية اللعبة، ونكون هنا مرةً أخرى أمام هيكل بيانات ثابت، إذ ينشئ تحديث حالة اللعبة حالةً جديدةً ويترك القديمة كما هي.

الكائنات الفاعلة Actors

تمثل الكائنات الفاعلة الموضع والحالة الحاليَين لعنصر معطى في اللعبة، وتعمل كلها بالواجهة نفسها، كما تحمل الخاصية pos الخاصة بها إحداثيات الركن العلوي الأيسر للعنصر، في حين تحمل خاصية size حجمه.

ثم إن لديها التابع update الذي يُستخدَم لحساب حالتها الجديدة وموضعها كذلك بعد خطوة زمنية معطاة، ويحاكي الإجراء الذي يأخذه الكائن الفاعل -الاستجابة لأزرار الأسهم والتحرك وفقها بالنسبة للاعب، والقفز للأمام أو الخلف بالنسبة للحمم-، ويعيد كائن فاعل actor جديدًا ومحدَّثًا.

تحتوي الخاصية type على سلسلة نصية تعرف نوع الكائن الفاعل سواءً كان "player" أو "coin" أو "lava"، وهذا مفيد عند رسم اللعبة، حيث سيعتمد مظهر المستطيل المرسوم من أجل كائن فاعل على نوعه.

تحتوي أصناف الكائنات الفاعلة على التابع الساكن create الذي يستخدمه الباني Level لإنشاء كائن فاعل من شخصية في مستوى السطح، كما يعطى إحداثيات الشخصية والشخصية نفسها، إذ هي مطلوبة لأن صنف Lava يعالج عدة شخصيات مختلفة.

لدينا فيما يلي الصنف Vec الذي سنستخدمه من أجل قيمنا ثنائية البعد مثل موضع الكائنات الفاعلة وحجمها.

class Vec {
  constructor(x, y) {
    this.x = x; this.y = y;
  }
  plus(other) {
    return new Vec(this.x + other.x, this.y + other.y);
  }
  times(factor) {
    return new Vec(this.x * factor, this.y * factor);
  }
}

يغيِّر التابع times حجم المتجه بعدد معيّن، والذي سيفيدنا حين نحتاج إلى زيادة متجه السرعة بضربه في مدة زمنية لنحصل على المسافة المقطوعة خلال تلك المدة.

تحصل الأنواع المختلفة من الكائنات الفاعلة على أصنافها الخاصة بما أنّ سلوكها مختلف. دعنا نعرِّف تلك الأصناف وسننظر لاحقًا في توابع update الخاصة بها. سيكون لصنف اللاعب الخاصية speed التي تخزن السرعة الحالية لتحاكي قوة الدفع والجاذبية.

class Player {
  constructor(pos, speed) {
    this.pos = pos;
    this.speed = speed;
  }

  get type() { return "player"; }

  static create(pos) {
    return new Player(pos.plus(new Vec(0, -0.5)),
                      new Vec(0, 0));
  }
}

Player.prototype.size = new Vec(0.8, 1.5);

يكون الموضع الابتدائي للاعب فوق الموضع الذي يظهر فيه محرف @ بنصف مربع، وذلك لأن طول اللاعب يساوي مربعًا ونصف، وتكون بهذه الطريقة قاعدته بمحاذاة قاعدة المربع الذي يظهر فيه.

كذلك تكون الخاصية size هي نفسها لجميع نُسخ Player، لذا نخزنها في النموذج الأولي بدلًا من النُسخ نفسها، وقد كان بإمكاننا استخدام جالبة مثل type، ولكنه سينشئ ويعيد ذلك كائن Vec جديد في كل مرة تُقرأ فيها الخاصية، وهو هدر لا حاجة له، إذ لا نحتاج إلى إعادة إنشاء السلاسل النصية في كل مرة نقيِّمها بما أنها غير قابلة للتغير immutable.

عند إنشاء الكائن الفاعل Lava سنحتاج إلى تهيئته initialization تهيئةً مختلفةً وفقًا للمحرف المبني عليه، فالحمم الديناميكية تتحرك بسرعتها الحالية إلى أن تصطدم بعائق، فإذا كانت لديها الخاصية reset فستقفز إلى موضع البداية -أي تقطير dripping-؛ أما إذا لم تكن لديها، فستعكس سرعتها وتكمل في الاتجاه المعاكس -أي ارتداد bouncing-.

ينظر التابع create في المحرف الذي يمرره الباني Level وينشئ كائن الحمم الفاعل المناسب.

class Lava {
  constructor(pos, speed, reset) {
    this.pos = pos;
    this.speed = speed;
    this.reset = reset;
  }

  get type() { return "lava"; }

  static create(pos, ch) {
    if (ch == "=") {
      return new Lava(pos, new Vec(2, 0));
    } else if (ch == "|") {
      return new Lava(pos, new Vec(0, 2));
    } else if (ch == "v") {
      return new Lava(pos, new Vec(0, 3), pos);
    }
  }
}

Lava.prototype.size = new Vec(1, 1);

أما كائنات Coin الفاعلة فهي بسيطة نسبيًا، إذ تظل في مكانها لا تتحرك، لكن سنعطيها تأثيرًا متمايلًا بحيث تتحرك رأسيًا جيئةً وذهابًا، كما يخزن كائن العملة موضعًا أساسيًا وخاصية wobble تتتَّبع مرحلة حركة الارتداد من أجل تتبعها، ويحددان معًا الموضع الحقيقي للعملة ويُخزَّن في الخاصية pos.

class Coin {
  constructor(pos, basePos, wobble) {
    this.pos = pos;
    this.basePos = basePos;
    this.wobble = wobble;
  }

  get type() { return "coin"; }

  static create(pos) {
    let basePos = pos.plus(new Vec(0.2, 0.1));
    return new Coin(basePos, basePos,
                    Math.random() * Math.PI * 2);
  }
}

Coin.prototype.size = new Vec(0.6, 0.6);

رأينا في نموذج كائن المستند في جافاسكريبت أنّ Math.sin تعطينا إحداثية y لنقطة ما في دائرة، وتتذبذب تلك الإحداثية في حركة موجية ناعمة أثناء الحركة على الدائرة، مما يعطينا دالةً جيبيةً نستفيد منها في نمذجة الحركة الموجية.

سنجعل مرحلة بدء كل عملة عشوائية، وذلك لكي نتفادى صنع حركة متزامنة للعملات، ويكون عرض الموجة التي تنتجها Math.sin هو 2π وهي مدة الموجة، ثم نضرب القيمة المعادة بـ Math.random بذلك العدد لتعطي العملة موضع بدء عشوائي على الموجة.

نستطيع الآن تعريف كائن levelChars الذي يربط محارف السطح لأنواع شبكة الخلفية أو لأصناف الكائنات الفاعلة.

const levelChars = {
  ".": "empty", "#": "wall", "+": "lava",
  "@": Player, "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

يعطينا ذلك جميع الأجزاء التي نحتاجها لإنشاء نسخة Level.

let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9

ستكون المهمة التالية هي عرض تلك المستويات على الشاشة وضبط الوقت والحركة فيها.

مشكلة التغليف

لا يؤثر التغليف encapsulation على أغلب الشيفرات الموجودة في هذا المقل لسببين، أولهما أنه يأخذ جهدًا إضافيًا فيجعل البرامج أكبر ويتطلب مفاهيم وواجهات إضافية، وبما أن تلك شيفرات كثيرة على القارئ، فقد اجتهدنا في تصغير البرنامج وتبسيطه؛ أما الثاني أن ثمة عناصر عديدة في اللعبة ترتبط ببعضها، بحيث إذا تغير سلوك أحدها، فمن الصعب بقاء غيرها كما هو، وسيجعل ذلك الواجهات تطرح الكثير من الافتراضات عن طريقة عمل اللعبة، مما يقلل من فائدتها، فإذا غيرنا جزءًا من النظام، فسيكون علينا مراقبة تأثير ذلك في الأجزاء الأخرى بما أنّ واجهاتها لن تغطي الموقف الجديد.

بعض النقاط الفاصلة تقبل الانفصال من خلال واجهات صارمة، لكن هذا ليس حال كل النقاط لدينا، كما سنهدر طاقةً كبيرةً في محاولة تغليف شيء لا يكون حدًا مناسبًا، وسنلاحظ إذا ارتكبنا ذلك الخطأ أنّ واجهاتنا صارت كبيرةً وكثيرة التفاصيل، كما ستحتاج إلى التغيير كل حين كلما تغير البرنامج.

لكن ثمة شيء سنغلفه هو النظام الفرعي للرسم، وذلك لأننا سنعرض اللعبة نفسها بطريقة مختلفة في المقال التالي، فإذا وضعنا الرسم خلف واجهة، فسنتمكن من تحميل برنامج اللعبة نفسه، ونلحقه بوحدة العرض الجديدة.

الرسم

تُغلَّف شيفرة الرسم من خلال تعريف كائن عرض display object يعرض مستوى ما وحالته، وسيكون اسم نوع العرض الذي نعرّفه في هذا المقال هو DOMDisplay بما أنه يستخدم عناصر DOM لعرض المستوى، كما سنستخدم ورقة أنماط style sheet لضبط الألوان الفعلية والخصائص الثابتة الأخرى للعناصر التي ستشكِّل اللعبة، كذلك يمكن تعيين الخاصية style للعناصر مباشرةً حين ننشئها لكن سينتج ذلك برامجًا أكثر إسهابًا.

توفر الدالة المساعدة التالية طريقةً موجزةً لإنشاء عنصر وتعطيه بعض السمات والعقد الفرعية:

function elt(name, attrs, ...children) {
  let dom = document.createElement(name);
  for (let attr of Object.keys(attrs)) {
    dom.setAttribute(attr, attrs[attr]);
  }
  for (let child of children) {
    dom.appendChild(child);
  }
  return dom;
}

يُنشأ العرض بإعطائه كائن مستوى وعنصرًا أبًا parent element ليلحِق نفسه به.

class DOMDisplay {
  constructor(parent, level) {
    this.dom = elt("div", {class: "game"}, drawGrid(level));
    this.actorLayer = null;
    parent.appendChild(this.dom);
  }

  clear() { this.dom.remove(); }
}

تُرسم شبكة الخلفية للمستوى مرةً واحدةً بما أنها لن تتغير، ويعاد رسم الكائنات الفاعلة في كل مرة يُحدّث فيها العرض بحالة ما، كما سنستخدم الخاصية actorLayer لتتبّع العنصر الذي يحمل الكائنات الفاعلة، بحيث يمكن حذفها واستبدالها بسهولة؛ كما ستُتتبّع إحداثياتنا وأحجامنا بوحدات الشبكة، حيث يشير الحجم أو المسافة التي تساوي 1 إلى كتلة شبكة واحدة، ويجب أن نزيد حجم الإحداثيات حين نضبط أحجام البكسلات، إذ سيبدو كل شيء صغيرًا في اللعبة إذا جعلنا كل بكسل يقابل مربعًا واحدًا، وسيعطينا الثابت scale عدد البكسلات التي تأخذها واحدةً واحدة single unit على الشاشة.

const scale = 20;

function drawGrid(level) {
  return elt("table", {
    class: "background",
    style: `width: ${level.width * scale}px`
  }, ...level.rows.map(row =>
    elt("tr", {style: `height: ${scale}px`},
        ...row.map(type => elt("td", {class: type})))
  ));
}

تُرسم الخلفية على أساس عنصر <table>، ويتوافق ذلك بسلاسة مع هيكل الخاصية rows للمستوى، فقد حُول كل صف في الشبكة إلى صف جدول -أي عنصر <tr>-؛ أما السلاسل النصية في الشبكة فتُستخدم على أساس أسماء أصناف لخلية الجدول -أي <td>-، كما يُستخدم عامل النشر spread operator -أي النقطة الثلاثية- لتمرير مصفوفات من العقد الفرعية إلى elt لفصل الوسائط.

يوضح المثال التالي كيف نجعل الجدول يبدو كما نريد من خلال شيفرة CSS:

.background    { background: rgb(52, 166, 251);
                 table-layout: fixed;
                 border-spacing: 0;              }
.background td { padding: 0;                     }
.lava          { background: rgb(255, 100, 100); }
.wall          { background: white;              }

تُستخدم بعض الوسوم مثل table-layout وborder-spacing وpadding، لمنع السلوك الافتراضي غير المرغوب فيه، فلا نريد لتخطيط الجدول أن يعتمد على محتويات خلاياه، كما لا نريد مسافات بين خلايا الجدول أو تبطينًا padding داخلها.

تضبط قاعدة background لون الخلفية، إذ تسمح CSS بتحديد الألوان على أساس كلمات -مثل white- أو بصيغة مثل ‎rgb(R, G, B)‎‎‎، حيث تُفصل مكونات اللون الحمراء والخضراء والزرقاء إلى ثلاثة أعداد من 0 إلى 255. ففي اللون ‎rgb(52, 166, 251)‎ مثلًا، سيكون مقدار المكون الأحمر 52 والأخضر 166 والأزرق 251، وبما أن مقدار الأزرق هو الأكبر، فسيكون اللون الناتج مائلًا للزرقة، ويمكن رؤية ذلك في القاعدة ‎.lava، إذ أنّ أول عدد فيها -أي الأحمر- هو الأكبر.

سنرسم كل كائن فاعل بإنشاء عنصر DOM له وضبط موضع وحجم ذلك العنصر بناءً على خصائص الكائن الفاعل، ويجب ضرب القيم في scale لتُحوَّل من وحدات اللعبة إلى بكسلات.

function drawActors(actors) {
  return elt("div", {}, ...actors.map(actor => {
    let rect = elt("div", {class: `actor ${actor.type}`});
    rect.style.width = `${actor.size.x * scale}px`;
    rect.style.height = `${actor.size.y * scale}px`;
    rect.style.left = `${actor.pos.x * scale}px`;
    rect.style.top = `${actor.pos.y * scale}px`;
    return rect;
  }));
}

تُفصل أسماء الأصناف بمسافات كي نعطي العنصر الواحد أكثر من صنف، ففي شيفرة CSS أدناه سنرى أن الصنف actor يعطي الكائنات الفاعلة موضعها المطلق، كما يُستخدم اسم نوعها على أساس صنف إضافي ليعطيها لون، ولا نريد تعريف صنف lava مرةً أخرى بما أننا سنعيد استخدامه من أجل مربعات شبكة الحمم التي عرّفناها سابقًا.

.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }

يُستخدَم التابع syncState لجعل العرض يظهر حالةً ما، وهو يحذف رسوميات الكائن الفاعل القديم أولًا إذا وُجدت، ثم يعيد رسم الكائنات الفاعلة في مواضعها الجديدة.

قد يكون من المغري استخدام عناصر DOM للكائنات الفاعلة، لكننا سنحتاج إلى الكثير من الحسابات الإضافية إذا أردنا إنجاح ذلك من أجل ربطها مع عناصر DOM، ولضمان حذف العناصر حين تختفي كائناتها الفاعلة، وعلى أيّ حال فليست لدينا كائنات فاعلة كثيرة في اللعبة، وبالتالي لن تكون إعادة رسمها مكلفةً.

DOMDisplay.prototype.syncState = function(state) {
  if (this.actorLayer) this.actorLayer.remove();
  this.actorLayer = drawActors(state.actors);
  this.dom.appendChild(this.actorLayer);
  this.dom.className = `game ${state.status}`;
  this.scrollPlayerIntoView(state);
};

نستطيع تخصيص الكائن الفاعل للاعب تخصيصًا مختلفًا حين تفوز اللعبة أو تخسر بإضافة حالة المستوى الحالية، مثل اسم صنف إلى المغلِّف، وذلك من خلال إضافة قاعدة CSS لا تأخذ ذلك التأثير إلا عندما يكون للاعب عنصر سلف بصنف ما.

.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

يتغير لون اللاعب إلى الأحمر الداكن بعد لمس الحمم ليشير إلى الحرق، كما نضيف إليه هالةً بيضاء حوله إذا جمع كل العملات، وذلك بإضافة ظلَّين أبيضين ضبابيين، بحيث يكون واحدًا أعلى يساره والثاني أعلى يمينه.

لا يمكن افتراض تلاؤم المستوى مع نافذة الرؤية على الدوام، ونافذة الرؤية هي العنصر الذي سنرسم اللعبة داخله، لذا نحتاج إلى استدعاء scrollPlayerIntoView الذي يضمن أننا سنمرر scroll نافذة الرؤية إذا كان المستوى سيخرج عنها إلى أن يصير اللاعب قريبًا من مركزها.

تعطي شيفرة CSS التالية قيمة حجم عظمى لعنصر DOM المغلِّف الخاص باللعبة، ويضمن ألا يُرى شيء خارج صندوق العنصر، كما سنعطيه موضعًا نسبيًا كي تكون مواضع الكائنات الفاعلة داخلها منسوبةً إلى الركن العلوي الأيسر من المستوى.

.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}

نبحث عن موضع اللاعب في التابع scrollPlayerIntoView ونحدِّث موضع التمرير للعنصر المغلِّف، كما نغير موضع التمرير بتعديل الخصائص scrollLeft وscrollTop الخاصة بالعنصر حين يقترب اللاعب من الحافة.

DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
  let width = this.dom.clientWidth;
  let height = this.dom.clientHeight;
  let margin = width / 3;

  // The viewport
  let left = this.dom.scrollLeft, right = left + width;
  let top = this.dom.scrollTop, bottom = top + height;

  let player = state.player;
  let center = player.pos.plus(player.size.times(0.5))
                         .times(scale);

  if (center.x < left + margin) {
    this.dom.scrollLeft = center.x - margin;
  } else if (center.x > right - margin) {
    this.dom.scrollLeft = center.x + margin - width;
  }
  if (center.y < top + margin) {
    this.dom.scrollTop = center.y - margin;
  } else if (center.y > bottom - margin) {
    this.dom.scrollTop = center.y + margin - height;
  }
};

تُظهِر الطريقة التي نحدد بها مركز اللاعب كيفية سماح التوابع التي على نوع Vec بكتابة حسابات الكائنات بطريقة قابلة للقراءة نوعًا ما، ونفعل ذلك بإضافة موضع الكائن الفاعل -وهنا ركنه العلوي الأيسر- ونصف حجمه، ويكون هذا هو المركز في إحداثيات المستوى، لكننا سنحتاج إليه في إحدائيات البكسل أيضًا، لذا نضرب المتجه الناتج بمقياس العرض.

تبدأ بعد ذلك سلسلة من التحقُّقات للتأكد من أن موضع اللاعب داخل المجال المسموح به، وقد يؤدي ذلك أحيانًا إلى تعيين إحداثيات تمرير غير منطقية، كأن تكون قيمًا سالبة أو أكبر من مساحة العنصر القابلة للتمرير، ولا بأس في هذا، إذ ستقيد عناصر DOM تلك القيم لتكون في نطاق مسموح به، فإذا ضُبطت srollLeft لتكون ‎-10 مثلًا، فإنها ستتغير لتصير 0.

قد يقال أن الأسهل يكون بجعل اللاعب يُمرَّر دائمًا ليكون في منتصف الشاشة، غير أنّ هذا -وإذا كان أسهل- سيُحدِث أثرًا عكسيًا من حيث تجربة استخدام للعبة، فكلما قفز اللاعب، ستتغير نافذة الرؤية للأعلى والأسفل معه، ولهذا من الأفضل حينها إبقاء منطقة محايدة ثابتة في منتصف الشاشة كي نتحرك دون التسبب في أي تمرير.

نستطيع الآن عرض المستوى الصغير الذي أنشأناه.

<link rel="stylesheet" href="css/game.css">

<script>
  let simpleLevel = new Level(simpleLevelPlan);
  let display = new DOMDisplay(document.body, simpleLevel);
  display.syncState(State.start(simpleLevel));
</script>

يُستخدَم الوسم <link> مع ‎‎rel="stylesheet"‎ لتحميل ملف CSS إلى الصفحة، ويحتوي ملف game.css على الأنماط الضرورية للعبتنا.

الحركة والتصادم

نحن الآن في مرحلة نستطيع فيها إضافة الحركة، وهي لا شك بأنها أكثر جزء مثير في اللعبة، والطريقة التي تتبعها أغلب الألعاب التي تشبه لعبتنا هي تقسيم الوقت إلى خطوات صغيرة ونحرك الكائنات الفاعلة بمسافة تتوافق مع سرعتها ومضروبة في حجم الخطوة الزمنية، كما سنحسب الزمن بالثواني، وعليه سيعبَّر عن السرعات بوحدات لكل ثانية.

يُعَدّ تحريك الكائنات أمرًا يسيرًا؛ أما التعامل مع التفاعلات التي يجب حدوثها بين العناصر فهو الأمر الصعب، فإذا اصطدم اللاعب بجدار أو أرضية، فلا يجب المرور من خلالها، بل تنتبه اللعبة إذا تسببت حركة ما في ارتطام كائن بآخر، ثم تتصرف وفق الحالة، فتتوقف الحركة مثلًا بالنسبة للجدران؛ أما إذا اصطدم بعملة ما فيجب جمعها، وإذا لمس حممًا بركانية فيجب خسارة اللعبة.

لا شك أن هذه عملية معقدة إذا أردنا ضبط قوانينها، لذا ستجد مكتبات يُطلق عليها عادةً محركات فيزيائية physics engines تحاكي التفاعل بين الكائنات الفيزيائية في بعدين أو ثلاثة أبعاد، ولكن بأي حال سنلقي نظرةً عليها في هذا المقال لنتعرف إليها، حيث سنعالج التصادمات بين الكائنات المستطيلة فقط على أساس تدريب عملي عليها.

ننظر أولًا قبل تحريك اللاعب أو كتلة الحمم هل تأخذه الحركة داخل جدار أم لا، فإذا أخذته؛ فإننا نلغي الحركة بالكلية. تعتمد الاستجابة لمثل ذلك التصادم على نوع الكائن الفاعل نفسه، فاللاعب مثلًا سيقف، بينما ترتد كتلة الحمم في الاتجاه المعاكس.

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

يخبرنا التابع التالي هل يلمس المستطيل -المحدَّد بموضع وحجم- عنصر شبكة من نوع ما أم لا.

Level.prototype.touches = function(pos, size, type) {
  let xStart = Math.floor(pos.x);
  let xEnd = Math.ceil(pos.x + size.x);
  let yStart = Math.floor(pos.y);
  let yEnd = Math.ceil(pos.y + size.y);

  for (let y = yStart; y < yEnd; y++) {
    for (let x = xStart; x < xEnd; x++) {
      let isOutside = x < 0 || x >= this.width ||
                      y < 0 || y >= this.height;
      let here = isOutside ? "wall" : this.rows[y][x];
      if (here == type) return true;
    }
  }
  return false;
};

يحسب التابع مجموعة مربعات الشبكة التي يتداخل الجسم معها من خلال استخدام Math.floor وMath.ceil على إحداثياته، وتذكَّر أنّ مربعات الشبكة حجمها 1 * 1 وحدة، فإذا قربنا جوانب الصندوق لأعلى وأسفل سنحصل على مجال مربعات الخلفية التي يلمسها الصندوق.

game-grid.png

سنمر حلقيًا على كتلة مربعات الشبكة التي خرجنا بها من الإحداثيات السابقة، ونعيد القيمة true إذا وجدنا مربعًا مطابِقًا. تُعامَل المربعات التي خارج المستوى على أساس جدار "wall" لضمان عدم خروج اللاعب من العالم الافتراضي، وأننا لن نقرأ أي شيء خارج حدود مصفوفتنا rows بالخطأ.

تابع الحالة update يستخدِم touches لمعرفة هل لمس اللاعب حممًا بركانية أم لا.

State.prototype.update = function(time, keys) {
  let actors = this.actors
    .map(actor => actor.update(time, this, keys));
  let newState = new State(this.level, actors, this.status);

  if (newState.status != "playing") return newState;

  let player = newState.player;
  if (this.level.touches(player.pos, player.size, "lava")) {
    return new State(this.level, actors, "lost");
  }

  for (let actor of actors) {
    if (actor != player && overlap(actor, player)) {
      newState = actor.collide(newState);
    }
  }
  return newState;
};

تُمرَّر كل من الخطوة الزمنية وهيكل البيانات إلى التابع، حيث يخبرنا هيكل البيانات بالمفاتيح المضغوط عليها حاليًا، ويستدعي التابعَ update على جميع الكائنات الفاعلة ليُنتج لنا مصفوفةً من نسخها المحدثة، كما تحصل الكائنات الفاعلة على الخطوة الزمنية والمفاتيح والحالة أيضًا لتتمكن من بناء تحديثها عليها، لكن تلك المفاتيح لا يقرؤها إلا اللاعب نفسه بما أنه هو الكائن الفاعل الوحيد الذي تتحكم به لوحة المفاتيح.

إذا انتهت اللعبة، فلا يكون قد بقي شيء من المعالجة لفعله، أي أن اللعبة يستحيل أن تفوز بعد خسارتها أو العكس؛ أما إذا لم تنتهي، فسيختبر التابع هل لمس اللاعب حمم الخلفية أم لا، فإذا لمسها تخسر اللعبة وتنتهي.

أخيرًا، إذا كانت اللعبة لا تزال قائمةً فسيرى هل تداخلت كائنات فاعلة أخرى مع اللاعب أم لا، ويُكتشف التداخل بين الكائنات الفاعلة باستخدام الدالة overlap التي تأخذ كائنين فاعلين وتعيد true إذا تلامسا فقط، وهي الحالة التي يتداخلا فيها على محوري x وy معًا.

function overlap(actor1, actor2) {
  return actor1.pos.x + actor1.size.x > actor2.pos.x &&
         actor1.pos.x < actor2.pos.x + actor2.size.x &&
         actor1.pos.y + actor1.size.y > actor2.pos.y &&
         actor1.pos.y < actor2.pos.y + actor2.size.y;
}

فإذا تداخل كائن فاعل، فسيحصل التابع collide الخاص به على فرصة لتحديث حالته، ويضبط لمس كائن الحمم حالة اللعبة إلى "lost"؛ أما العملات فستختفي حين نلمسها، وعند لمس العملة الأخيرة تُضبَط حالة اللعبة إلى "won".

Lava.prototype.collide = function(state) {
  return new State(state.level, state.actors, "lost");
};

Coin.prototype.collide = function(state) {
  let filtered = state.actors.filter(a => a != this);
  let status = state.status;
  if (!filtered.some(a => a.type == "coin")) status = "won";
  return new State(state.level, filtered, status);
};

تحديثات الكائنات الفاعلة

تأخذ توابع update الخاصة بالكائنات الفاعلة الخطوة الزمنية وكائن الحالة وكائن keys على أساس وسائط لها، مع استثناء تابع الكائن الفاعل Lava، إذ يتجاهل كائن keys.

Lava.prototype.update = function(time, state) {
  let newPos = this.pos.plus(this.speed.times(time));
  if (!state.level.touches(newPos, this.size, "wall")) {
    return new Lava(newPos, this.speed, this.reset);
  } else if (this.reset) {
    return new Lava(this.reset, this.speed, this.reset);
  } else {
    return new Lava(this.pos, this.speed.times(-1));
  }
};

يحسب التابع update موضعًا جديدًا بإضافة ناتج الخطوة الزمنية والسرعة الحالية إلى الموضع القديم، فإذا لم تحجب ذلك الموضع الجديد أية عوائق فسينتقل إليه؛ أما إذا وُجد عائق فسيعتمد السلوك حينها على نوع كتلة الحمم، فالحمم المتساقطة لديها الموضع reset الذي تقفز إليه حين تصطدم بشيء ما؛ أما الحمم المرتدة، فتعكس سرعتها وتضربها في ‎-1 كي تبدأ بالحركة في الاتجاه المعاكس.

تستخدِم العملات كذلك التابع update من أجل تأثير التمايل، فتتجاهل التصادمات مع الشبكة بما أنها تتمايل في المربع نفسه.

const wobbleSpeed = 8, wobbleDist = 0.07;

Coin.prototype.update = function(time) {
  let wobble = this.wobble + time * wobbleSpeed;
  let wobblePos = Math.sin(wobble) * wobbleDist;
  return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
                  this.basePos, wobble);
};

تتزايد الخاصية wobble لتراقب الوقت، ثم تُستخدَم على أساس وسيط لـ Math.sin لإيجاد الموضع الجديد على الموجة، ثم يُحسب موضع العملة الحالي من موضعها الأساسي وإزاحة مبنية على هذه الموجة.

يتبقى لنا اللاعب نفسه، إذ تُعالَج حركة اللاعب معالجةً مستقلةً لكل محور، ذلك أنه ينبغي على اصطدامه بالأرض ألا يمثِّل مشكلةً وألا يمنع الحركة الأفقية، كما أن الاصطدام بحائط لا يجب ألا يوقف حركة السقوط أو القفز.

const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;

Player.prototype.update = function(time, state, keys) {
  let xSpeed = 0;
  if (keys.ArrowLeft) xSpeed -= playerXSpeed;
  if (keys.ArrowRight) xSpeed += playerXSpeed;
  let pos = this.pos;
  let movedX = pos.plus(new Vec(xSpeed * time, 0));
  if (!state.level.touches(movedX, this.size, "wall")) {
    pos = movedX;
  }

  let ySpeed = this.speed.y + time * gravity;
  let movedY = pos.plus(new Vec(0, ySpeed * time));
  if (!state.level.touches(movedY, this.size, "wall")) {
    pos = movedY;
  } else if (keys.ArrowUp && ySpeed > 0) {
    ySpeed = -jumpSpeed;
  } else {
    ySpeed = 0;
  }
  return new Player(pos, new Vec(xSpeed, ySpeed));
};

تُحسب الحركة الأفقية وفقًا لحالة مفاتيح الأسهم اليمين واليسار، فإذا لم يكن هناك حائط يحجب الموضع الجديد الذي سينشأ بسبب تلك الحركة، فسيُستخدَم؛ وإلا نظل على الموضع القديم.

كذلك الأمر بالنسبة للحركة الرأسية، لكن يجب محاكاة القفز والجاذبية، فتتزايد سرعة اللاعب الرأسية ySpeed أولًا لتعادل الجاذبية (تراجع)، ثم نتحقق من الحوائط مرةً أخرى، فإذا لم نصطدم بحائط فسنستخدم الموضع الجديد؛ أما إذا اصطدمنا بحائط فلدينا احتمالان، وهما إما أن يُضغط زر السهم الأعلى ونحن نتحرك إلى الأسفل، أي أن ما اصطدمنا به كان تحتنا، فتُضبط السرعة على قيمة كبيرة سالبة، وهذا يجعل اللاعب يقفز لأعلى، ويُعَد ما سوى ذلك اصطدام اللاعب بشيء في طريقه، وهنا تتغير السرعة إلى صفر؛ أما قوة الجاذبية وسرعة القفز وغيرها من الثوابت في تلك اللعبة، فتُضبط بالتجربة والخطأ، حيث اختبرنا القيم حتى وصلنا إلى قيم متوافقة مع بعضها بعضًا ومناسبة.

مفاتيح التعقب

لا نريد للمفاتيح أن تُحدِث تأثيرًا واحدًا لكل نقرة عليها، بل نريد أن يظل تأثيرها عاملًا طالما كان المفتاح مضغوطًا، وذلك مفيد في شأن لعبة مثل التي نكتبها من أجل إجراء ما مثل تحريك اللاعب.

سنُعِدّ معالِج مفتاح يخزن الحالة الراهنة لمفاتيح الأسهم الأربعة، كما سنستدعي preventDefault لتلك المفاتيح كي لا تتسبب في تمرير الصفحة.

تعيد الدالة في المثال أدناه كائنًا عند إعطائها مصفوفةً من أسماء المفاتيح، حيث يتعقب الموضع الحالي لتلك المفاتيح ويسجل معالجات أحداث للأحداث "keydown" و"keyup"، وإذا كانت شيفرة المفتاح التي في الحدث موجودةً في مجموعة الشيفرات التي تتعقبها، فستحدِّث الكائن.

function trackKeys(keys) {
  let down = Object.create(null);
  function track(event) {
    if (keys.includes(event.key)) {
      down[event.key] = event.type == "keydown";
      event.preventDefault();
    }
  }
  window.addEventListener("keydown", track);
  window.addEventListener("keyup", track);
  return down;
}

const arrowKeys =
  trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);

تُستخدَم الدالة المعالج نفسها لنوعي الأحداث، إذ تنظر في الخاصية type لكائن الحدث لتحدِّد هل يجب تحديث حالة المفتاح إلى القيمة true -أي "keydown"- أو القيمة false -أي "keyup"-.

تشغيل اللعبة

توفِّر الدالة requestAnimationFrame التي رأيناها في مقال نموذج كائن المستند في جافاسكريبت طريقةً جيدةً لتحريك اللعبة، لكن واجهتها بدائية للغاية، إذ نحتاج إلى تعقب الوقت الذي استدعيت فيه دالتنا في آخر مرة، ثم نستدعي requestAnimationFrame مرةً أخرى بعد كل إطار ولهذا سنعرِّف دالةً مساعدةً تغلّف هذه الأجزاء المملة في واجهة مريحة وعملية، وتسمح لنا باستدعاء runAnimation ببساطة لتعطيها دالةً تتوقع فرق الوقت على أساس وسيط وترسم إطارًا واحدًا، كما تتوقف الحركة إذا أعادت دالة الإطار القيمة false.

function runAnimation(frameFunc) {
  let lastTime = null;
  function frame(time) {
    if (lastTime != null) {
      let timeStep = Math.min(time - lastTime, 100) / 1000;
      if (frameFunc(timeStep) === false) return;
    }
    lastTime = time;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

ضبطنا القيمة العظمى لخطوة الإطار لتكون مساويةً لـ 100 ميلي ثانية -أي عُشر ثانية-، فإذا أُخفيت نافذة المتصفح أو التبويب الذي فيه صفحتنا، فستتوقف استدعاءات requestAnimationFrame إلى أن يُعرَض التبويب أو النافذة مرةً أخرى، ويكون الفرق في هذه الحالة بين lastTime وtime هو الوقت الكلي الذي أُخفيت فيه الصفحة.

لا شك في أن تقدُّم اللعبة بذلك المقدار في خطوة واحدة سيكون سخيفًا وقد يسبب آثارًا جانبيةً غريبةً، كأن يسقط اللاعب داخل الأرضية.

تحوِّل الدالة كذلك الخطوات الزمنية إلى ثواني، وهي أسهل في النظر إليها على أساس كمية عنها إذا كانت بالمللي ثانية، وتأخذ الدالة runLevel الكائن Level وتعرض بانيًا وتُعيد وعدًا، كما تعرض المستوى -في document.body-، وتسمح للمستخدِم باللعب من خلاله، وإذا انتهى المستوى بالفوز أو الخسارة، فستنتظر runLevel زمنًا قدره ثانيةً واحدةً إضافيةً ليتمكن المستخدِم من رؤية ما حدث، ثم تمحو العرض وتوقف التحريك، وتحل الوعد لحالة اللعبة النهائية.

function runLevel(level, Display) {
  let display = new Display(document.body, level);
  let state = State.start(level);
  let ending = 1;
  return new Promise(resolve => {
    runAnimation(time => {
      state = state.update(time, arrowKeys);
      display.syncState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        resolve(state.status);
        return false;
      }
    });
  });
}

تتكون اللعبة من سلسلة من المستويات، بحيث يعاد المستوى الحالي إذا مات اللاعب، وإذا اكتمل مستوى، فسننتقل إلى المستوى التالي، ويمكن التعبير عن ذلك بالدالة التالية التي تأخذ مصفوفةً من أسطح المستويات (سلاسل نصية) وتعرض بانيًا:

async function runGame(plans, Display) {
  for (let level = 0; level < plans.length;) {
    let status = await runLevel(new Level(plans[level]),
                                Display);
    if (status == "won") level++;
  }
  console.log("You've won!");
}

لأننا جعلنا runLevel تعيد وعدًا، فيمكن كتابة runGame باستخدام دالة async كما هو موضح في الحادي عشر، وهي تُعيد وعدًا آخرًا يُحَل عندما يُنهي اللاعب اللعبة.

ستجد مجموعةً من أسطح المستويات متاحةً في رابطة GAME_LEVELS في صندوق الاختبارات الخاص بهذا المقال، وتغذي تلك الصفحة المستويات إلى runGame لتبدأ اللعبة الحقيقية.

<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>

جرب بنفسك لترى ما إذا كنت تستطيع تجاوز هذه المستويات.

تدريبات

انتهاء اللعبة

من المتعارف عليه في ألعاب الحاسوب أنّ اللاعب يبدأ بعدد محدود من فرص الحياة التي تنقص بمقدار حياة واحدة كلما مات في اللعبة، وإذا انتهت الفرص المتاحة، فستعيد اللعبة التشغيل من البداية.

عدِّل runGame لتضع فيها خاصية الحيوات، واجعل اللاعب يبدأ بثلاثة حيوات، ثم أخرج عدد الحيوات الحالي باستخدام console.log في كل مرة يبدأ فيها مستوى.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

<link rel="stylesheet" href="css/game.css">

<body>
<script>
   // دالة runGame القديمة، عدّلها ...
  async function runGame(plans, Display) {
    for (let level = 0; level < plans.length;) {
      let status = await runLevel(new Level(plans[level]),
                                  Display);
      if (status == "won") level++;
    }
    console.log("You've won!");
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

الإيقاف المؤقت للعبة

أضف خاصية الإيقاف المؤقت للعبة والعودة إليها من خلال مفتاح Esc، ويمكن تنفيذ هذا بتغيير دالة runLevel لتستخدِم معالِج حدث لوحة مفاتيح آخر، وتعترض الحركة أو تستعيدها كلما ضغط اللاعب على زر Esc.

قد لا تبدو واجهة runAnimation مناسبةً لهذه الخاصية، لكنها ستكون كذلك إذا أعدت ترتيب الطريقة التي تستدعيها runLevel بها.

إذا تمكنت من تنفيذ ذلك فثمة شيء آخر قد تستطيع فعله، ذلك أنّ الطريقة التي نسجل بها معالِجات الأحداث تسبب لنا مشكلة، فالكائن arrowKeys حاليًا هو رابطة عامة global binding، وتظل معالجات أحداثه باقيةً حتى لو لم تكن هناك لعبة تعمل، فتستطيع القول أنها تتسرب من نظامنا. وسِّع trackKeys من أجل توفير طريقة لتسجيل معالجاتها عندما تبدأ ثم تلغي تسجيلها مرةً أخرى عند انتهائها.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runLevel function. Modify this...
  function runLevel(level, Display) {
    let display = new Display(document.body, level);
    let state = State.start(level);
    let ending = 1;
    return new Promise(resolve => {
      runAnimation(time => {
        state = state.update(time, arrowKeys);
        display.syncState(state);
        if (state.status == "playing") {
          return true;
        } else if (ending > 0) {
          ending -= time;
          return true;
        } else {
          display.clear();
          resolve(state.status);
          return false;
        }
      });
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

إرشادات الحل

يمكن اعتراض الحركة بإعادة false من الدالة المعطاة لـ runAnimation، ويمكن متابعتها باستدعاء runAnimation مرةً أخرى، وهكذا سنحتاج إلى إبلاغ الدالة المعطاة لـ runAnimation أننا سنوقف اللعبة مؤقتًا؛ ولفعل هذا، استخدِم رابطةً يستطيع كل من معالج الحدث والدالة الوصول إليها.

عند البحث عن طريقة لإلغاء تسجيل المعالجات المسجَّلة بواسطة trackKeys، تذكر أنّ قيمة الدالة الممررة نفسها إلى addEventListener يجب تمريرها إلى removeEventListener من أجل حذف معالج بنجاح، وعليه يجب أن تكون قيمة الدالة handler المنشأة في trackKeys متاحةً في الشيفرة التي تلغي تسجيل المعالِجات.

تستطيع إضافة خاصية إلى الكائن المعاد بواسطة trackKeys تحتوي على قيمة الدالة أو على تابع يعالج إلغاء التسجيل مباشرةً.

الوحش

من الشائع أيضًا في ألعاب المنصة أن تحتوي على أعداء تستطيع القفز فوقها لتتغلب عليها، ويطلب منك هذا التدريب إضافة مثل نوع الكائن الفاعل ذلك إلى اللعبة.

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

يتوقف التأثير الواقع على اللاعب إذا لمسه أحد الوحوش بكون اللاعب يقفز فوق الوحش أم لا، ويمكنك تقريب الأمر بالتحقق من قاعدة اللاعب هل هي قريبة من قمة الوحش أم لا، فإذا كانت قريبةً فسيختفي الوحش، وإذا كانت بعيدةً فستخسر اللعبة.

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
  <script>
    // أكمل التوابع التالية: constructor وupdate وcollide
    class Monster {
      constructor(pos, /* ... */) {}

      get type() { return "monster"; }

      static create(pos) {
        return new Monster(pos.plus(new Vec(0, -1)));
      }

      update(time, state) {}

      collide(state) {}
    }

    Monster.prototype.size = new Vec(1.2, 2);

    levelChars["M"] = Monster;

    runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
  </script>
</body>

إرشادات الحل

إذا أردت تنفيذ نوع حركة حالي stateful مثل الارتداد، فتأكد من تخزين الحالة المطلوبة في الكائن الفاعل، بأن تضمِّنها على أساس وسيط باني وتضيفها على أساس خاصية.

تذكر أنّ update تعيد كائنًا جديدًا بدلًا من تغيير الكائن القديم، وابحث عن اللاعب في state.actors عند معالجة اصطدام ووازن موضعه مع موضع الوحش.

للحصول على قاعدة اللاعب يجب عليك إضافة حجمه الرأسي إلى موضعه الرأسي، وسيمثل إنشاء حالة محدثة إما التابع collide الخاص بـ Coin، وهو ما يعني حذف الكائن الفاعل، أو ذلك الخاص بـ Lava، والذي سيغير الحالة إلى "lost" وفقًا لموضع اللاعب.

ترجمة -بتصرف- للفصل السادس عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...