الفصل 16

مشروع: لعبة منصات

كل الواقع لعبة.

— إيان بانكس، لاعب الألعاب (The Player of Games)

رسم توضيحي يُظهر شخصية لعبة حاسوب تقفز فوق الحمم في عالم ثنائي الأبعاد

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

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

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

اللعبة

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

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

يمثل الصندوق الداكن اللاعب، ومهمته جمع الصناديق الصفراء (العملات) مع تجنب الأشياء الحمراء (الحمم). ويُكتمل المستوى عند جمع كل العملات.

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

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

التقنية

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

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

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

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

وفي الفصل التالي، سنستكشف تقنية أخرى من تقنيات المتصفح، وهي وسم <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") {
          let pos = new Vec(x, y);
          this.startActors.push(type.create(pos, ch));
          type = "empty";
        }
        return type;
      });
    });
  }
}

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

إذن يحمل rows مصفوفة من مصفوفات المحارف، أي أسطر الخطة. ويمكننا استنتاج عرض المستوى وارتفاعه منها. لكن يجب علينا مع ذلك فصل العناصر المتحركة عن شبكة الخلفية. سنسمي العناصر المتحركة فاعلين (actors). وستُخزَّن في مصفوفة من الكائنات. أما الخلفية فستكون مصفوفة من مصفوفات النصوص، تحمل أنواع الخانات مثل "empty" أو "wall" أو "lava".

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

لتفسير المحارف في الخطة، يستخدم باني Level كائن levelChars، الذي يحمل لكل محرف مستخدم في وصف المستويات نصاً إذا كان نوع خلفية، وصنفاً إذا كان ينتج فاعلاً. وعندما يكون type صنف فاعل، تُستخدم طريقته الساكنة create لإنشاء كائن يُضاف إلى startActors، وتعيد دالة التحويل "empty" لخانة الخلفية هذه.

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

أثناء سير اللعبة، سينتهي الفاعلون في مواضع مختلفة أو سيختفون تماماً (كما تفعل العملات عند جمعها). وسنستخدم صنف 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" عند انتهاء اللعبة.

وهذه مرة أخرى بنية بيانات مستدامة — فتحديث حالة اللعبة ينشئ حالة جديدة ويترك القديمة سليمة.

الفاعلون

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

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

تحتوي خاصية 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، لذا نخزّنها على النموذج الأولي (prototype) بدلاً من النسخ نفسها. كان يمكننا استخدام دالة جلب مثل type، لكن ذلك سينشئ ويعيد كائن Vec جديداً في كل مرة تُقرأ فيها الخاصية، وهذا إهدار. (النصوص، لكونها غير قابلة للتغيير، لا يلزم إعادة إنشائها في كل مرة تُقيَّم فيها.)

عند إنشاء فاعل Lava، نحتاج إلى تهيئة الكائن بشكل مختلف حسب المحرف الذي يستند إليه. تتحرك الحمم الديناميكية بسرعتها الحالية حتى تصطدم بعائق. وعندئذ، إذا كانت تملك خاصية reset، فستقفز عائدة إلى موضع انطلاقها (تقاطر). وإذا لم تملكها، فستعكس سرعتها وتواصل في الاتجاه الآخر (ارتداد).

تنظر طريقة 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);

رأينا في الفصل 14 أن 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

المهمة المقبلة هي عرض مستويات كهذه على الشاشة ونمذجة الزمن والحركة داخلها.

الرسم

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

يرسم كائن عرض اللعبة مستوى وحالة معطيين. ونمرر بانيه إلى اللعبة للسماح باستبداله. وصنف العرض الذي نعرّفه في هذا الفصل اسمه DOMDisplay لأنه يستخدم عناصر DOM لإظهار المستوى.

سنستخدم ورقة أنماط لضبط الألوان الفعلية وغيرها من الخصائص الثابتة للعناصر المكوّنة للعبة. كان ممكناً أيضاً الإسناد المباشر إلى خاصية 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;
}

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

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 عدد البكسلات التي تشغلها وحدة واحدة على الشاشة.

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>). وتستخدم الشيفرة معامل النشر (النقاط الثلاث) لتمرير مصفوفات من العقد الفرعية إلى 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) تُستخدم لكبت السلوك الافتراضي غير المرغوب. فنحن لا نريد أن يعتمد تخطيط الجدول على محتويات خلاياه، ولا نريد فراغاً بين خلايا الجدول أو حشواً داخلها.

