فصل 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 شروع کرد، می‌توانیم از رشته‌های بلند که در آن هر کاراکتر نماینده‌ی یک عنصر است استفاده کنیم –هم برای یک بخش پس‌زمینه و هم یک عنصر در حال حرکت.

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

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>) تبدیل می‌شود. رشته‌های موجود در grid به عنوان نام‌های کلاس برای سلول‌های جدول (<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") می‌شود.