מקרה לדוגמה – Onslaught! אצטדיון

מבוא

ביוני 2010 נודע לנו שתחרות פיתוח משחק ל "zine" Boing Boing המקומית ערכה. מצאנו את זה תירוץ ממש טוב ליצור משחק מהיר ופשוט ב-JavaScript וב-<canvas>, אז התחלנו לעבוד. אחרי התחרות עדיין היו לנו הרבה רעיונות ורצינו לסיים את מה שהתחלנו. זהו מקרה לדוגמה של התוצאה, משחק קטן בשם Onslaught! זירה.

מראה רטרו בתצוגת פיקסלים

היה חשוב שהמשחק שלנו יהיה דומה למשחק Nintendo Entertainment System בסגנון רטרו, לאור הנחת היסוד של התחרות לפתח משחק על סמך אוטומטית. רוב המשחקים לא דורשים את הדרישה הזו, אבל זהו סגנון אומנותי נפוץ (במיוחד בקרב מפתחי אינדי) בזכות הקלות שבה אפשר ליצור נכסים ובזכותו הוא מושך בעיקר גיימרים נוסטלגיים.

הסתערות! גדלים של פיקסלים בזירה
הגדלת הפיקסלים עלולה להקטין את העבודה של עיצוב גרפי.

בהתחשב בגודלן של ה-Sprite האלה, החלטנו להכפיל את הפיקסלים שלנו. כלומר, Sprite של 16x16 יהיה כעת 32x32 פיקסלים וכן הלאה. כבר מההתחלה, התמקדנו ביצירת נכסים במקום לגרום לדפדפן לעשות את העבודה הקשה. פשוט היה קל יותר ליישם את הכלי, אבל היו לו גם כמה יתרונות משמעותיים.

הנה תרחיש שחשבנו עליו:

<style>
canvas {
  width: 640px;
  height: 320px;
}
</style>
<canvas width="320" height="240">
  Sorry, your browser is not supported.
</canvas>

השיטה הזו תכלול Sprite בגודל 1x1 במקום להכפיל אותם בצד של יצירת הנכסים. משם, ה-CSS ישתלט על אזור העריכה עצמו וישנה את גודלו. נקודות ההשוואה שלנו העלו שהשיטה הזו יכולה להיות מהירה פי שניים מרינדור תמונות גדולות (מוכפלות), אבל לצערנו, שינוי הגודל של CSS כולל הסרת קצוות משוננים, שלא הצלחנו למצוא דרך למנוע.

אפשרויות לשינוי הגודל של קנבס
שמאל: הכפלה של נכסים מושלמים ב-Photoshop. ימין: שינוי הגודל של שירות ה-CSS יוסיף אפקט מטושטש.

מבחינת המשחק שלנו, הפיקסלים הנפרדים לא יצאו משימוש, אבל אם אתם צריכים לשנות את גודל הקנבס ואת החלק השמאלי של העיצוב בהתאם לפרויקט שלכם, כדאי לשקול את הגישה הזו כדי לשפר את הביצועים.

טריקים משעשעים על קנבס

כולנו יודעים ש-<canvas> הוא הבעיה החדשה, אבל לפעמים מפתחים עדיין ממליצים להשתמש ב-DOM. אם קשה לכם להחליט במה כדאי להשתמש, הנה דוגמה לאופן שבו <canvas> חסך לנו הרבה זמן ואנרגיה.

כשהאויב נפגע ב-Onslaught! זירה, מהבהב בצבע אדום ומציג בקצרה אנימציית "כאב". כדי להגביל את מספר הגרפיקה שהיה עלינו ליצור, נציג רק את האויבים ש "כואבים" מלמטה. זה נראה טוב בתוך המשחק, ואפשר לחסוך המון זמן של יצירת Sprite. אבל למפלצות הבוס, זה ממש קוצר לראות Sprite גדול (בגודל של 64x64 פיקסלים או יותר) שפונה שמאלה או למעלה לפתאום כלפי מטה, אל מסגרת הכאב.

פתרון ברור הוא לשרטט מסגרת קשה לכל בוס בכל אחד משמונת הכיוונים, אבל זה היה גוזל זמן רב. בזכות <canvas>, הצלחנו לפתור את הבעיה בקוד:

מה נופל לכם נזק ב-Onslaught! אצטדיון
אפשר ליצור אפקטים מעניינים באמצעותcontext.globalCompositeOperation.

