Bab 16 dari Eloquent JavaScript. Sebelumnya: 15 Menangani Event. Berikutnya: 17 Menggambar di Canvas.
Semua realitas adalah permainan.
— Iain Banks, The Player of Games

Banyak dari kekaguman awal saya sama komputer, kaya kebanyakan anak kutu buku, berhubungan sama game komputer. Saya tertarik ke dunia-dunia simulasi kecil yang bisa saya manipulasi dan tempat cerita (semacamnya) terungkap, lebih, saya rasa, karena cara saya memproyeksikan imajinasi saya ke mereka daripada karena kemungkinan yang sebenarnya mereka tawarkan.
Saya gak mendoakan karier di pemrograman game buat siapa pun. Kaya industri musik, ketimpangan antara jumlah anak muda yang bersemangat pengen kerja di dalamnya dan permintaan sebenarnya buat orang kaya gitu bikin environment yang agak gak sehat. Tapi nulis game buat bersenang-senang itu menyenangkan.
Bab ini bakal ngelewatin implementasi game platformer kecil. Game platformer (atau game “jump and run”) adalah game yang ngarepin pemain buat mindahin figur lewat sebuah dunia, yang biasanya dua dimensi dan dilihat dari samping, sambil melompati dan naik ke atas sesuatu.
Game-nya
Game kita bakal kurang lebih didasarkan pada Dark Blue oleh Thomas Palef. Saya milih game itu karena dia menghibur sekaligus minimalis dan karena dia bisa dibangun tanpa terlalu banyak kode. Keliatannya kaya gini:

Kotak gelap mewakili pemain, yang tugasnya ngumpulin kotak kuning (koin) sambil ngehindarin barang merah (lava). Sebuah level selesai waktu semua koin udah dikumpulin.
Pemain bisa jalan-jalan dengan tombol panah kiri dan kanan dan bisa melompat dengan panah atas. Melompat adalah spesialisasi karakter game ini. Dia bisa nyapai beberapa kali tingginya sendiri dan bisa ngubah arah di tengah udara. Ini mungkin gak sepenuhnya realistis, tapi membantu ngasih pemain perasaan mengendalikan avatar di layar secara langsung.
Game-nya terdiri dari latar statis, ditata kaya grid, dengan elemen bergerak ditumpangin di atas latar itu. Setiap field di grid entah kosong, padat, atau lava. Elemen bergeraknya adalah pemain, koin, dan potongan lava tertentu. Posisi elemen-elemen ini gak dibatasi ke grid, koordinat mereka bisa pecahan, ngebolehin gerakan yang mulus.
Teknologinya
Kita bakal pakai DOM browser buat nampilin game-nya, dan kita bakal baca input pengguna dengan nanganin event tombol.
Kode terkait layar dan keyboard cuma bagian kecil dari kerjaan yang perlu kita lakuin buat ngebangun game ini. Karena semuanya keliatan kaya kotak berwarna, menggambar gak rumit: kita bikin elemen DOM dan pakai styling buat ngasih mereka warna latar, ukuran, dan posisi.
Kita bisa mewakili latarnya sebagai tabel, karena dia grid kotak yang gak berubah. Elemen yang bergerak bebas bisa ditumpangin pakai elemen yang diposisikan secara absolut.
Di game dan program lain yang harus menganimasi grafis dan merespons input pengguna tanpa delay yang terlihat, efisiensi itu penting. Walaupun DOM awalnya gak didesain buat grafis berkinerja tinggi, dia sebenernya lebih baik di ini daripada yang kamu harapkan. Kamu lihat beberapa animasi di Bab 14. Di mesin modern, game sederhana kaya ini berkinerja baik, bahkan kalau kita gak terlalu khawatir soal optimasi.
Di bab berikutnya, kita bakal ngeksplorasi teknologi browser lain, tag <canvas>, yang nyediain cara yang lebih tradisional buat menggambar grafis, bekerja dalam bentuk shape dan piksel bukannya elemen DOM.
Level
Kita bakal mau cara yang bisa dibaca manusia dan bisa diedit manusia buat nentuin level. Karena gak masalah kalau semuanya mulai di grid, kita bisa pakai string besar di mana setiap karakter mewakili elemen, entah bagian dari grid latar atau elemen bergerak.
Rencana buat level kecil mungkin keliatan kaya gini:
let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;Titik adalah ruang kosong, karakter hash (#) adalah dinding, dan tanda plus adalah lava. Posisi awal pemain adalah tanda at (@). Setiap karakter O adalah koin, dan tanda sama dengan (=) di atas adalah blok lava yang bergerak bolak-balik secara horizontal.
Kita bakal mendukung dua jenis lava bergerak tambahan: karakter pipa (|) bikin gumpalan yang bergerak vertikal, dan v nunjukin lava menetes, lava yang bergerak vertikal yang gak memantul bolak-balik tapi cuma bergerak turun, melompat balik ke posisi awalnya waktu dia kena lantai.
Game utuh terdiri dari beberapa level yang harus diselesaiin pemain. Sebuah level selesai waktu semua koin udah dikumpulin. Kalau pemain nyentuh lava, level saat ini dikembaliin ke posisi awalnya, dan pemain boleh coba lagi.
Membaca Level
Class berikut nyimpen objek level. Argumennya harus string yang mendefinisikan level-nya.
class Level {
constructor(plan) {
let rows = plan.trim().split("\n").map(l => [...l]);
this.height = rows.length;
this.width = rows[0].length;
this.startActors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
let type = levelChars[ch];
if (typeof type != "string") {
let pos = new Vec(x, y);
this.startActors.push(type.create(pos, ch));
type = "empty";
}
return type;
});
});
}
}Method trim dipakai buat ngehapus whitespace di awal dan akhir string plan-nya. Ini ngebolehin plan contoh kita mulai dengan newline biar semua baris langsung di bawah satu sama lain. String sisanya dipecah di karakter newline, dan setiap baris di-spread jadi array, ngehasilin array karakter.
Jadi rows megang array dari array karakter, baris-baris plan-nya. Kita bisa nurunin lebar dan tinggi level dari ini. Tapi kita masih harus misahin elemen bergerak dari grid latar. Kita bakal nyebut elemen bergerak actor. Mereka bakal disimpan di array objek. Latarnya bakal jadi array dari array string, megang jenis field kaya "empty", "wall", atau "lava".
Buat bikin array ini, kita me-map baris-barisnya lalu isinya. Ingat bahwa map ngasih indeks array sebagai argumen kedua ke fungsi pemetaan, yang ngasih tahu kita koordinat x dan y dari karakter tertentu. Posisi di game bakal disimpan sebagai pasangan koordinat, dengan kiri atas jadi 0,0 dan setiap kotak latar tingginya dan lebarnya 1 unit.
Buat menafsirkan karakter di plan-nya, constructor Level pakai objek levelChars, yang, buat setiap karakter yang dipakai di deskripsi level, megang string kalau dia jenis latar, dan class kalau dia ngehasilin actor. Waktu type adalah class actor, method static create-nya dipakai buat bikin objek, yang ditambahin ke startActors, dan fungsi pemetaannya ngembaliin "empty" buat kotak latar ini.
Posisi actor-nya disimpan sebagai objek Vec. Ini vektor dua dimensi, objek dengan properti x dan y, kaya yang dilihat di latihan Bab 6.
Seiring game jalan, actor bakal berakhir di tempat berbeda atau bahkan menghilang sepenuhnya (kaya koin waktu dikumpulin). Kita bakal pakai class State buat ngelacak keadaan game yang lagi jalan.
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");
}
}Properti status bakal beralih ke "lost" atau "won" waktu game udah berakhir.
Ini lagi-lagi struktur data persistent, ngupdate keadaan game bikin keadaan baru dan ngebiarin yang lama utuh.
Actor
Objek actor mewakili posisi dan keadaan saat ini dari elemen bergerak tertentu (pemain, koin, atau lava bergerak) di game kita. Semua objek actor memenuhi interface yang sama. Mereka punya properti size dan pos yang megang ukuran dan koordinat sudut kiri atas dari persegi panjang yang mewakili actor ini, dan method update.
Method update ini dipakai buat ngitung keadaan dan posisi baru mereka setelah langkah waktu tertentu. Dia nyimulasiin hal yang dilakuin actor-nya, bergerak sebagai respons ke tombol panah buat pemain dan memantul bolak-balik buat lava, dan ngembaliin objek actor baru yang udah diupdate.
Properti type berisi string yang ngidentifikasi jenis actor-nya, "player", "coin", atau "lava". Ini berguna waktu menggambar game, tampilan persegi panjang yang digambar buat actor didasarkan pada jenisnya.
Class actor punya method static create yang dipakai constructor Level buat bikin actor dari karakter di plan level. Dia dikasih koordinat karakter dan karakternya sendiri, yang perlu karena class Lava nanganin beberapa karakter berbeda.
Ini class Vec yang bakal kita pakai buat nilai dua dimensi kita, kaya posisi dan ukuran actor.
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);
}
}Method times menskalakan vektor dengan angka yang dikasih. Dia bakal berguna waktu kita perlu ngaliin vektor kecepatan dengan interval waktu buat dapet jarak yang ditempuh selama waktu itu.
Jenis actor yang berbeda dapet class mereka sendiri, karena perilaku mereka sangat berbeda. Kita definisikan class-class ini. Kita bakal nyampe ke method update mereka nanti.
Class player punya properti speed yang nyimpen kecepatannya saat ini buat nyimulasiin momentum dan gravitasi.
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);Karena pemain tingginya satu setengah kotak, posisi awalnya disetel jadi setengah kotak di atas posisi tempat karakter @ muncul. Dengan cara ini, bagian bawahnya sejajar dengan bagian bawah kotak tempat dia muncul.
Properti size sama buat semua instance Player, jadi kita nyimpennya di prototype bukannya di instance-nya sendiri. Kita bisa aja pakai getter kaya type, tapi itu bakal bikin dan ngembaliin objek Vec baru setiap kali propertinya dibaca, yang bakal boros. (String, karena immutable, gak harus dibikin ulang setiap kali mereka dievaluasi.)
Waktu nyusun actor Lava, kita perlu nginisialisasi objeknya secara berbeda tergantung karakter yang jadi dasarnya. Lava dinamis bergerak dengan kecepatannya saat ini sampai dia kena rintangan. Di titik itu, kalau dia punya properti reset, dia bakal melompat balik ke posisi awalnya (menetes). Kalau gak, dia bakal ngebalik kecepatannya dan lanjut ke arah lain (memantul).
Method create ngelihat karakter yang dikasih constructor Level dan bikin actor lava yang sesuai.
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);Actor Coin relatif sederhana. Mereka kebanyakan cuma duduk di tempatnya. Tapi biar game-nya sedikit lebih hidup, mereka dikasih “goyangan” (wobble), gerakan naik-turun vertikal yang sedikit. Buat ngelacak ini, objek koin nyimpen posisi dasar juga properti wobble yang ngelacak fase gerakan memantulnya. Bareng-bareng, ini nentuin posisi aktual koinnya (disimpan di properti 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);Di Bab 14, kita lihat bahwa Math.sin ngasih kita koordinat-y dari titik di lingkaran. Koordinat itu bolak-balik dalam bentuk gelombang yang mulus seiring kita bergerak di lingkaran, yang bikin fungsi sinus berguna buat memodelkan gerakan bergelombang.
Buat ngehindarin situasi di mana semua koin bergerak naik-turun secara sinkron, fase awal setiap koin diacak. Periode gelombang Math.sin, lebar gelombang yang dia hasilkan, adalah 2π. Kita ngaliin nilai yang dikembaliin Math.random dengan angka itu buat ngasih koinnya posisi awal acak di gelombangnya.
Kita sekarang bisa mendefinisikan objek levelChars yang memetakan karakter plan ke jenis grid latar atau class actor.
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};Itu ngasih kita semua bagian yang dibutuhin buat bikin instance Level.
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9Tugas ke depan adalah nampilin level kaya gitu di layar dan memodelkan waktu dan gerakan di dalamnya.
Menggambar
Di bab berikutnya, kita bakal nampilin game yang sama dengan cara berbeda. Biar itu mungkin, kita naruh logika menggambar di balik interface dan ngasihnya ke game sebagai argumen. Dengan cara itu, kita bisa pakai program game yang sama dengan modul tampilan baru yang berbeda.
Objek tampilan game menggambar level dan state tertentu. Kita ngasih constructor-nya ke game biar bisa diganti. Class tampilan yang kita definisikan di bab ini disebut DOMDisplay karena dia pakai elemen DOM buat nampilin level-nya.
Kita bakal pakai style sheet buat nyetel warna aktual dan properti tetap lainnya dari elemen yang nyusun game-nya. Bisa juga langsung nge-assign ke properti style elemennya waktu kita bikin mereka, tapi itu bakal ngehasilin program yang lebih bertele-tele.
Fungsi pembantu berikut nyediain cara ringkas buat bikin elemen dan ngasih dia beberapa atribut dan node anak:
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;
}Tampilan dibikin dengan ngasih dia elemen induk yang harus dia tambahin dirinya ke situ dan objek level.
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 latar level, yang gak pernah berubah, digambar sekali. Actor digambar ulang setiap kali tampilannya diupdate dengan state tertentu. Properti actorLayer bakal dipakai buat ngelacak elemen yang megang actor biar mereka bisa gampang dihapus dan diganti.
Koordinat dan ukuran kita dilacak dalam unit grid, di mana ukuran atau jarak 1 berarti satu blok grid. Waktu nyetel ukuran piksel, kita bakal harus menskalakan koordinat ini ke atas, semua di game bakal sangat kecil dengan satu piksel per kotak. Konstanta scale ngasih jumlah piksel yang dimakan satu unit di layar.
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})))
));
}Bentuk elemen <table> cocok banget dengan struktur properti rows level, setiap baris grid diubah jadi baris tabel (elemen <tr>). String di grid dipakai sebagai nama class buat elemen sel tabel (<td>). Kodenya pakai operator spread (tiga titik) buat ngasih array node anak ke elt sebagai argumen terpisah.
CSS berikut bikin tabelnya keliatan kaya latar yang kita mau:
.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; }Beberapa dari ini (table-layout, border-spacing, dan padding) dipakai buat menekan perilaku default yang gak diinginkan. Kita gak mau layout tabelnya tergantung isi selnya, dan kita gak mau ruang antara sel tabel atau padding di dalamnya.
Aturan background nyetel warna latar. CSS ngebolehin warna ditentuin baik sebagai kata (white) atau dengan format kaya rgb(R, G, B), di mana komponen merah, hijau, dan biru dari warnanya dipisah jadi tiga angka dari 0 sampai 255. Di rgb(52, 166, 251), komponen merahnya 52, hijaunya 166, dan birunya 251. Karena komponen birunya paling besar, warna hasilnya bakal kebiruan. Di aturan .lava, angka pertama (merah) paling besar.
Kita menggambar setiap actor dengan bikin elemen DOM buatnya dan nyetel posisi dan ukuran elemen itu berdasarkan properti actor-nya. Nilainya harus dikali scale buat pergi dari unit game ke piksel.
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;
}));
}Buat ngasih elemen lebih dari satu class, kita misahin nama class dengan spasi. Di kode CSS berikut, class actor ngasih actor posisi absolut mereka. Nama jenis mereka dipakai sebagai class ekstra buat ngasih mereka warna. Kita gak harus mendefinisikan class lava lagi karena kita pakai ulang class buat kotak grid lava yang kita definisikan sebelumnya.
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }Method syncState dipakai buat bikin tampilannya nunjukin state tertentu. Dia pertama ngehapus grafis actor yang lama, kalau ada, lalu menggambar ulang actor di posisi baru mereka. Mungkin menggoda buat nyoba pakai ulang elemen DOM buat actor, tapi biar itu jalan, kita bakal butuh banyak pembukuan tambahan buat ngaitin actor dengan elemen DOM dan buat mastiin kita ngehapus elemen waktu actor mereka menghilang. Karena biasanya cuma bakal ada segelintir actor di game, menggambar ulang semuanya gak mahal.
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);
};Dengan nambahin status level saat ini sebagai nama class ke pembungkusnya, kita bisa nge-style actor pemain sedikit berbeda waktu game-nya menang atau kalah dengan nambahin aturan CSS yang berlaku cuma waktu pemain punya elemen leluhur dengan class tertentu.
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}Setelah nyentuh lava, pemainnya jadi merah gelap, nunjukin hangus. Waktu koin terakhir udah dikumpulin, kita nambahin dua bayangan putih buram, satu ke kiri atas dan satu ke kanan atas, buat bikin efek halo putih.
Kita gak bisa berasumsi bahwa level-nya selalu muat di viewport, elemen tempat kita menggambar game-nya. Itu kenapa kita butuh pemanggilan scrollPlayerIntoView: dia mastiin kalau level-nya menonjol keluar dari viewport, kita nge-scroll viewport itu buat mastiin pemainnya dekat pusatnya. CSS berikut ngasih elemen DOM pembungkus game ukuran maksimum dan mastiin apa pun yang nyembul keluar dari kotak elemennya gak terlihat. Kita juga ngasih dia posisi relatif biar actor di dalamnya diposisikan relatif terhadap sudut kiri atas level.
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}Di method scrollPlayerIntoView, kita nemuin posisi pemain dan ngupdate posisi scroll elemen pembungkusnya. Kita ngubah posisi scroll dengan memanipulasi properti scrollLeft dan scrollTop elemen itu waktu pemainnya terlalu dekat ke pinggir.
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// Viewport-nya
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;
}
};Cara pusat pemain ditemuin nunjukin gimana method di tipe Vec kita ngebolehin perhitungan dengan objek ditulis dengan cara yang relatif bisa dibaca. Buat nemuin pusat actor-nya, kita nambahin posisinya (sudut kiri atasnya) dan setengah ukurannya. Itu pusat dalam koordinat level, tapi kita butuhnya dalam koordinat piksel, jadi kita lalu ngaliin vektor hasilnya dengan skala tampilan kita.
Berikutnya, serangkaian pengecekan memverifikasi bahwa posisi pemain gak di luar rentang yang dibolehin. Perhatiin bahwa kadang ini bakal nyetel koordinat scroll ngawur yang di bawah nol atau melampaui area yang bisa di-scroll elemennya. Ini gak apa-apa, DOM bakal ngebatasin mereka ke nilai yang bisa diterima. Nyetel scrollLeft ke -10 bakal bikinnya jadi 0.
Walaupun bakal sedikit lebih sederhana buat selalu nyoba nge-scroll pemain ke pusat viewport, ini bikin efek yang agak mengganggu. Waktu kamu melompat, tampilannya bakal terus bergeser naik dan turun. Lebih menyenangkan buat punya area “netral” di tengah layar tempat kamu bisa bergerak tanpa bikin scroll apa pun.
Kita sekarang bisa nampilin level mungil kita.
<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>Tag <link>, waktu dipakai dengan rel="stylesheet", adalah cara buat ngeload file CSS ke halaman. File game.css berisi style yang perlu buat game kita.
Gerakan dan Collision
Sekarang kita di titik di mana kita bisa mulai nambahin gerakan. Pendekatan dasar yang diambil kebanyakan game kaya ini adalah mecah waktu jadi langkah kecil dan, buat setiap langkah, mindahin actor sejauh jarak yang sesuai dengan kecepatan mereka dikali ukuran langkah waktunya. Kita bakal ngukur waktu dalam detik, jadi kecepatan diekspresikan dalam unit per detik.
Mindahin sesuatu itu gampang. Bagian susahnya adalah nanganin interaksi antar elemen. Waktu pemain kena dinding atau lantai, mereka gak boleh cuma bergerak menembusnya. Game harus nyadar waktu gerakan tertentu bikin objek kena objek lain dan merespons sesuai. Buat dinding, gerakannya harus dihentiin. Waktu kena koin, koin itu harus dikumpulin. Waktu nyentuh lava, game-nya harus kalah.
Nyelesaiin ini buat kasus umum adalah tugas besar. Kamu bisa nemuin library, biasanya disebut physics engine, yang nyimulasiin interaksi antara objek fisik dalam dua atau tiga dimensi. Kita bakal ngambil pendekatan yang lebih sederhana di bab ini, cuma nanganin collision antara objek persegi panjang dan nanganinnya dengan cara yang agak simplistik.
Sebelum mindahin pemain atau blok lava, kita ngetes apakah gerakannya bakal bawa dia ke dalam dinding. Kalau iya, kita cuma ngebatalin gerakannya sama sekali. Respons ke collision kaya gitu tergantung jenis actor-nya, pemain bakal berhenti, sedangkan blok lava bakal memantul balik.
Pendekatan ini butuh langkah waktu kita agak kecil, karena dia bakal bikin gerakan berhenti sebelum objeknya beneran nyentuh. Kalau langkah waktu (dan jadi langkah gerakan)-nya terlalu besar, pemain bakal berakhir melayang jarak yang terlihat di atas tanah. Pendekatan lain, bisa dibilang lebih baik tapi lebih rumit, adalah nemuin titik collision persis dan pindah ke sana. Kita bakal ngambil pendekatan sederhana dan nyembunyiin masalahnya dengan mastiin animasinya berjalan dalam langkah kecil.
Method ini ngasih tahu kita apakah persegi panjang (ditentuin oleh posisi dan ukuran) nyentuh elemen grid dari jenis yang dikasih.
Level.prototype.touches = function(pos, size, type) {
let xStart = Math.floor(pos.x);
let xEnd = Math.ceil(pos.x + size.x);
let yStart = Math.floor(pos.y);
let yEnd = Math.ceil(pos.y + size.y);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};Method-nya ngitung set kotak grid yang di-overlap sama body-nya dengan pakai Math.floor dan Math.ceil di koordinatnya. Ingat bahwa kotak grid ukurannya 1 kali 1 unit. Dengan ngebulatin sisi-sisi kotak ke atas dan ke bawah, kita dapet rentang kotak latar yang disentuh kotaknya.
Kita ngeloop blok kotak grid yang ditemuin dengan ngebulatin koordinatnya dan ngembaliin true waktu kotak yang cocok ditemuin. Kotak di luar level selalu diperlakukan sebagai "wall" buat mastiin pemain gak bisa ninggalin dunianya dan bahwa kita gak bakal gak sengaja nyoba baca di luar batas array rows kita.
Method update state pakai touches buat nyari tahu apakah pemainnya nyentuh lava.
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;
};Method-nya dikasih langkah waktu dan struktur data yang ngasih tahu tombol mana yang lagi ditahan. Hal pertama yang dia lakuin adalah manggil method update di semua actor, ngehasilin array actor yang udah diupdate. Actor juga dapet langkah waktu, tombolnya, dan state-nya biar mereka bisa mendasarkan update mereka pada itu. Cuma pemain yang bakal beneran baca tombol, karena itu satu-satunya actor yang dikendalikan keyboard.
Kalau game-nya udah berakhir, gak ada pemrosesan lebih lanjut yang harus dilakuin (game gak bisa menang setelah kalah, atau sebaliknya). Kalau gak, method-nya ngetes apakah pemainnya nyentuh lava latar. Kalau iya, game-nya kalah dan kita selesai. Terakhir, kalau game-nya beneran masih berlangsung, dia ngelihat apakah ada actor lain yang overlap pemain.
Overlap antar actor dideteksi dengan fungsi overlap. Dia ngambil dua objek actor dan ngembaliin true waktu mereka nyentuh, yaitu kasusnya waktu mereka overlap baik sepanjang sumbu-x dan sepanjang sumbu-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;
}Kalau ada actor yang overlap, method collide-nya dapet kesempatan buat ngupdate state-nya. Nyentuh actor lava nyetel status game ke "lost". Koin menghilang waktu kamu nyentuh mereka dan nyetel status ke "won" waktu mereka koin terakhir level-nya.
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 Actor
Method update objek actor ngambil sebagai argumen langkah waktu, objek state, dan objek keys. Yang buat jenis actor Lava ngabaikan objek 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));
}
};Method update ini ngitung posisi baru dengan nambahin hasil kali langkah waktu dan kecepatan saat ini ke posisi lamanya. Kalau gak ada rintangan yang ngeblok posisi baru itu, dia pindah ke sana. Kalau ada rintangan, perilakunya tergantung jenis blok lava-nya, lava menetes punya posisi reset, yang dia lompat balik ke situ waktu dia kena sesuatu. Lava memantul ngebalik kecepatannya dengan ngaliinnya dengan -1 biar dia mulai bergerak ke arah berlawanan.
Koin pakai method update mereka buat bergoyang. Mereka ngabaikan collision dengan grid, karena mereka cuma bergoyang di dalam kotak mereka sendiri.
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);
};Properti wobble dinaikin buat ngelacak waktu lalu dipakai sebagai argumen ke Math.sin buat nemuin posisi baru di gelombang. Posisi koin saat ini lalu dihitung dari posisi dasarnya dan offset berdasarkan gelombang ini.
Itu ninggalin pemainnya sendiri. Gerakan pemain ditangani terpisah per sumbu karena kena lantai gak boleh nyegah gerakan horizontal, dan kena dinding gak boleh ngehentiin gerakan jatuh atau melompat.
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));
};Gerakan horizontalnya dihitung berdasarkan keadaan tombol panah kiri dan kanan. Waktu gak ada dinding yang ngeblok posisi baru yang dibikin gerakan ini, dia dipakai. Kalau gak, posisi lamanya dijaga.
Gerakan vertikal bekerja dengan cara serupa tapi harus nyimulasiin melompat dan gravitasi. Kecepatan vertikal pemain (ySpeed) pertama dipercepat buat memperhitungkan gravitasi.
Kita ngecek dinding lagi. Kalau kita gak kena apa pun, posisi barunya dipakai. Kalau ada dinding, ada dua kemungkinan hasil. Waktu panah atas ditekan dan kita bergerak turun (artinya hal yang kita kena ada di bawah kita), kecepatannya disetel ke nilai negatif yang relatif besar. Ini bikin pemain melompat. Kalau itu bukan kasusnya, pemainnya cuma nabrak sesuatu, dan kecepatannya disetel ke nol.
Kekuatan gravitasi, kecepatan melompat, dan konstanta lain di game ditentuin dengan cuma nyoba beberapa angka dan ngelihat mana yang terasa pas. Kamu bisa coba bereksperimen dengan mereka.
Tracking Tombol
Buat game kaya ini, kita gak mau tombol berefek sekali per penekanan tombol. Sebaliknya, kita mau efek mereka (mindahin figur pemain) tetap aktif selama mereka ditahan.
Kita perlu nyiapin key handler yang nyimpen keadaan saat ini dari tombol panah kiri, kanan, dan atas. Kita juga bakal mau manggil preventDefault buat tombol-tombol itu biar mereka gak berakhir nge-scroll halaman.
Fungsi berikut, waktu dikasih array nama tombol, bakal ngembaliin objek yang ngelacak posisi saat ini dari tombol-tombol itu. Dia mendaftarkan event handler buat event "keydown" dan "keyup" dan, waktu kode tombol di event-nya ada di set kode yang dia lacak, ngupdate objeknya.
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"]);Fungsi handler yang sama dipakai buat kedua jenis event. Dia ngelihat properti type event object buat nentuin apakah keadaan tombolnya harus diupdate jadi true ("keydown") atau false ("keyup").
Menjalankan Game
Fungsi requestAnimationFrame, yang kita lihat di Bab 14, nyediain cara bagus buat menganimasi game. Tapi interface-nya cukup primitif, pakainya butuh kita ngelacak waktu fungsinya dipanggil terakhir kali dan manggil requestAnimationFrame lagi setelah setiap frame.
Kita definisikan fungsi pembantu yang ngebungkus semua itu di interface yang praktis dan ngebolehin kita cuma manggil runAnimation, ngasihnya fungsi yang ngarepin selisih waktu sebagai argumen dan menggambar satu frame. Waktu fungsi frame-nya ngembaliin nilai false, animasinya berhenti.
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);
}Saya udah nyetel langkah frame maksimum 100 milidetik (sepersepuluh detik). Waktu tab atau jendela browser dengan halaman kita disembunyiin, pemanggilan requestAnimationFrame bakal ditangguhkan sampai tab atau jendelanya ditampilin lagi. Di kasus ini, selisih antara lastTime dan time bakal jadi seluruh waktu di mana halamannya disembunyiin. Memajukan game sebanyak itu dalam satu langkah bakal keliatan konyol dan mungkin bikin side effect aneh, kaya pemain jatuh menembus lantai.
Fungsinya juga ngonversi langkah waktu ke detik, yang jumlah yang lebih gampang dipikirin daripada milidetik.
Fungsi runLevel ngambil objek Level dan constructor tampilan dan ngembaliin promise. Dia nampilin level-nya (di document.body) dan ngebolehin pengguna memainkannya. Waktu level-nya selesai (kalah atau menang), runLevel nunggu satu detik lagi (biar pengguna lihat apa yang terjadi) lalu ngebersihin tampilannya, ngehentiin animasinya, dan me-resolve promise-nya ke status akhir game-nya.
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;
}
});
});
}Game adalah urutan level. Setiap kali pemain mati, level saat ini dimulai ulang. Waktu sebuah level selesai, kita pindah ke level berikutnya. Ini bisa diekspresikan dengan fungsi berikut, yang ngambil array plan level (string) dan constructor tampilan:
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!");
}Karena kita bikin runLevel ngembaliin promise, runGame bisa ditulis pakai fungsi async, kaya yang ditunjukin di Bab 11. Dia ngembaliin promise lain, yang resolve waktu pemain nyelesaiin game-nya.
Ada sekumpulan plan level yang tersedia di binding GAME_LEVELS di sandbox bab ini. Halaman ini ngasih mereka ke runGame, mulai game beneran.
<link rel="stylesheet" href="css/game.css">
<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>Lihat apakah kamu bisa ngalahin mereka. Saya senang bikin mereka.
Ringkasan
- Level ditulis sebagai string besar (grid karakter).
Levelmem-parse-nya jadi grid latar + array actor (elemen bergerak). - State itu persistent: tiap update bikin state baru, gak ngubah yang lama.
statusbisa"playing","lost","won". - Actor (
Player,Coin,Lava) punyapos,size,type, methodupdate(ngitung state baru per langkah waktu), dancollide.Vecbuat vektor 2D. - DOMDisplay menggambar grid latar sekali (tabel) dan actor tiap frame (div ber-position absolut), diskalakan
scalepiksel per unit. - Collision ditangani per sumbu dengan
touches(cek grid) danoverlap(antar actor). Gerakan yang bakal masuk dinding dibatalin. - Tombol dilacak
trackKeys(tetap aktif selama ditahan). Loop pakairunAnimation/requestAnimationFrame, dengan langkah waktu maks 100ms. runLevelngembaliin promise;runGame(async) ngerantai level-level.
Latihan
Game Over
Tradisional buat game platformer buat bikin pemain mulai dengan jumlah nyawa (lives) terbatas dan ngurangin satu nyawa setiap kali mereka mati. Waktu pemain kehabisan nyawa, game-nya dimulai ulang dari awal.
Sesuaikan runGame buat ngimplementasiin nyawa. Bikin pemain mulai dengan tiga. Keluarin jumlah nyawa saat ini (pakai console.log) setiap kali level dimulai.
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// Fungsi runGame yang lama. Modifikasi...
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>Solusi:
<link rel="stylesheet" href="css/game.css">
<body>
<script>
async function runGame(plans, Display) {
let lives = 3;
for (let level = 0; level < plans.length && lives > 0;) {
console.log(`Level ${level + 1}, lives: ${lives}`);
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
else lives--;
}
if (lives > 0) {
console.log("You've won!");
} else {
console.log("Game over");
}
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>Menjeda Game
Bikin mungkin buat menjeda (menangguhkan) dan melanjutkan game dengan mencet esc. Kamu bisa ngelakuin ini dengan ngubah fungsi runLevel buat nyiapin event handler keyboard yang menginterupsi atau melanjutkan animasi setiap kali esc ditekan.
Interface runAnimation mungkin gak keliatan cocok buat ini sekilas, tapi dia cocok kalau kamu ngatur ulang cara runLevel manggilnya.
Waktu kamu punya itu jalan, ada sesuatu lain yang bisa kamu coba. Cara kita udah mendaftarkan event handler keyboard agak bermasalah. Objek arrowKeys saat ini binding global, dan event handler-nya dipertahankan bahkan waktu gak ada game yang jalan. Kamu bisa bilang mereka bocor keluar dari sistem kita. Perluas trackKeys buat nyediain cara ngebatalin pendaftaran handler-nya, lalu ubah runLevel buat mendaftarkan handler-nya waktu dia mulai dan ngebatalin pendaftaran mereka lagi waktu dia selesai.
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// Fungsi runLevel yang lama. Modifikasi ini...
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>Petunjuk:
Animasi bisa diinterupsi dengan ngembaliin false dari fungsi yang dikasih ke runAnimation. Dia bisa dilanjutin dengan manggil runAnimation lagi.
Jadi kita perlu ngomunikasiin fakta bahwa kita lagi menjeda game ke fungsi yang dikasih ke runAnimation. Buat itu, kamu bisa pakai binding yang bisa diakses event handler maupun fungsi itu.
Waktu nemuin cara ngebatalin pendaftaran handler yang didaftarkan trackKeys, ingat bahwa nilai fungsi yang persis sama yang dikasih ke addEventListener harus dikasih ke removeEventListener buat berhasil ngehapus handler. Jadi, nilai fungsi handler yang dibikin di trackKeys harus tersedia buat kode yang ngebatalin pendaftaran handler-nya.
Kamu bisa nambahin properti ke objek yang dikembaliin trackKeys, yang berisi entah nilai fungsi itu atau method yang nanganin pembatalan pendaftaran langsung.
Solusi:
<link rel="stylesheet" href="css/game.css">
<body>
<script>
// Level yang lagi ditampilin bisa dalam tiga keadaan `running`:
// "yes": Jalan normal.
// "no": Dijeda, animasi gak jalan.
// "pausing": Harus dijeda, tapi animasi masih jalan.
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
let running = "yes";
return new Promise(resolve => {
function escHandler(event) {
if (event.key != "Escape") return;
event.preventDefault();
if (running == "no") {
running = "yes";
runAnimation(frame);
} else if (running == "yes") {
running = "pausing";
} else {
running = "yes";
}
}
window.addEventListener("keydown", escHandler);
let arrowKeys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
function frame(time) {
if (running == "pausing") {
running = "no";
return false;
}
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();
window.removeEventListener("keydown", escHandler);
arrowKeys.unregister();
resolve(state.status);
return false;
}
}
runAnimation(frame);
});
}
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);
down.unregister = () => {
window.removeEventListener("keydown", track);
window.removeEventListener("keyup", track);
};
return down;
}
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>Sebuah Monster
Tradisional buat game platformer buat punya musuh yang bisa kamu kalahin dengan melompat di atas mereka. Latihan ini minta kamu nambahin jenis actor kaya gitu ke game.
Kita bakal nyebut actor ini monster. Monster bergerak cuma horizontal. Kamu bisa bikin mereka bergerak ke arah pemain, memantul bolak-balik kaya lava horizontal, atau punya pola gerakan lain apa pun yang kamu mau. Class-nya gak harus nanganin jatuh, tapi dia harus mastiin monster gak jalan menembus dinding.
Waktu monster nyentuh pemain, efeknya tergantung apakah pemain lagi melompat di atas mereka atau gak. Kamu bisa memperkirakan ini dengan ngecek apakah bagian bawah pemain dekat bagian atas monster. Kalau ini kasusnya, monster-nya menghilang. Kalau gak, game-nya kalah.
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>
<body>
<script>
// Lengkapi method constructor, update, dan collide
class Monster {
constructor(pos, /* ... */) {}
get type() { return "monster"; }
static create(pos) {
return new Monster(pos.plus(new Vec(0, -1)));
}
update(time, state) {}
collide(state) {}
}
Monster.prototype.size = new Vec(1.2, 2);
levelChars["M"] = Monster;
runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
</script>
</body>Petunjuk:
Kalau kamu mau ngimplementasiin jenis gerakan yang punya state, kaya memantul, pastiin kamu nyimpen state yang perlu di objek actor-nya, sertain dia sebagai argumen constructor dan tambahin dia sebagai properti.
Ingat bahwa update ngembaliin objek baru bukannya ngubah yang lama.
Waktu nanganin collision, temuin pemainnya di state.actors dan bandingin posisinya ke posisi monster. Buat dapet bagian bawah pemain, kamu harus nambahin ukuran vertikalnya ke posisi vertikalnya. Pembikinan state yang diupdate bakal mirip method collide Coin (ngehapus actor-nya) atau Lava (ngubah status ke "lost"), tergantung posisi pemain.
Solusi:
<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>
<body>
<script>
const monsterSpeed = 4;
class Monster {
constructor(pos) { this.pos = pos; }
get type() { return "monster"; }
static create(pos) { return new Monster(pos.plus(new Vec(0, -1))); }
update(time, state) {
let player = state.player;
let speed = (player.pos.x < this.pos.x ? -1 : 1) * time * monsterSpeed;
let newPos = new Vec(this.pos.x + speed, this.pos.y);
if (state.level.touches(newPos, this.size, "wall")) return this;
else return new Monster(newPos);
}
collide(state) {
let player = state.player;
if (player.pos.y + player.size.y < this.pos.y + 0.5) {
let filtered = state.actors.filter(a => a != this);
return new State(state.level, filtered, state.status);
} else {
return new State(state.level, state.actors, "lost");
}
}
}
Monster.prototype.size = new Vec(1.2, 2);
levelChars["M"] = Monster;
runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
</script>
</body>