Chapter 16پروژه: یک بازی پرشی

تمام واقعیت یک بازی است.

Iain Banks, The Player of Games
Picture of a game character jumping over lava

بیشتر شیفتگی اولیه من نسبت به کامپیوترها، مثل خیلی از بچه‌های دیگر، از بازی‌های کامپیوتری شروع شد. من جذب دنیاهای شبیه‌سازی شده‌ی کوچکی شدم که می توانستم آن‌ها را اداره کنم و در آن‌ها داستان‌هایی گشوده می‌شد – گمان می کنم بیشتر به خاطر گسترش تخیلاتم به درون بازی‌ها بود تا امکانات و قابلیت‌های خود بازی ها.

من برای هیچ کس حرفه‌ی برنامه نویسی بازی‌های کامپیوتر را آرزو نمی کنم. بسیار شبیه به صنعت موسیقی، تفاوت فاحش بین تعداد زیاد افرادی که دوست دارند در آن کار کنند و تقاضای واقعی برای آن‌ها، باعث ایجاد محیط نسبتا نامناسبی شده است. اما نوشتن بازی‌ها برای تفریح کاری دلچسب است.

در این فصل به سراغ پیاده‌سازی یک بازی پرشی (سکوبازی) کوچک می رویم. سکوبازی‌ها (یا بازی‌های حرکت و پرش)، بازی‌هایی هستند که بازیکن باید یک شخصیت را در جهان بازی حرکت دهد که معمولا این جهان دو بعدی است و از کنار نمایش داده می‌شود و شخصیت از روی (و درون) چیزها می تواند بپرد.

بازی

بازی ما به طور کلی بر پایه‌ی بازی Dark Blue که توسط توماس پالف ساخته شده خواهد بود. من این بازی را انتخاب کردم به دلیل اینکه هم سرگرم‌کننده و هم ساده است، و نیازی نیست برای نوشتن آن کدنویسی خیلی زیادی انجام شود. بازی به شکل زیر خواهد بود.

The game Dark Blue

مستطیل تیره رنگ نمایانگر شخصیت بازی است که وظیفه‌اش جمع آوری مستطیل های زرد (سکه‌ها) بدون برخورد با چیزهای قرمز رنگ (گدازه‌ها) است. یک مرحله بازی زمانی کامل می‌شود که تمامی سکه‌ها جمع آوری شده باشند.

بازیکن می تواند به وسیله‌ی کلیدهای چپ و راست صفحه‌کلید جابجا شود و با فشردن کلید بالا، بپرد. پریدن توانایی خاص این کاراکتر است. می تواند چندین برابر قد خودش بپرد و در هوا جهتش را عوض کند. بازی به طور کامل واقع‌گرایانه نیست اما به بازیکن این حس را القا می کند که کاملا شخصیت بازی تحت کنترلش حرکت می کند.

این بازی از یک پس‌زمینه‌ی ثابت تشکیل شده است که مثل یک grid طرح بندی شده است همراه با عناصر متحرکی که روی پس‌زمینه قرار گرفته اند. هر فیلد از grid یا خالی، یا رنگ‌شده یا یک گدازه است. عناصر متحرک شامل بازیکنان، سکه‌ها و بعضی از گدازه‌ها می شوند. موقعیت این عناصر محدود به grid نیست. – ممکن است مختصاتشان اعشاری باشد که باعث حرکت نرم‌تر آن‌ها خواهد شد.

تکنولوژی مورد استفاده

ما از DOM مرورگر برای نمایش بازی استفاده می کنیم و ورودی کاربر را با مدیریت رخدادهای کلیدها، خواهیم خواند.

کدهای مربوط به صفحه‌ی نمایش و صفحه‌کلید فقط بخشی کوچکی از کاری که برای ساخت این بازی لازم است را شامل می شوند. به دلیل این که همه چیز شبیه به مستطیل‌های رنگی است، مشکل طراحی نداریم: عناصر DOM را ایجاد کرده و با استفاده از سبک‌دهی به آن‌ها رنگ پس‌زمینه ،اندازه و موقعیت می دهیم.