تضبط القاعدة 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;
}

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

لا يمكننا افتراض أن المستوى يتسع دائماً داخل منطقة العرض (viewport)، أي العنصر الذي نرسم اللعبة فيه. ولهذا نحتاج إلى استدعاء scrollPlayerIntoView: فهو يضمن أنه إذا كان المستوى يبرز خارج منطقة العرض، نمرر تلك المنطقة لضمان أن يكون اللاعب قرب مركزها. تمنح شيفرة 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;

  // منطقة العرض
  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 سيقيدها إلى قيم مقبولة. فضبط scrollLeft على -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 وحدة. وبتقريب أضلاع الصندوق لأعلى ولأسفل، نحصل على نطاق مربعات الخلفية التي يلامسها الصندوق.

مخطط يُظهر شبكة متراكب عليها صندوق أسود. جميع مربعات الشبكة المغطاة جزئياً بالكتلة معلَّمة.

نمر بحلقة على كتلة مربعات الشبكة التي وجدناها بتقريب الإحداثيات، ونعيد 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، التي رأيناها في الفصل 14، طريقة جيدة لتحريك لعبة. لكن واجهتها بدائية إلى حد كبير — فاستخدامها يتطلب منا تتبع الوقت الذي استُدعيت فيه دالتنا في المرة السابقة واستدعاء 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 وباني عرض وتعيد وعداً (promise). وهي تعرض المستوى (في 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، كما رأينا في الفصل 11. وهي تعيد وعداً آخر يُحلّ عند إنهاء اللاعب للعبة.

تتوفر مجموعة من خطط المستويات في الارتباط GAME_LEVELS في بيئة تجريبية لهذا الفصل. وتغذّي هذه الصفحة runGame بها، فتبدأ لعبة فعلية.

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

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

جرّب أن تهزمها. لقد استمتعت ببنائها.

التمارين

انتهت اللعبة

من التقليدي في ألعاب المنصات أن يبدأ اللاعب بعدد محدود من الأرواح ويُخصم منها روح واحدة في كل مرة يموت فيها. وعندما تنفد أرواح اللاعب، تُعاد اللعبة من البداية.

عدّل runGame لتنفيذ الأرواح. واجعل اللاعب يبدأ بثلاثة. وأخرج عدد الأرواح الحالي (باستخدام console.log) في كل مرة يبدأ فيها مستوى.

<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 ارتباط عام حالياً، وتُبقى معالجات أحداثه موجودة حتى عندما لا تكون هناك لعبة قيد التشغيل. يمكنك القول إنها تتسرب خارج نظامنا. وسّع trackKeys لتوفر طريقة لإلغاء تسجيل معالجاتها، ثم غيّر runLevel لتسجل معالجاتها عند بدئها وتلغي تسجيلها مجدداً عند انتهائها.

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

<body>
<script>
  // دالة 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;
        }
      });
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
إظهار التلميح

يمكن قطع الرسوم المتحركة بإعادة false من الدالة المعطاة إلى runAnimation. ويمكن متابعتها باستدعاء runAnimation مجدداً.

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

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

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

وحش

من التقليدي في ألعاب المنصات وجود أعداء يمكنك هزيمتهم بالقفز فوقهم. يطلب منك هذا التمرين إضافة نوع فاعل كهذا إلى اللعبة.

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

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

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

<body>
  <script>
    // أكمل الباني وطريقتَي 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>
إظهار التلميح

إذا أردت تنفيذ نوع من الحركة يحتفظ بحالة، كالارتداد، فتأكد من تخزين الحالة اللازمة في كائن الفاعل — أدرجها كمعطى للباني وأضفها كخاصية.

تذكّر أن update تعيد كائناً جديداً بدلاً من تغيير الكائن القديم.

عند التعامل مع التصادم، ابحث عن اللاعب في state.actors وقارن موضعه بموضع الوحش. للحصول على قاعدة اللاعب، عليك إضافة حجمه العمودي إلى موضعه العمودي. وسيشبه إنشاء الحالة المحدّثة إما طريقة collide في Coin (إزالة الفاعل) أو طريقة Lava (تغيير الحالة إلى "lost")، حسب موضع اللاعب.