תחילה משרטטים את המפלצת ומעבירים אותה ל "מאגר מאגר" <canvas> מוסתר, מתחת לכיתוב אדום, ואז מעבדים את התוצאה בחזרה למסך. הקוד נראה בערך כך:

// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");

// Draw your image on the buffer
buffer.drawImage(image, 0, 0);

// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();

// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);

לולאת המשחקים

יש כמה הבדלים חשובים בפיתוח משחקים לעומת פיתוח באינטרנט. בסטאק האינטרנט מקובל להגיב לאירועים שקרו דרך event listener. לכן, קוד אתחול לא יכול לעשות שום דבר מלבד האזנה לאירועי קלט. הלוגיקה של המשחק שונה, כי חשוב לעדכן את עצמו כל הזמן. לדוגמה, אם שחקן לא זז, זה לא צריך למנוע מהגובלינים להשיג אותו!

דוגמה ל-game Loop:

function main () {
  handleInput();
  update();
  render();
};

setInterval(main, 1);

ההבדל החשוב הראשון הוא שהפונקציה handleInput לא מבצעת שום דבר מיד. אם משתמש לוחץ על מקש באפליקציית אינטרנט אופיינית, כדאי לבצע מיד את הפעולה הרצויה. אבל במשחק, הדברים צריכים לקרות בסדר כרונולוגי, כדי לזרום בצורה נכונה.

window.addEventListener("mousedown", function(e) {
  // A mouse click means the players wants to attack.
  // We don't actually do that yet, but instead tell the rest
  // of the program about the request.
  buttonStates[e.button] = true;
}, false);

function handleInput() {
  // Here is where we respond to the click
  if (buttonStates[LEFT_BUTTON]) {
    player.attacking = true;
    delete buttonStates[LEFT_BUTTON];
  }
};

עכשיו אנחנו יודעים על הקלט ובודקים אותו בפונקציה update, מתוך ידיעה שהוא יפעל בהתאם לשאר כללי המשחק.

function update() {
  // Check for collisions, states, whatever else is needed

  // If after that the player can still attack, do it!
  if (player.attacking && player.canAttack()) {
    player.attack();
  }
};

לסיום, לאחר חישוב כל הנתונים, הגיע הזמן לצייר מחדש את המסך. ב-DOM-land, הדפדפן מטפל בהרמה זו. אבל כשמשתמשים ב-<canvas>, צריך לצייר מחדש באופן ידני בכל פעם שמשהו קורה (בדרך כלל מדובר בכל פריים!).

function render() {
  // First erase everything, something like:
  context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);

  // Draw the player (and whatever else you need)
  context.drawImage(
    player.getImage(),
    player.x, player.y
  );
};

בניית מודלים מבוססת זמן

מודלים מבוססי-זמן הם הרעיון של העברת Sprite על סמך משך הזמן שחלף מאז עדכון הפריים האחרון. הטכניקה הזו מאפשרת למשחק לרוץ מהר ככל האפשר, תוך הקפדה על תנועה של דמויות Sprite במהירויות קבועות.

כדי להשתמש במודלים מבוססי-זמן, אנחנו צריכים לתעד את הזמן שחלף מאז הפריים האחרון. כדי לעקוב אחרי הפונקציה הזו, אנחנו צריכים להרחיב את הפונקציה update() של ה-game Loop.

function update() {

  // NOTE: You'll need to initially seed this.lastUpdate
  // with the current time when your game loop starts
  // this.lastUpdate = Date.now();

  // Calculate elapsed time since last frame
  var now = Date.now();
  var elapsed = (now - this.lastUpdate);
  this.lastUpdate = now;

  // Do stuff with elapsed

};

עכשיו, כשיש לנו את הזמן שחלף, אנחנו יכולים לחשב את המרחק של Sprite נתון על מנת להזיז כל פריים. קודם כל, עלינו לעקוב אחרי מספר דברים באובייקט Sprite: המיקום הנוכחי, המהירות והכיוון.

var Sprite = function() {

  // The sprite's position relative to the top left of the game world
  this.position = {x: 0, y: 0};

  // The sprite's direction. A positive x value indicates moving to the right
  this.direction = {x: 1, y: 0};

  // How many pixels the sprite moves per second
  this.speed = 50;
};

נביא בחשבון את המשתנים האלה, כך נוכל להעביר מכונה של מחלקת ה-Sprite שלמעלה באמצעות מודלים מבוססי-זמן:

// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;

// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);

חשוב לשים לב שהערכים direction.x ו-direction.y צריכים להיות מנורמלים, כלומר הם תמיד צריכים להיות בין -1 ל-1.

פרמטרים להשוואה

אמצעי הבקרה היו כנראה המכשול הגדול ביותר במהלך הפיתוח של Onslaught! זירה. בהדגמה הראשונה הייתה תמיכה רק במקלדת; השחקנים הזיזו את הדמות הראשית במסך באמצעות מקשי החיצים, ויורה בכיוון שהוא פונה באמצעות מקש הרווח. על אף שהוא קצת אינטואיטיבי וקל להבנה, הוא הפך את המשחק לכמעט בלתי ניתן להפעלה ברמות קשות יותר. עשרות אויבים וקליעים מעופפים ליד השחקן בכל רגע נתון, ולכן חשוב להיות שולבו בין הרעים בזמן שיורים לכל כיוון.

כדי להשוות למשחקים דומים בז'אנר שלו, הוספנו תמיכה בעכבר כדי לשלוט ברשת מיקוד, שבה הדמות תשתמש כדי לכוון את ההתקפות שלו. עדיין אפשר להזיז את הדמות באמצעות המקלדת, אבל לאחר השינוי הזה הוא יוכל לירות בכל כיוון של 360 מעלות בו-זמנית. שחקנים כבדים מאוד אהבו את התכונה הזו, אבל הייתה לה תופעת לוואי עצובה של משתמשים במשטח המגע.

הסתערות! חלון פקדי זירה (הוצא משימוש)
פקדים ישנים או חלון עזר של 'איך משחקים' ב-Onslaught! זירה.

כדי להתאים את המשתמשים למשטח המגע, החזרנו את מקשי החיצים, הפעם כדי לאפשר את ההפעלה בכיוונים הלחיצים. לדעתנו, אנחנו משרתים את כל סוגי השחקנים, אבל גם יצרנו יותר מדי מורכבות למשחק שלנו. להפתעתנו, מאוחר יותר שמענו שחלק מהשחקנים לא היו מודעים לאמצעי הבקרה האופציונליים לעכבר (או במקלדת!) לתקיפה, למרות מודולים של מדריך, שרובם התעלמו מהם.

הסתערות! מדריך לפקדי הזירה
השחקנים מתעלמים משכבת-העל של המדריך, כי הם מעדיפים לשחק וליהנות!

מזל טוב שיש לנו גם כמה מעריצים אירופאיים, אבל שמענו מהם תסכול על כך שאין להם מקלדות QWERTY טיפוסיות והם לא יכולים להשתמש במקשים WASD לתנועה כיוונית. שחקנים מצד שמאל הביעו תלונות דומות.

באמצעות סכימת הבקרה המורכבת שהטמענו, קיימת גם בעיה בהפעלה במכשירים ניידים. ובאמת, אחת הבקשות הנפוצות שלנו היא לגרום ל-Onslaught! ארנה זמינה ב-Android, ב-iPad ובמכשירי מגע אחרים (שבהם אין מקלדת). אחד מיתרונות הליבה של HTML5 הוא הניידות שלו, כך שבהחלט אפשר להעביר את המשחק למכשירים האלה – אנחנו פשוט צריכים לפתור את הבעיות הרבות (העיקרית, אמצעי בקרה וביצועים).

כדי לטפל בבעיות הרבות, התחלנו לשחק בשיטת קלט אחת בגיימפליי שמערבת רק אינטראקציה עם העכבר (או מגע). השחקנים לוחצים על המסך או נוגעים בו, והדמות הראשית הולכת לכיוון המיקום הלחיץ, ותוקפת באופן אוטומטי את הרשע הקרוב ביותר. הקוד נראה בערך כך:

// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
  // Found one! Shoot in its direction
  var shoot = hostile.boundingBox().center().subtract(
    player.boundingBox().center()
  ).normalize();
}

// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
  player.boundingBox().center()
).magnitude();

// Prevent jittering if the character is close enough
if (distance < 3) {
  move.zero();
}

// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
  player.setDirection(move);
}

הסרת הגורם הנוסף שלפיו צריך לכוון אויבים יכולה להקל על המשחק במצבים מסוימים, אבל אנחנו מאמינים שהפיכת הדברים לפשוטים יותר עבור השחקן נותנת יתרונות רבים. מופיעות אסטרטגיות נוספות, כמו הצבת הדמות קרוב לאויבים מסוכנים כדי לטרגט אותם, והאפשרות לתמוך במכשירי מגע היא בעלת ערך רב.