می توانیم پس‌زمینه را به شکل یک جدول نمایش دهیم چرا که این gridای ثابت از چهارگوش‌ها است. عناصری که آزادانه حرکت می کنند را می توان با موقعیت‌دهی مطلق روی طرح قرار داد.

در بازی‌ها و دیگر برنامه‌هایی که در آن‌ها باید تصاویر به حرکت درآیند و به ورودی کاربر بدون تاخیر پاسخ دهند، کارایی خیلی مهم است. اگرچه DOM اساسا برای انجام کارهای گرافیکی سطح بالا طراحی نشده است، اما در عمل از چیزی که انتظارش را داشتید بهتر کار می کند. در فصل 14 چند متحرک‌سازی مشاهده نمودید. در یک کامپیوتر مدرن، یک بازی ساده مثل این بازی به خوبی اجرا می‌شود، حتی اگر به بهینه‌سازی آن زیاد فکر نکنیم.

در فصل بعدی به سراغ تکنولوژی دیگری در مرورگر خواهیم رفت، برچسب <canvas> که روش ریشه‌دارتری برای کشیدن تصاویر فراهم می سازد؛ کار با اشکال و پیکسل‌ها به جای استفاده از عناصر DOM.

مراحل بازی

ما به روشی خوانا و قابل ویرایش برای انسان نیاز داریم تا به وسیله‌ی آن مراحل بازی را مشخص کنیم. چون می توان همه چیز را روی grid شروع کرد، می‌توانیم از رشته‌های بلند که در آن هر کاراکتر نماینده‌ی یک عنصر است استفاده کنیم –چه بخشی از grid پس‌زمینه یا یک عنصر در حال حرکت.

طرح مورد نظر برای یک مرحله‌ی کوچک ممکن است شبیه زیر باشد:

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 در اینجا برای حذف فضاهای خالی شروع و پایان رشته‌ی طرح استفاده می‌شود. این به طرح مثال ما این امکان را می دهد که در یک خط جدید شروع شود تا همه‌ی خطوط مستقیما زیر یکدیگر قرار بگیرند. رشته‌ی باقیمانده براساس کاراکترهای خط جدید تقسیم می‌شود و هر خط درون یک آرایه پخش می‌شود و آرایه‌ای از کاراکترها تولید می‌شود.

بنابراین rows، آرایه‌ای از آرایه‌های کاراکترها را نگه‌داری می کند، همان ردیف‌های طرح. می توانیم طول و عرض هر مرحله را از این ها بدست بیاوریم. اما هنوز لازم است که عناصر متحرک را از grid پس‌زمینه جدا کنیم. عناصر متحرک را بازیگران می نامیم. آن را در آرایه‌ای از اشیاء ذخیره می کنیم. پس‌زمینه، آرایه‌ای از آرایه‌های رشته‌ها خواهد بود که نوع فیلدهایی مثل "empty"، "wall"، یا "lava" را نگه‌داری می کند.

برای ایجاد این آرایه‌ها به سراغ تک تک ردیف ها و بعد محتوای آن‌ها می رویم. به خاطر داشته باشید که متد map اندیس آرایه را به عنوان آرگومان دوم به تابع ارسال می کند که به ما مختصات x و y کاراکتر داده‌شده را می دهد. موقعیت ها در این بازی به صورت جفت‌هایی از مختصات با مبدا بالا و چپ 0,0 ذخیره می شوند و هر مربع پس‌زمینه دارای 1 واحد طول و عرض می باشد.

برای تفسیر کاراکترهای موجود در طرح، تابع سازنده‌ی Level از شیء levelChars استفاده می کند که عناصر پس‌زمینه را به رشته‌ها و بازیگران را به کلاس‌ها نگاشت می کند. زمانی که type یک کلاس بازیگر است، متد استاتیک create آن برای ایجاد یک شیء استفاده می شود که به startActors افزوده می‌شود و تابع map مقدار "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" تغییر می کند .

