מבוא
ביוני 2010, הגיעה אלינו הודעה על כך שתחרות לפיתוח משחקים מתקיימת באתר החדשות המקומי Boing Boing.
ראינו בכך תירוץ מצוין ליצור משחק מהיר ופשוט ב-JavaScript וב-<canvas>
, אז התחלנו לעבוד. אחרי התחרות עדיין היו לנו הרבה רעיונות ורצינו לסיים את מה שהתחלנו. זהו מקרה לדוגמה של התוצאה, משחק קטן בשם Onslaught! Arena.
המראה הרטרו המפוקסל
חשוב היה שהמשחק שלנו ייראה ויחווה כמו משחק רטרו ל-Nintendo Entertainment System, בהתאם להנחת היסוד של התחרות לפיה צריך לפתח משחק שמבוסס על צ'יפטון. ברוב המשחקים אין צורך בדרישה הזו, אבל זה עדיין סגנון אמנותי נפוץ (במיוחד בקרב מפתחים עצמאיים) בגלל הקלות שביצירת הנכסים והמשיכה הטבעית שלו למשחקנים עם נוסטלגיה.
בגלל שהספרייטים האלה קטנים מאוד, החלטנו להכפיל את מספר הפיקסלים. כלומר, ספרייטים בגודל 16x16 יהיו עכשיו בגודל 32x32 פיקסלים וכן הלאה. מההתחלה התמקדנו בצד של יצירת הנכסים, במקום לגרום לדפדפן לבצע את העבודה הקשה. פשוט היה קל יותר להטמיע את האפשרות הזו, אבל היו לה גם יתרונות מובהקים מבחינת המראה.
זהו תרחיש שבדקנו:
<style>
canvas {
width: 640px;
height: 320px;
}
</style>
<canvas width="320" height="240">
Sorry, your browser is not supported.
</canvas>
השיטה הזו תכלול ספרייטים בגודל 1x1 במקום להכפיל אותם בצד יצירת הנכסים. לאחר מכן, CSS ייקח את המושכות וישנה את הגודל של הקנבס עצמו. לפי מדדי הביצועים שלנו, השיטה הזו יכולה להיות מהירה פי שניים בערך מעיבוד תמונות גדולות יותר (כפולות), אבל לצערנו שינוי הגודל ב-CSS כולל ביטול aliasing, ולא הצלחנו למצוא דרך למנוע זאת.
זה היה גורם מכריע לגבי המשחק שלנו, כי פיקסלים בודדים חשובים מאוד, אבל אם אתם צריכים לשנות את גודל הלוח והסינון נגד aliasing מתאים לפרויקט שלכם, תוכלו לשקול את הגישה הזו מטעמי ביצועים.
טריקים משעשעים ב-Canvas
כולנו יודעים ש-<canvas>
הוא הדבר החדש החם, אבל לפעמים מפתחים עדיין ממליצים להשתמש ב-DOM. אם אתם לא בטוחים באיזו דרך להשתמש, הנה דוגמה לאופן שבו <canvas>
עזר לנו לחסוך הרבה זמן ואנרגיה.
כשאויב נפגע בהתקפה חזקה! Arena, הוא יהבהב באדום ויציג אנימציה של 'כאב' למשך זמן קצר. כדי להגביל את מספר הגרפיקה שצריך ליצור, אנחנו מציגים אויבים ב'כאב' רק בכיוון כלפי מטה. זה נראה בסדר במשחק, וחסכנו המון זמן ביצירת האנימציה. עם זאת, במפלצות הבוס היה מטריד לראות ספיריט גדול (בגודל 64x64 פיקסלים או יותר) שמשנה פתאום את הכיוון מימין או מלמעלה למטה בפריים של הכאב.
פתרון ברור היה לצייר מסגרת של נקודות כאב לכל בוס בכל אחת משמונת הכיוונים, אבל זה היה צורך הרבה זמן. תודה ל-<canvas>
, הצלחנו לפתור את הבעיה הזו בקוד:
קודם אנחנו מציירים את המפלצת ב'מאגר' <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);
לולאת המשחק
יש כמה הבדלים משמעותיים בין פיתוח משחקים לפיתוח אינטרנט. ב-web stack, מקובל להגיב לאירועים שהתרחשו באמצעות פונקציות 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, הדפדפן מטפל בעבודה הזו. אבל כשמשתמשים ב-<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
);
};
בניית מודלים מבוססי-זמן
בניית מודל מבוססת-זמן היא הקונספט של תנועת ספרייטים על סמך משך הזמן שחלף מאז העדכון האחרון של הפריים. הטכניקה הזו מאפשרת למשחק לפעול מהר ככל האפשר, תוך הבטחה שהאנימציות ינועו במהירות עקבית.
כדי להשתמש בבניית מודלים מבוססת-זמן, אנחנו צריכים לתעד את משך הזמן שחלף מאז שציירנו את הפריים האחרון. כדי לעקוב אחרי זה, נצטרך לשפר את הפונקציה update()
של לולאת המשחק.
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: המיקום הנוכחי, המהירות והכיוון.
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! Arena. הדמו הראשון בכלל לא תמך במשחק ביד, והשחקנים הזיזו את הדמות הראשית במסך באמצעות מקשי החיצים, וירו לכיוון שאליו היא פנתה באמצעות מקש הרווח. אמנם המשחק היה אינטואיטיבי וקליל להבנה, אבל בגלל זה כמעט לא ניתן היה לשחק בו ברמות קשות יותר. עשרות אויבים וקליעים נורים לעבר השחקן בכל רגע נתון, ולכן חובה לדעת לנוע בין הרעים תוך כדי ירי בכל כיוון.
כדי להשוות למשחקים דומים בז'אנר, הוספנו תמיכה בעכבר כדי לשלוט בכוונן הירי, שבו הדמות משתמשת כדי לכוון את ההתקפות שלה. עדיין אפשר היה להזיז את הדמות באמצעות המקלדת, אבל אחרי השינוי הזה הוא יכול היה לירות בו-זמנית בכל כיוון של 360 מעלות. גיימרים כבדים אהבו את התכונה הזו, אבל היא גרמה לתופעה לא נעימה של תסכול בקרב משתמשי משטח המגע.
כדי להתאים את המשחק למשתמשים עם משטח מגע, החזרת את האפשרות להשתמש במקשי החיצים, הפעם כדי לאפשר ירי בכיוונים שבהם לוחצים. חשבנו שאנחנו מספקים משחק לכל סוגי השחקנים, אבל לא ידענו שאנחנו מוסיפים למשחק יותר מדי מורכבות. להפתעתנו, מאוחר יותר שמענו שחלק מהשחקנים לא ידעו על האפשרות להשתמש בלחצני העכבר (או במקלדת!) כדי לתקוף, למרות מודלים של הדרכות שרוב השחקנים התעלמו מהם.
יש לנו גם כמה מעריצים באירופה, אבל שמענו מהם שהם לא תמיד משתמשים במקלדות QWERTY רגילות ולא יכולים להשתמש במקשים WASD לתנועה בכיוונים שונים. שחקנים שמאליים הביעו תלונות דומות.
מערכת הבקרה המורכבת שהטמענו מובילה גם לבעיה של משחק במכשירים ניידים. אכן, אחת מהבקשות הנפוצות ביותר היא ליצור Onslaught! Arena זמינה במכשירי 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! Arena היה התג <audio>
של HTML5.
כנראה שהבעיה הכי רצינית היא זמן האחזור: כמעט בכל הדפדפנים יש עיכוב בין הקריאה ל-.play()
לבין הפעלת הצליל בפועל. זה יכול לקלקל את חוויית המשחק של השחקנים, במיוחד כשהם משחקים במשחק מהיר כמו שלנו.
בעיות אחרות כוללות כישלון בהפעלת האירוע 'progress', שעלול לגרום לתהליך הטעינה של המשחק להיתקע ללא הגבלת זמן. לכן, אימצנו את מה שאנחנו מכנים 'שיטה של מעבר אוטומטי', שבה אם לא ניתן לטעון את Flash, עוברים ל-HTML5 Audio. הקוד נראה כך:
/*
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. יש מגוון רחב של אפשרויות, כולל אחסון מקומי, אחסון סשן ומסדי נתוני SQL באינטרנט.
החלטנו להשתמש ב-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. רוב הטמעות ה-SDK מטפלות בכל מה שמפתחי המשחקים צריכים, החל מהגרפיקה ועד לשמירת מצב המשחק. יש כמה כאבי גדילה (כמו בעיות עם התג <audio>
), אבל מפתחי הדפדפנים פועלים במהירות, והמצב כבר מצוין, כך שהעתיד נראה מבטיח למשחקים שנוצרו ב-HTML5.