אודיו

מבין אמצעי הבקרה והביצועים, אחת מהבעיות העיקריות שלנו בתהליך הפיתוח של Onslaught! ארנה היה התג <audio> של HTML5. נראה שההיבט הגרוע ביותר הוא זמן האחזור: כמעט בכל הדפדפנים יש עיכוב בין ההפעלה של .play() לבין הצליל שמושמע בפועל. מצבים כאלה עלולים להרוס את החוויה של הגיימר, במיוחד כשהם משחקים במשחק בקצב מהיר כמו שלנו.

בעיות אחרות כוללות את האירוע "התקדמות" שלא פועל, שעלול לגרום לזרימת הטעינה של המשחק להיתקע ללא הגבלת זמן. לכן אימצנו את השיטה שאנחנו מכנים 'שיטת נפילה-קדימה', שבה אם טעינת ה-Flash נכשלת, נעבור לאודיו מסוג HTML5. הקוד נראה בערך כך:

/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/

// Default to sm2 (Flash)
var api = "sm2";

function initAudio (callback) {
  switch (api) {
    case "sm2":
      soundManager.onerror = (function (init) {
        return function () {
          api = "html5";
          init(callback);
        };
      }(arguments.callee));
      break;
    case "html5":
      var audio = document.createElement("audio");

      if (
        audio
        && audio.canPlayType
        && audio.canPlayType("audio/mpeg;")
      ) {
        callback();
      } else {
        // No audio support :(
      }
      break;
  }
};

ייתכן שיהיה חשוב גם שהמשחק יתמוך בדפדפנים שלא יוכלו להפעיל קובצי MP3 (כמו Mozilla Firefox). במקרה כזה, אפשר לזהות את התמיכה ולהחליף אותה למשהו כמו Ogg Vorbis, באמצעות קוד כזה:

/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/

var audio = document.createElement("audio");

if (audio && audio.canPlayType) {
  if (!audio.canPlayType("audio/mpeg;")) {
    // Here you know you CANNOT use .mp3 files
    if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
      // Here you know you CAN use .ogg files
    }
  }
}

שמירת נתונים

אי אפשר לשחק יריות בסגנון ארקייד בלי להשיג תוצאות גבוהות! ידענו שאנחנו זקוקים לכמה מנתוני המשחק שלנו כדי שיימשכו, ובעוד שהיינו יכולים להשתמש במשהו ישן, כמו קובצי cookie, רצינו להתעמק בטכנולוגיות HTML5 החדשות והמהנות. בהחלט לא חסרות אפשרויות, כולל אחסון מקומי, אחסון סשנים ומסדי נתונים Web SQL.

ALT_TEXT_HERE
התוצאות הגבוהות נשמרות וגם המקום שלך במשחק אחרי הבסת כל בוס.

החלטנו להשתמש ב-localStorage מכיוון שהוא חדש, מעולה וקל לשימוש. הוא תומך בשמירה של צמדי מפתח/ערך בסיסיים, שהוא כל מה שנחוץ לנו במשחק. הנה דוגמה ישירה לאופן השימוש בו:

if (typeof localStorage == "object") {
  localStorage.setItem("foo", "bar");
  localStorage.getItem("foo"); // Value is "bar"
  localStorage.removeItem("foo");
  localStorage.getItem("foo"); // Value is now null
}

יש כמה "טעויות" שכדאי לשים לב אליהן. לא משנה מה מעבירים, הערכים מאוחסנים כמחרוזות, מה שעלול להוביל לתוצאות בלתי צפויות:

localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
  // It's true!
}

// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)

// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}

סיכום

השימוש ב-HTML5 הוא מדהים. רוב ההטמעות מטפלות בכל מה שמפתח המשחק צריך, מגרפיקה ועד שמירת מצב המשחק. אומנם יש כמה קשיים שהולכים ומתפתחים (כמו צרות עם התגים <audio>), אבל מפתחי הדפדפנים זזים במהירות והדברים כבר לא פחות טובים, אבל העתיד נראה טוב יותר במשחקים שמבוססים על HTML5.

הסתערות! זירה עם לוגו HTML5 מוסתר
כדי לקבל מגן HTML5, מקלידים 'html5' בזמן ההפעלה של Onslaught! זירה.