این دوباره یک ساختار داده‌ی مانا محسوب می‌شود – به روز رسانی وضعیت بازی باعث ایجاد وضعیت جدیدی می‌شود و وضعیت قبلی را دست‌نخورده باقی می گذارد.

بازیگران

اشیاء بازیگر نمایانگر موقعیت و وضعیت یک عنصر متحرک در بازی ما می‌باشند. تمامی اشیاء بازیگر از رابط یکسانی پیروی می کنند. خاصیت pos آن‌ها، مختصات گوشه‌ی بالا-چپ عنصر را نگه‌داری کرده و خاصیت size آن‌ها اندازه‌شان را نگه‌داری می کند.

آن‌ها نیز دارای یک متد 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 با توجه به عدد دریافتی اندازه‌ی یک بردار (vector) را تغییر می دهد. زمانی که لازم است تا یک بردار سرعت‌ را در یک وقفه‌ی زمان ضرب کنیم تا فاصله‌ی پیموده شده را در طول آن زمان به دست بیاوریم، مفید خواهد بود.

انواع مختلف بازیگران دارای کلاس‌های خودشان می باشند، به دلیل اینکه رفتارهایشان خیلی متفاوت است. اجازه بدهید این کلاس ها را تعریف کنیم. بعدا به متدهای update شان خواهیم پرداخت.

کلاس Player دارای خاصیتی به نام speed است که سرعت فعلی اش را ذخیره می کند تا جاذبه و تکانه (momentum) را شبیه‌سازی کند.

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 ذخیره کرد. می توانستیم از یک getter مثل type استفاده کنیم اما در این صورت یک شیء Vec جدید هر بار که خاصیت خوانده می شد ایجاد و برگردانده می شد که کاری بیهوده است. (رشته‌ها با توجه به غیرقابل تغییر بودن، نیازی ندارند با هر بار ارزیابی از نو ایجاد شوند).

زمانی که یک بازیگر Lava را می سازیم، لازم است که شیء با توجه با کاراکتری که بر پایه‌ی آن است مقداردهی متفاوتی شود. گدازه‌ی پویا با سرعت فعلی‌اش حرکت می کند تا زمانی که به یک مانع بخورد. در این نقطه، اگر دارای خاصیت 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);

در فصل 14، دیدیم که متد Math.sin مختصات عرضی نقطه‌ای روی دایره را به ما می دهد. مقدار این مختصات با حرکت در محیط دایره به صورت موجی در یک بازه بالا و پایین می رود که موجب می‌شود تابع سینوس گزینه‌ی خوبی برای مدلسازی حرکت موجی برای ما باشد.

برای جلوگیری از حالتی که همه‌ی سکه‌ها همزمان بالا و پایین بروند، فاز شروع هر سکه به صورت تصادفی تعیین می‌شود. فاز موج Math.sin همان عرض موجی است که تولید می کند و برابر با 2π می باشد. مقدار بازگشتی از Math.random را در آن عدد ضرب کرده تا موقعیت شروع تصادفی ای به سکه روی موج بدهیم.

اکنون می توانیم شیء levelChars را تعریف کنیم که کاراکترهای طرح را روی انواع grid پس‌زمینه یا کلاس‌های بازیگر نگاشت کند.

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

کار باقی مانده این است که این مرحله‌ها را روی صفحه‌ی نمایش نشان دهیم و زمان و حرکت را درون آن مدلسازی کنیم.

کپسوله‌سازی به عنوان یک بار

بیشتر کدهای این فصل بدون در نظر گرفته کپسوله سازی نوشته شده اند و این کار دو دلیل دارد. اول اینکه کپسوله‌سازی کار بیشتری از ما می گیرد. باعث بزرگتر شدن برنامه می‌شود و نیاز به طرح مفاهیم و رابط های بیشتری دارد. به دلیل این که نمی توان در اینجا کد زیادی به نمایش گذاشت و برای خواننده کسل کننده خواهد شد، من تلاش کردم که که برنامه را کوچک نگه دارم.

دوما، عناصر متنوع درون این بازی با هم ارتباط تنگاتنگی دارند و اگر رفتار یکی از آن ها تغییر کند، بعید است که دیگر عناصر بتوانند به همان صورت قبلی بمانند. رابط‌های بین این عناصر ممکن است به اینجا ختم شود که فرض‌های زیادی درباره‌ی نحوه‌ی عملکرد بازی در نظر بگیرند. این باعث می‌شود که اثرگذاری این رابط‌ها بسیار کاهش یابد- هربار که بخشی از سیستم را تغییر می دهید، همچنان بایستی نگران نحوه‌ی اثر آن روی دیگر قسمت‌ها باشید چراکه رابط‌های آن ها شرایط جدید را پوشش نداده اند.

بعضی نقاط قابل برش در سیستم (cutting points)، خودشان مناسب قرارگرفتن به عنوان قسمت‌های مجزا توسط رابط‌های دقیق می‌باشند، اما دیگر قسمت‌ها این طور نیستند. تلاش در جهت کپسوله‌سازی چیزی که کرانه‌ی مناسبی محسوب نمی‌شود، روش مطمئنی برای تلف کردن زیاد انرژی است. زمانی که مرتکب این اشتباه می شوید معمولا متوجه می شوید که رابط شما به شکل نامناسبی بزرگ و دارای جزئیات می‌شود و اغلب با تکامل برنامه، لازم است تغییر کند.

یک چیز هست که قصد داریم تا کپسوله‌سازی کنیم و آن طراحی زیرسیستم است. دلیل این کار این است که ما بازی را به روش متفاوتی در فصل آینده قرار است نمایش دهیم. با قرار دادن عمل طراحی پشت یک رابط، می توانیم همین برنامه‌ی بازی را آنجا بارگیری کرده و ماژول نمایش جدیدی را به خدمت بگیریم.

رسم

عمل کپسوله کردن کد رسم اشکال با تعریف یک شیء display انجام می‌شود که وضعیت و مرحله‌ی داده شده را نمایش می دهد. نوع displayای که در این فصل تعریف می کنیم DOMDisplay خوانده می‌شود به دلیل این که از عناصر DOM برای نمایش مرحله استفاده می شود.

ما از یک برگه‌ی سبک‌دهی (css) برای تنظیم رنگ‌های واقعی و دیگر خاصیت‌های ثابت عناصر سازنده‌ی بازی استفاده می کنیم. همچنین می توان مستقیما خاصیت 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;
}

یک display به این صورت ایجاد می‌شود که به آن عنصر والدی اختصاص داده می‌شود که باید خودش و یک شیء مرحله را به آن اضافه کند.

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

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

grid پس‌زمینه‌ی مرحله، که همیشه ثابت است، فقط یک بار رسم می‌شود. بازیگران اما با هر بار تغییر صفحه نمایش توسط یک وضعیت جدید از نو رسم می شوند. خاصیت actorLayer برای رصد عنصری که بازیگران را نگه‌داری می کند استفاده می‌شود تا آن ها بتوانند به آسانی تغییر و حذف شوند.

مختصات و اندازه‌های ما در واحدهای grid اندازه‌گیری می شوند، برای اندازه یا فاصله، 1 به معنای یک بلاک grid است. زمانی که اندازه‌های پیکسلی را تنظیم می کنیم، می بایست مقیاس این مختصات را افزایش دهیم – اگر برای هر مربع یک پیکسل در نظر بگیریم همه‌ی عناصر بازی به شدت کوچک می شوند. ثابت 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 مربوط به مرحله به خوبی هماهنگی دارد – هر ردیف از grid به یک ردیف جدول (<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) برای تغییر رفتارهای پیشفرض ناخواسته است. ما نمی خواهیم که قالب جدول وابسته به محتوای خانه‌هایش باشد و دوست نداریم بین خانه‌های جدول فاصله باشد یا درونشان 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 را دوباره تعریف کنیم چون از همان کلاس lava که برای مربع‌های grid تعریف کرده بودیم در قبل استفاده خواهیم کرد.

.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);
};

با افزودن وضعیت فعلی مرحله به عنوان یک نام کلاس به wrapper، می توانیم شخصیت بازی را در زمان برنده شدن یا باختن بازی سبک‌دهی متفاوتی بکنیم و این کار با افزودن یک دستور CSS که زمانی اعمال می‌شود که بازیکن عنصر والدش دارای کلاس داده شده باشد.

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

بعد از برخورد با گدازه، رنگ بازیکن به قرمز تیره تغییر می کند که سوختن را نمایش دهد. زمانی که آخرین سکه هم جمع شد دو سایه‌ی سفید تار- یکی به بالا-چپ و دیگری به بالا-راست اضافه می کنیم- تا جلوه‌ی هاله‌ی روشن را ایجاد کنیم.

نمی توانیم فرض بگیریم که مرحله‌ی بازی همیشه در میدان دید (viewport) باشد – منظور عنصری است که در آن بازی را ترسیم می کنیم. به همین دلیل است که فراخوانی scrollPlayerIntoView لازم است – این تابع باعث می‌شود تا در صورتی که مرحله‌ی بازی از اندازه‌ی میدان دید فراتر رفت، آن عنصر میدان دید اسکرول شود و شخصیت بازی نزدیک وسط تصویر آن قرار گیرد. دستورات ‌CSS پیش رو به عنصر wrapper بازی بیشینه‌ی اندازه را اختصاص داده و اطمینان حاصل می کند که هر چیزی که بیرون از محدوده‌ی این عنصر قرار بگیرد قابل مشاهده نخواهد بود. همچنین به عنصر بیرونی یک موقعیت نسبی تخصیص دادیم که باعث می شود بازیگران درون آن نسبت به گوشه‌ی چپ-بالای مرحله موقعیت دهی شوند.

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

در متد scrollPlayerIntoView ما موقعیت بازیکن را پیدا می کنیم و موقعیت اسکرول عنصر پوشاننده‌ی آن را به‌روز می کنیم. موقعیت اسکرول را با دستکاری خاصیت‌های scollLeft و 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 آن ها را به مقدارهای قابل قبول محدود می کند. اگر مقدار scrollLeft را برابر -10 تنظیم کنید به صورت خودکار 0 خواهد شد.

کمی کار راحت تر می شد اگر همیشه بازیکن در مرکز میدان دید scroll می شد. اما این باعث نسبتا حالت لرزش ایجاد می کرد. در هنگام پرش، تصویر مداوم به سمت بالا و پایین حرکت می کند. بهتر است که یک ناحیه بیطرف در مرکز صفحه‌ی نمایش داشته باشیم که بتوان در آن بدون ایجاد اسکرول به حرکت پرداخت.

اکنون می توانیم مرحله را به نشان دهیم.

<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 سبک‌های مورد نیاز بازی را در بر دارد.

حرکت و برخورد

اکنون در نقطه‌ای قرار داریم که می توانیم حرکت را به بازی اضافه کنیم – جالب ترین قسمت بازی. روش اصلی که اکثر بازی‌های مشابه استفاده می‌کنند این است که زمان را به گام‌های کوچک تقسیم کنیم و هر گام، بازیگران را مسافتی معادل ضرب سرعتشان در اندازه گام زمانی، جابجا کنیم. ما زمان را با ثانیه اندازه‌گیری می کنیم؛ بنابراین سرعت‌ها به صورت واحد (unit) بر ثانیه بیان می شوند.

حرکت دادن عناصر ساده است. قسمت مشکل مدیریت تعاملات بین عنصرها است. زمانی که بازیکن به دیوار یا زمین برخورد می کند، نباید وارد آن بشود. بازی باید متوجه برخورد یک شیء با شیء دیگر بشود و واکنش مناسبی نشان دهد. برای دیوارها، حرکت بایستی متوقف شود. زمانی که به سکه‌ها برخورد می‌شود، باید جمع‌آوری شوند. زمانی که گدازه‌ها لمس می شوند، بازی باید با شکست پایان یابد.

به طور کلی حل این مشکل کار زیادی می‌طلبد. می توانید از کتابخانه‌هایی که معمولا موتورهای فیزیک (physics engines) نامیده می شوند استفاده کنید که این تعاملات فیزیکی بین اشیاء را در دو یا سه بعد شبیه‌سازی می کنند. ما از روش ساده‌تری در این فصل استفاده خواهیم کرد و فقط برخورد بین مستطیل‌ها را به شکلی خیلی ساده و ابتدایی پوشش می دهیم.

قبل از حرکت دادن یک بازیکن یا یک گدازه، بررسی می کنیم که آیا این حرکت باعث می شود که شیء به درون دیوار برود. اگر این طور بود، حرکت را لغو می کنیم. پاسخ به این گونه برخورد بستگی به نوع بازیگر دارد – اگر بازیکن بود که از حرکت می ایستد درحالیکه بلاک گدازه هم در جهت عکس برخواهد گشت.

در این روش لازم است که گام‌های زمانی ما کوتاه باشند تا بتوان قبل از لمس شیء از حرکت ایستاد. اگر گام‌های زمانی (و در نتیجه گام‌های حرکتی) خیلی بلند باشند، منجر می‌شود که بازیکن با فاصله‌ی محسوسی روی زمین شناور بماند. روشی دیگر، که احتمالا بهتر اما پیچیده‌تر است این است که دقیقا نقطه‌ی برخورد را پیدا کنیم و به سمت آن حرکت انجام شود. ما از روش ساده تر استفاده خواهیم کرد و مشکلاتش را با کوتاهتر کردن گام‌های زمانی حل می کنیم.

متد پیش رو به ما نشان می دهد که یک مستطیل (که با موقعیت و اندازه مشخص می‌شود) با یک عنصر grid داده شده تماس دارد یا خیر.

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

  for (var y = yStart; y < yEnd; y++) {
    for (var 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;
};

متد بالا مجموعه‌ای از مربع‌های grid که body با آن‌ها همپوشانی دارد را با استفاده از Math.floor و Math.ceil روی مختصاتش محاسبه می کند. به خاطر داشته باشید که مربع‌های grid دارای اندازه‌ی 1 در 1 واحد می‌باشند. با رند کردن کناره‌های مستطیل به بالا و پایین، بازه‌ای از مربع‌های پس‌زمینه را در اختیار خواهیم داشت که مستطیل آن ها را لمس می کند.

Finding collisions on a grid

ما مربع‌های grid به دست آمده از رندسازی مختصات را یک به یک پیمایش می کنیم و زمانی که یک مربع مورد نظر پیدا شود مقدار 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 دریافت می کند. متد update مربوط به نوع 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));
  }
};

این متد یک موقعیت جدید را با افزودن نتیجه‌ی گام زمانی و سرعت فعلی به موقعیت قبلی اش، محاسبه می کند. اگر مانعی برای موقعیت جدید وجود نداشته باشد، به آنجا حرکت می کند. اگر مانعی موجود باشد ، رفتار متناسب با نوع بلاک گدازه خواهد بود – گدازه‌ی dripping دارای یک موقعیت reset می باشد که وقتی به شیءای برخود می‌کند به آن می بپرد. گدازه‌ای که بالاپایین می رود، سرعتش را ضرب در -1 منفی می کند در نتیجه با رسیدن به مانع، جهت حرکت معکوس می‌شود.

سکه‌ها از متد update شان استفاده می کنند تا جنب و جوش داشته باشند. سکه‌ها برخورد با grid را در نظر نمی گیرند چرا که آن ها فقط درون مربع خودشان جنب و جوش دارند.

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 یک ثانیه‌ی دیگر منتظر می ماند (برای اینکه به کاربر نشان دهد چه اتفاقی می افتد) و بعد صفحه را پاک کرده، انیمیشن را متوقف کرده و promise را برای وضعیت نهایی بازی رسیدگی می کند.

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;
      }
    });
  });
}

یک بازی شامل چندین مرحله می‌شود. زمانی که بازیکن در بازی می‌میرد مرحله‌ی فعلی از نو شروع می‌شود. زمانی که یک مرحله به اتمام می رسد، به مرحله‌ی بعدی منتقل می شویم. این کار را می توان با تابع پیش رو نمایش داد که آرایه‌ای از طرح‌های مراحل (رشته‌ای) و یک سازندهی Display را دریافت می کند.

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 یک promise بر می گرداند، runGame را می توان با یک تابع async نوشت، همانطور که در فصل 11 شرح داده شد. این تابع یک promise دیگر برمی گرداند که وقتی بازیکن بازی را تمام می کند رسیدگی می‌شود.

مجموعه‌ای از طرح‌های مراحل در متغیر 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>
  // The old runGame function. Modify it...
  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 آن را فراخوانی می کند را تغییر دهید، مناسب خواهد بود.

بعد از انجام قسمت بالا، چیزی دیگری هست که می توانید انجام دهید. روشی که برای ثبت گرداننده‌های کلید استفاده می کردیم مقداری مشکل‌زا است. شیء arrows در فضای سراسری در دسترس است و گرداننده‌ی رخدادش نیز حتی زمانی که بازی اجرا نمی‌شود در دسترس است. می توان گفت که این دو از سیستم نشت کرده‌اند. trackKeys را توسعه داده تا راهی برای لغو ثبت گرداننده‌هایش فراهم شود و بعد runLevel را تغییر دهید تا گرداننده‌هایش را در زمانی که شروع می‌شود ثبت کند و با پایان کارش آن ها را لغو ثبت نمایند.

<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 ارسال شده است، به removeEventListenre ارسال شود تا آن گرداننده حذف شود. بنابراین، مقدار تابع handler که در ‍trackKeys ایجاد شده، باید در دسترس کدی که عمل لغو ثبت را انجام می دهد باشد.

یک هیولا

یکی از چیزهای رایج در سکوبازی‌ها داشتن دشمنانی است که بتوان روی آن ها پرید. این تمرین از شما می خواهد که این شخصیت بازیگر را به بازی اضافه کنید.

ما آن را هیولا می نامیم. هیولاها به صورت افقی حرکت می کنند. می توانید طوری آن ها را بسازید که به طرف بازیکن حرکت کنند یا مثل گدازه‌های متحرک حرکت عقب و جلو داشته باشند یا هر الگوی حرکتی که شما دوست دارید. نیازی نیست افتادن را پیاده سازی کنید اما باید مطمئن شود که هیولا درون دیوار ها نرود.

زمانی که یک هیولا با یک بازیکن برخورد می کند بسته به اینکه بازیکن روی آن پریده باشد یا خیر واکنش متفاوت خواهد بود. می توانیم این را با بررسی تطابق پایین بازیکن با بالای هیولا متوجه شویم. در این صورت هیولا باید ناپدید شود. در غیر این صورت بازیکن می بازد.

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

<body>
  <script>
    // Complete the constructor, update, and collide methods
    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 را پیدا کنید و موقعیت آن را با موقعیت هیولا مقایسه نمایید. برای بدست آوردن مختصات پایین بازیکن، باید اندازه‌ی عمودی آن را به موقعیت عمودیش اضافه نمایید. بسته به موقعیت مکانی بازیکن، ایجاد یک وضعیت به‌روز، موجب بروز برخورد مربوط به سکه (حذف آن) یا گدازه (تغییر وضعیت به "lost") می‌شود.