A * Pathfinding untuk Platformer Berbasis Grid 2D: Membuat Bot Mengikuti Jalan
() translation by (you can also view the original English article)
Dalam tutorial ini, kami akan menggunakan algoritme peruntukkan platformer yang telah kami bangun untuk memberi tenaga bot yang dapat mengikuti jalur itu sendiri; cukup klik pada lokasi dan itu akan berjalan dan melompat ke sana. Ini sangat berguna untuk NPC!
Demo
Anda dapat memainkan demo Unity, atau versi WebGL (100MB +), untuk melihat hasil akhir dalam aksi. Gunakan WASD untuk memindahkan karakter, klik kiri di suatu tempat untuk menemukan jalan yang dapat Anda ikuti untuk sampai ke sana, klik kanan sel untuk beralih ke tanah pada titik itu, klik tengah untuk menempatkan platform satu arah, dan klik -dan-tarik slider untuk mengubah nilainya.
Memperbarui Engine
Penanganan Status Bot
Bot memiliki dua definisi: yang pertama adalah tidak melakukan apa-apa, dan yang kedua adalah untuk menangani gerakan. Namun, dalam permainan Anda, Anda mungkin akan membutuhkan lebih banyak untuk mengubah perilaku bot sesuai dengan situasi.
1 |
public enum BotState |
2 |
{
|
3 |
None = 0, |
4 |
MoveTo, |
5 |
}
|
Loop pembaruan bot akan melakukan hal yang berbeda tergantung pada status mana yang saat ini ditetapkan ke mCurrentBotState
:
1 |
void BotUpdate() |
2 |
{ |
3 |
switch (mCurrentBotState) |
4 |
{ |
5 |
case BotState.None: |
6 |
/* no need to do anything */ |
7 |
break; |
8 |
|
9 |
case BotState.MoveTo: |
10 |
/* bot movement update logic */ |
11 |
break; |
12 |
} |
13 |
|
14 |
CharacterUpdate(); |
15 |
} |
Fungsi CharacterUpdate
menangani semua input dan pembaruan fisika untuk bot.
Untuk mengubah status, kami akan menggunakan fungsi ChangeState
yang hanya memberikan nilai baru ke mCurrentBotState
:
1 |
public void ChangeState(BotState newState) |
2 |
{
|
3 |
mCurrentBotState = newState; |
4 |
}
|
Mengendalikan Bot
Kami akan mengontrol bot dengan mensimulasikan input, yang akan kami tetapkan ke array Booleans:
1 |
protected bool[] mInputs; |
Array ini diindeks oleh KeyInput
enum
:
1 |
public enum KeyInput |
2 |
{
|
3 |
GoLeft = 0, |
4 |
GoRight, |
5 |
GoDown, |
6 |
Jump, |
7 |
Count
|
8 |
}
|
Misalnya, jika kita ingin mensimulasikan tekan tombol kiri, kita akan melakukannya seperti ini:
1 |
mInputs[(int)KeyInput.GoLeft] = true; |
Logika karakter kemudian akan menangani input buatan ini dengan cara yang sama yang akan menangani masukan nyata.
Kita juga membutuhkan fungsi pembantu tambahan atau tabel pencarian untuk mendapatkan jumlah bingkai yang kita perlukan untuk menekan tombol lompat agar dapat melompat ke sejumlah blok yang diberikan:
1 |
int GetJumpFrameCount(int deltaY) |
2 |
{
|
3 |
if (deltaY <= 0) |
4 |
return 0; |
5 |
else
|
6 |
{
|
7 |
switch (deltaY) |
8 |
{
|
9 |
case 1: |
10 |
return 1; |
11 |
case 2: |
12 |
return 2; |
13 |
case 3: |
14 |
return 5; |
15 |
case 4: |
16 |
return 8; |
17 |
case 5: |
18 |
return 14; |
19 |
case 6: |
20 |
return 21; |
21 |
default: |
22 |
return 30; |
23 |
}
|
24 |
}
|
25 |
}
|
Perhatikan bahwa ini hanya akan bekerja secara konsisten jika permainan kami diperbarui dengan frekuensi tetap dan kecepatan lompatan awal karakter adalah sama. Idealnya, kami akan menghitung nilai-nilai ini secara terpisah untuk setiap karakter tergantung pada kecepatan lompatan karakter itu, tetapi hal di atas akan berfungsi dengan baik dalam kasus kami.
Mempersiapkan dan Memperoleh Jalan untuk Diikuti
Membatasi Lokasi Tujuan
Sebelum kita benar-benar menggunakan pathfinder, itu akan menjadi ide yang baik untuk memaksa tujuan tujuan berada di tanah. Ini karena pemain sangat mungkin untuk mengklik titik yang sedikit di atas tanah, dalam hal ini jalur bot akan berakhir dengan lompatan canggung ke udara. Dengan menurunkan titik akhir agar berada di permukaan tanah, kita dapat dengan mudah menghindari hal ini.
Pertama, mari kita lihat fungsi TappedOnTile
. Fungsi ini dipanggil ketika pemain mengklik di mana saja dalam game; parameter mapPos
adalah posisi ubin yang diklik pemain:
1 |
public void TappedOnTile(Vector2i mapPos) |
2 |
{
|
3 |
}
|
Kita perlu menurunkan posisi ubin yang diklik hingga berada di tanah:
1 |
public void TappedOnTile(Vector2i mapPos) |
2 |
{
|
3 |
while (!(mMap.IsGround(mapPos.x, mapPos.y))) |
4 |
--mapPos.y; |
5 |
}
|
Akhirnya, setelah kami tiba di ubin tanah, kami tahu di mana kami ingin memindahkan karakter ke:
1 |
public void TappedOnTile(Vector2i mapPos) |
2 |
{
|
3 |
while (!(mMap.IsGround(mapPos.x, mapPos.y))) |
4 |
--mapPos.y; |
5 |
|
6 |
MoveTo(new Vector2i(mapPos.x, mapPos.y + 1)); |
7 |
}
|
Menentukan Lokasi Awal
Sebelum kita benar-benar memanggil fungsi FindPath
, kita perlu memastikan bahwa kita melewati sel awal yang benar.
Pertama, mari kita asumsikan bahwa ubin awal adalah sel kiri-bawah karakter:
1 |
public void MoveTo(Vector2i destination) |
2 |
{
|
3 |
Vector2i startTile = mMap.GetMapTileAtPoint(mAABB.Center - mAABB.HalfSize + Vector2.one * Map.cTileSize * 0.5f); |
4 |
}
|
Ubin ini mungkin bukan yang ingin kita berikan kepada algoritme sebagai simpul pertama, karena jika karakter kita berdiri di tepi platform, startTile
dihitung dengan cara ini mungkin tidak memiliki ground, seperti dalam situasi berikut:



Dalam hal ini, kami ingin mengatur simpul awal ke ubin yang ada di sisi kiri karakter, bukan di tengahnya.
Mari kita mulai dengan membuat fungsi yang akan memberi tahu kita apakah karakter akan sesuai dengan posisi yang berbeda, dan jika itu benar, apakah itu di tanah di tempat itu:
1 |
bool IsOnGroundAndFitsPos(Vector2i pos) |
2 |
{
|
3 |
}
|
Pertama, mari kita lihat apakah karakter sesuai dengan tempatnya. Jika tidak, kami dapat segera mengembalikan false
:
1 |
bool IsOnGroundAndFitsPos(Vector2i pos) |
2 |
{
|
3 |
for (int y = pos.y; y < pos.y + mHeight; ++y) |
4 |
{
|
5 |
for (int x = pos.x; x < pos.x + mWidth; ++x) |
6 |
{
|
7 |
if (mMap.IsObstacle(x, y)) |
8 |
return false; |
9 |
}
|
10 |
}
|
11 |
}
|
Sekarang kita bisa melihat apakah ada ubin di bawah karakter adalah ubin tanah:
1 |
bool IsOnGroundAndFitsPos(Vector2i pos) |
2 |
{
|
3 |
for (int y = pos.y; y < pos.y + mHeight; ++y) |
4 |
{
|
5 |
for (int x = pos.x; x < pos.x + mWidth; ++x) |
6 |
{
|
7 |
if (mMap.IsObstacle(x, y)) |
8 |
return false; |
9 |
}
|
10 |
}
|
11 |
|
12 |
for (int x = pos.x; x < pos.x + mWidth; ++x) |
13 |
{
|
14 |
if (mMap.IsGround(x, pos.y - 1)) |
15 |
return true; |
16 |
}
|
17 |
|
18 |
return false; |
19 |
}
|
Mari kembali ke fungsi MoveTo
, dan lihat apakah kita harus mengubah ubin awal. Kita perlu melakukannya jika karakter berada di tanah tetapi ubin awal tidak:
1 |
Vector2i startTile = mMap.GetMapTileAtPoint(mAABB.Center - mAABB.HalfSize + Vector2.one * Map.cTileSize * 0.5f); |
2 |
|
3 |
if (mOnGround && !IsOnGroundAndFitsPos(startTile)) |
4 |
{
|
5 |
}
|
Kita tahu bahwa, dalam hal ini, karakter berdiri di tepi kiri atau tepi kanan platform.
Pertama mari kita periksa tepi kanan; jika karakter cocok di sana dan ubin berada di tanah, maka kita perlu memindahkan ubin mulai satu ruang ke kanan. Jika tidak, maka kita perlu memindahkannya ke kiri.
1 |
if (mOnGround && !IsOnGroundAndFitsPos(startTile)) |
2 |
{
|
3 |
if (IsOnGroundAndFitsPos(new Vector2i(startTile.x + 1, startTile.y))) |
4 |
startTile.x += 1; |
5 |
else
|
6 |
startTile.x -= 1; |
7 |
}
|
Sekarang kita harus memiliki semua data yang kita perlukan untuk memanggil pathfinder:
1 |
var path = mMap.mPathFinder.FindPath( |
2 |
startTile, |
3 |
destination, |
4 |
Mathf.CeilToInt(mAABB.HalfSizeX / 8.0f), |
5 |
Mathf.CeilToInt(mAABB.HalfSizeY / 8.0f), |
6 |
(short)mMaxJumpHeight); |
Argumen pertama adalah ubin awal.
Yang kedua adalah tujuan; kita bisa melewati ini apa adanya.
Argumen ketiga dan keempat adalah lebar dan tinggi yang perlu didekati oleh ukuran petak. Perhatikan bahwa di sini kita ingin menggunakan langit-langit tinggi dalam ubin —jadi, misalnya, jika tinggi sebenarnya dari karakter adalah 2,3 ubin, kami ingin algoritmanya berpikir bahwa karakternya adalah 3 ubin tinggi. (Lebih baik jika tinggi sebenarnya dari karakter sebenarnya sedikit lebih kecil dari ukurannya di ubin, untuk memungkinkan sedikit lebih banyak ruang untuk kesalahan dari jalur mengikuti AI.)
Akhirnya, argumen kelima adalah tinggi lompatan maksimum karakter.
Mencadangkan Daftar Node
Setelah menjalankan algoritma, kita harus memeriksa apakah hasilnya baik-baik saja - artinya, jika ada jalan yang ditemukan:
1 |
if (path != null && path.Count > 1) |
2 |
{
|
3 |
}
|
Jika demikian, kita perlu menyalin node ke buffer terpisah, karena jika beberapa objek lain memanggil fungsi FindPath
pathfinder sekarang, hasil lama akan ditimpa. Menyalin hasilnya ke daftar terpisah akan mencegah hal ini.
1 |
if (path != null && path.Count > 1) |
2 |
{
|
3 |
for (var i = path.Count - 1; i >= 0; --i) |
4 |
mPath.Add(path[i]); |
5 |
}
|
Seperti yang Anda lihat, kami menyalin hasilnya dalam urutan terbalik; ini karena hasilnya sendiri terbalik. Melakukan hal ini berarti simpul dalam daftar mPath
akan berada di urutan pertama hingga terakhir.
Sekarang mari kita atur node tujuan saat ini. Karena simpul pertama dalam daftar adalah titik awal, kita dapat benar-benar melewatkannya dan melanjutkan dari simpul kedua dan seterusnya:
1 |
if (path != null && path.Count > 1) |
2 |
{ |
3 |
for (var i = path.Count - 1; i >= 0; --i) |
4 |
mPath.Add(path[i]); |
5 |
|
6 |
mCurrentNodeId = 1; |
7 |
ChangeState(BotState.MoveTo); |
8 |
} |
Setelah menetapkan node tujuan saat ini, kami mengatur status bot ke MoveTo
, jadi status yang sesuai akan diaktifkan.
Mendapatkan Konteks
Sebelum kita mulai menulis aturan untuk gerakan AI, kita harus dapat menemukan situasi apa karakter tersebut pada suatu titik tertentu.
Kita perlu untuk tahu:
- posisi destinasi sebelumnya, saat ini dan berikutnya
- apakah tujuan saat ini ada di tanah atau di udara
- apakah karakter telah mencapai tujuan saat ini pada sumbu x
- apakah karakter telah mencapai tujuan saat ini pada sumbu y
Catatan: tujuan di sini belum tentu tujuan akhir; mereka adalah simpul dalam daftar dari bagian sebelumnya.
Informasi ini akan memungkinkan kita secara akurat menentukan apa yang harus dilakukan bot dalam situasi apa pun.
Mari kita mulai dengan mendeklarasikan fungsi untuk mendapatkan konteks ini:
1 |
public void GetContext(out Vector2 prevDest, out Vector2 currentDest, out Vector2 nextDest, out bool destOnGround, out bool reachedX, out bool reachedY) |
2 |
{
|
3 |
}
|
Menghitung Posisi Dunia Node Tujuan
Hal pertama yang harus kita lakukan dalam fungsi ini adalah menghitung posisi dunia dari simpul tujuan.
Mari kita mulai dengan menghitung ini untuk tujuan sebelumnya. Operasi ini tergantung pada bagaimana dunia game Anda diatur; dalam kasus saya, koordinat peta tidak sesuai dengan koordinat dunia, jadi kami perlu menerjemahkannya.
Menerjemahkannya sangat sederhana: kita hanya perlu mengalikan posisi simpul dengan ukuran ubin, lalu mengimbangi vektor yang dihitung berdasarkan posisi peta:
1 |
prevDest = new Vector2(mPath[mCurrentNodeId - 1].x * Map.cTileSize + mMap.transform.position.x, |
2 |
mPath[mCurrentNodeId - 1].y * Map.cTileSize + mMap.transform.position.y); |
Perhatikan bahwa kita mulai dengan mCurrentNodeId
sama dengan 1
, jadi kita tidak perlu khawatir tentang tidak sengaja mencoba mengakses node dengan indeks -1
.
Kami akan menghitung posisi tujuan saat ini dengan cara yang sama:
1 |
currentDest = new Vector2(mPath[mCurrentNodeId].x * Map.cTileSize + mMap.transform.position.x, |
2 |
mPath[mCurrentNodeId].y * Map.cTileSize + mMap.transform.position.y); |
Dan sekarang untuk posisi tujuan selanjutnya. Di sini kita perlu memeriksa apakah ada simpul yang tersisa untuk diikuti setelah kita mencapai tujuan kita saat ini, jadi pertama mari kita berasumsi bahwa tujuan berikutnya sama dengan yang sekarang:
1 |
nextDest = currentDest; |
Sekarang, jika ada simpul yang tersisa, kami akan menghitung tujuan berikutnya dengan cara yang sama seperti yang kami lakukan sebelumnya:
1 |
if (mPath.Count > mCurrentNodeId + 1) |
2 |
{
|
3 |
nextDest = new Vector2(mPath[mCurrentNodeId + 1].x * Map.cTileSize + mMap.transform.position.x, |
4 |
mPath[mCurrentNodeId + 1].y * Map.cTileSize + mMap.transform.position.y); |
5 |
}
|
Memeriksa apakah the Node adalah di tanah
Langkah selanjutnya adalah menentukan apakah tujuan saat ini ada di lapangan.
Ingat bahwa tidak cukup hanya memeriksa ubin tepat di bawah tujuan; kita perlu mempertimbangkan kasus-kasus di mana karakter lebih dari satu blok lebar:



Mari kita mulai dengan mengasumsikan bahwa posisi tujuan tidak ada di tanah:
1 |
destOnGround = false; |
Sekarang kita akan melihat ubin di bawah tujuan untuk melihat apakah ada balok padat di sana. Jika ada, kita dapat mengatur destOnGround
ke true
:
1 |
for (int x = mPath[mCurrentNodeId].x; x < mPath[mCurrentNodeId].x + mWidth; ++x) |
2 |
{
|
3 |
if (mMap.IsGround(x, mPath[mCurrentNodeId].y - 1)) |
4 |
{
|
5 |
destOnGround = true; |
6 |
break; |
7 |
}
|
8 |
}
|
Memeriksa Apakah Node Telah Mencapai X-Axis
Sebelum kita dapat melihat apakah karakter telah mencapai tujuan, kita perlu mengetahui posisinya di jalan. Posisi ini pada dasarnya adalah pusat sel kiri bawah karakter kita. Karena karakter kita sebenarnya tidak dibangun dari sel, kita hanya akan menggunakan posisi kiri bawah kotak pembatas karakter ditambah setengah sel:
1 |
Vector2 pathPosition = mAABB.Center - mAABB.HalfSize + Vector2.one * Map.cTileSize * 0.5f; |
Ini adalah posisi yang kita butuhkan untuk mencocokkan dengan node tujuan.
Bagaimana kita bisa menentukan apakah karakter telah mencapai tujuan pada sumbu x? Akan aman untuk mengasumsikan bahwa, jika karakter bergerak ke kanan dan memiliki posisi x lebih besar dari atau sama dengan tujuan, maka tujuan telah tercapai.
Untuk melihat apakah karakternya bergerak dengan benar, kami akan menggunakan tujuan sebelumnya, yang dalam kasus ini pasti berada di sebelah kiri yang sekarang:
1 |
reachedX = (prevDest.x <= currentDest.x && pathPosition.x >= currentDest.x); |
Hal yang sama berlaku untuk sisi yang berlawanan; jika tujuan sebelumnya adalah di sebelah kanan saat ini dan posisi x karakter kurang dari atau sama dengan posisi tujuan, maka kita dapat yakin bahwa karakter telah mencapai tujuan pada sumbu x:
1 |
reachedX = (prevDest.x <= currentDest.x && pathPosition.x >= currentDest.x) |
2 |
|| (prevDest.x >= currentDest.x && pathPosition.x <= currentDest.x); |
Snap posisi karakter
Kadang-kadang, karena kecepatan karakter, ia melampaui tujuan, yang dapat menyebabkannya tidak mendarat di node target. Lihat contoh berikut:



Untuk memperbaikinya, kami akan menjepret posisi karakter sehingga mendarat di node tujuan.
Kondisi untuk kita untuk mengambil karakter adalah:
- Tujuan tercapai pada sumbu-x.
- Jarak antara posisi bot dan tujuan saat ini lebih besar dari
cBotMaxPositionError
. - Jarak antara posisi bot dan tujuan saat ini tidak terlalu jauh, jadi kami tidak mengambil karakter dari jauh.
- Karakter tidak bergerak ke kiri atau kanan belokan terakhir, jadi kami mengambil karakter hanya jika jatuh lurus ke bawah.
1 |
if (reachedX && Mathf.Abs(pathPosition.x - currentDest.x) > Constants.cBotMaxPositionError && Mathf.Abs(pathPosition.x - currentDest.x) < Constants.cBotMaxPositionError*3.0f && !mPrevInputs[(int)KeyInput.GoRight] && !mPrevInputs[(int)KeyInput.GoLeft]) |
2 |
{
|
3 |
pathPosition.x = currentDest.x; |
4 |
mPosition.x = pathPosition.x - Map.cTileSize * 0.5f + mAABB.HalfSizeX + mAABBOffset.x; |
5 |
}
|
cBotMaxPositionError
dalam tutorial ini sama dengan 1 piksel; ini adalah seberapa jauh kita membiarkan karakter itu dari tujuan sementara tetap membiarkannya pergi ke tujuan berikutnya.
Memeriksa Apakah Node Telah Mencapai Y-Axis
Mari kita cari tahu kapan kita dapat yakin bahwa karakter telah mencapai posisi Y targetnya. Pertama-tama, jika tujuan sebelumnya berada di bawah yang sekarang, dan karakter kita melompat ke puncak dari tujuan saat ini, maka kita dapat berasumsi bahwa tujuan telah tercapai.
1 |
reachedY = (prevDest.y <= currentDest.y && pathPosition.y >= currentDest.y); |
Demikian pula, jika tujuan saat ini di bawah yang sebelumnya dan karakter telah mencapai y-posisi node saat ini, kita dapat mengatur reachedY
menjadi true
juga.
1 |
reachedY = (prevDest.y <= currentDest.y && pathPosition.y >= currentDest.) |
2 |
|| (prevDest.y >= currentDest.y && pathPosition.y <= currentDest.y); |
Terlepas dari apakah karakter perlu melompat atau jatuh untuk mencapai posisi y-tujuan node, jika itu benar-benar dekat, maka kita harus mengatur reachedY
ke true
juga:
1 |
reachedY = (prevDest.y <= currentDest.y && pathPosition.y >= currentDest.y) |
2 |
|| (prevDest.y >= currentDest.y && pathPosition.y <= currentDest.y) |
3 |
|| (Mathf.Abs(pathPosition.y - currentDest.y) <= Constants.cBotMaxPositionError); |
Jika tujuan ada di tanah tetapi karakter tidak, maka kita dapat mengasumsikan bahwa posisi Y tujuan saat ini belum tercapai:
1 |
if (destOnGround && !mOnGround) |
2 |
reachedY = false; |
Itu saja — itulah semua data dasar yang perlu kita ketahui untuk mempertimbangkan jenis gerakan apa yang perlu dilakukan AI.
Penanganan yang Gerakan Bot
Hal pertama yang harus dilakukan dalam fungsi update
kami adalah mendapatkan konteks yang baru saja kami terapkan:
1 |
Vector2 prevDest, currentDest, nextDest; |
2 |
bool destOnGround, reachedY, reachedX; |
3 |
GetContext(out prevDest, out currentDest, out nextDest, out destOnGround, out reachedX, out reachedY); |
Sekarang mari kita dapatkan posisi karakter di sepanjang jalan. Kami menghitung ini dengan cara yang sama seperti yang kami lakukan dalam fungsi GetContext
:
1 |
Vector2 pathPosition = mAABB.Center - mAABB.HalfSize + Vector2.one * Map.cTileSize * 0.5f; |
Pada awal frame kita perlu mereset input palsu, dan menugaskan mereka hanya jika kondisi untuk melakukannya muncul. Kami hanya akan menggunakan empat input: dua untuk gerakan kiri dan kanan, satu untuk melompat, dan satu untuk menjatuhkan platform satu arah.
1 |
mInputs[(int)KeyInput.GoRight] = false; |
2 |
mInputs[(int)KeyInput.GoLeft] = false; |
3 |
mInputs[(int)KeyInput.Jump] = false; |
4 |
mInputs[(int)KeyInput.GoDown] = false; |
Kondisi pertama untuk gerakan adalah ini: jika tujuan saat ini lebih rendah dari posisi karakter dan karakter berdiri di platform satu arah, lalu tekan tombol ke bawah, yang akan menghasilkan karakter melompat dari platform ke bawah :
1 |
if (pathPosition.y - currentDest.y > Constants.cBotMaxPositionError && mOnOneWayPlatform) |
2 |
mInputs[(int)KeyInput.GoDown] = true; |
Penanganan Lompatan
Mari kita paparkan bagaimana lompatan kita seharusnya bekerja. Pertama, kami tidak ingin menekan tombol lompat jika mFramesOfJumping
adalah 0
.
1 |
if (mFramesOfJumping > 0) |
2 |
{
|
3 |
}
|
Kondisi kedua yang harus diperiksa adalah karakternya tidak ada di tanah.
Dalam implementasi fisika platformer ini, karakter dibiarkan melompat jika hanya melangkah dari tepi platform dan tidak lagi di tanah. Ini adalah metode populer untuk mengurangi ilusi bahwa pemain telah menekan tombol melompat tetapi karakter tidak melompat, yang mungkin muncul karena masukan lag atau pemain menekan tombol melompat tepat setelah karakter telah pindah dari platform.
1 |
if (mFramesOfJumping > 0 && !mOnGround) |
2 |
{
|
3 |
}
|
Kondisi ini akan berfungsi jika karakter perlu melompat dari langkan, karena bingkai lompatan akan diatur ke jumlah yang tepat, karakter secara alami akan berjalan dari langkan, dan pada titik itu juga akan memulai lompatan.
Ini tidak akan berhasil jika lompatan harus dilakukan dari tanah; untuk menangani ini kita perlu memeriksa kondisi ini:
- Karakter telah mencapai node tujuan posisi x, mana itu akan mulai melompat.
- Node tujuan tidak berada di tanah; Jika kita melompat, kita perlu pergi melalui simpul yang di udara pertama.
1 |
if (mFramesOfJumping > 0 && |
2 |
(!mOnGround || (reachedX && !destOnGround))) |
3 |
{
|
4 |
}
|
Karakter juga harus melompat jika berada di tanah dan tujuan ada di tanah juga. Ini biasanya akan terjadi jika karakter perlu melompat satu ubin ke atas dan ke samping untuk mencapai platform yang hanya satu blok lebih tinggi.
1 |
if (mFramesOfJumping > 0 && |
2 |
(!mOnGround || (reachedX && !destOnGround) || (mOnGround && destOnGround))) |
3 |
{
|
4 |
}
|
Sekarang mari kita mengaktifkan lompatan dan pengurangan bingkai lompatan, sehingga karakter memegang lompatan untuk jumlah bingkai yang benar:
1 |
if (mFramesOfJumping > 0 && |
2 |
(!mOnGround || (reachedX && !destOnGround) || (mOnGround && destOnGround))) |
3 |
{
|
4 |
mInputs[(int)KeyInput.Jump] = true; |
5 |
if (!mOnGround) |
6 |
--mFramesOfJumping; |
7 |
}
|
Perhatikan bahwa kami mengurangi mFramesOfJumping
hanya jika karakter tidak ada di tanah. Ini untuk menghindari secara tidak sengaja mengurangi panjang lompatan sebelum memulai lompatan.
Melanjutkan ke Node tujuan berikutnya
Mari kita pikirkan apa yang perlu terjadi ketika kita mencapai simpul — yaitu, ketika reachedX
dan reachedY
bernilai true
.
1 |
if (reachedX && reachedY) |
2 |
{
|
3 |
}
|
Pertama, kami akan menaikkan ID node saat ini:
1 |
mCurrentNodeId++; |
Sekarang kita perlu memeriksa apakah ID ini lebih besar dari jumlah node di jalur kita. Jika ya, itu berarti karakter telah mencapai tujuan:
1 |
if (mCurrentNodeId >= mPath.Count) |
2 |
{
|
3 |
mCurrentNodeId = -1; |
4 |
ChangeState(BotState.None); |
5 |
break; |
6 |
}
|
Hal berikutnya yang harus kita lakukan adalah menghitung lompatan untuk simpul berikutnya. Karena kita perlu menggunakan ini di lebih dari satu tempat, mari kita buat fungsi untuk itu:
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
}
|
Kami hanya ingin melompat jika node baru lebih tinggi dari yang sebelumnya dan karakter ada di tanah:
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
if (mPath[currentNodeId].y - mPath[prevNodeId].y > 0 && mOnGround) |
4 |
{
|
5 |
}
|
6 |
}
|
Untuk mengetahui berapa banyak ubin yang harus kita lompati, kita akan mengulanginya melalui simpul selama lebih tinggi dan lebih tinggi. Ketika kita sampai ke node yang berada pada ketinggian lebih rendah, atau node yang memiliki tanah di bawahnya, kita dapat berhenti, karena kita tahu bahwa tidak akan ada yang lebih tinggi dari itu.
Pertama, mari kita menyatakan dan menetapkan variabel yang akan menampung nilai melompat:
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
if (mPath[currentNodeId].y - mPath[prevNodeId].y > 0 && mOnGround) |
4 |
{
|
5 |
int jumpHeight = 1; |
6 |
}
|
7 |
}
|
Sekarang mari iterate melalui node, dimulai pada node saat ini:
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
if (mPath[currentNodeId].y - mPath[prevNodeId].y > 0 && mOnGround) |
4 |
{
|
5 |
int jumpHeight = 1; |
6 |
|
7 |
for (int i = currentNodeId; i < mPath.Count; ++i) |
8 |
{
|
9 |
}
|
10 |
}
|
11 |
}
|
Jika node berikutnya lebih tinggi dari jumpHeight
, dan itu tidak di tanah, maka mari kita atur ketinggian lompatan baru:
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
if (mPath[currentNodeId].y - mPath[prevNodeId].y > 0 && mOnGround) |
4 |
{
|
5 |
int jumpHeight = 1; |
6 |
|
7 |
for (int i = currentNodeId; i < mPath.Count; ++i) |
8 |
{
|
9 |
if (mPath[i].y - mPath[prevNodeId].y >= jumpHeight && !mMap.IsGround(mPath[i].x, mPath[i].y - 1)) |
10 |
jumpHeight = mPath[i].y - mPath[prevNodeId].y; |
11 |
}
|
12 |
}
|
13 |
}
|
Jika ketinggian node baru lebih rendah dari sebelumnya, atau di tanah, maka kita mengembalikan jumlah frame lompatan yang diperlukan untuk ketinggian yang ditemukan. (Dan jika tidak perlu melompat, mari kita kembali 0
.)
1 |
public int GetJumpFramesForNode(int prevNodeId) |
2 |
{
|
3 |
int currentNodeId = prevNodeId + 1; |
4 |
|
5 |
if (mPath[currentNodeId].y - mPath[prevNodeId].y > 0 && mOnGround) |
6 |
{
|
7 |
int jumpHeight = 1; |
8 |
for (int i = currentNodeId; i < mPath.Count; ++i) |
9 |
{
|
10 |
if (mPath[i].y - mPath[prevNodeId].y >= jumpHeight) |
11 |
jumpHeight = mPath[i].y - mPath[prevNodeId].y; |
12 |
if (mPath[i].y - mPath[prevNodeId].y < jumpHeight || !mMap.IsGround(mPath[i].x, mPath[i].y - 1)) |
13 |
return GetJumpFrameCount(jumpHeight); |
14 |
}
|
15 |
}
|
16 |
|
17 |
return 0; |
18 |
}
|
Kita perlu memanggil fungsi ini di dua tempat.
Yang pertama adalah dalam kasus di mana karakter telah mencapai simpul x dan posisi-y:
1 |
if (reachedX && reachedY) |
2 |
{
|
3 |
int prevNodeId = mCurrentNodeId; |
4 |
mCurrentNodeId++; |
5 |
|
6 |
if (mCurrentNodeId >= mPath.Count) |
7 |
{
|
8 |
mCurrentNodeId = -1; |
9 |
ChangeState(BotState.None); |
10 |
break; |
11 |
}
|
12 |
|
13 |
if (mOnGround) |
14 |
mFramesOfJumping = GetJumpFramesForNode(prevNodeId); |
15 |
}
|
Perhatikan bahwa kita mengatur frame lompatan untuk seluruh lompatan, jadi ketika kita mencapai simpul di udara kita tidak ingin mengubah jumlah lompatan frame yang ditentukan sebelum lompatan terjadi.
Setelah kami memperbarui tujuan, kami perlu memproses semuanya lagi, sehingga bingkai pergerakan selanjutnya dihitung segera. Untuk ini, kita akan menggunakan perintah goto
:
1 |
goto case BotState.MoveTo; |
Tempat kedua yang kita perlukan untuk menghitung lompatan adalah fungsi MoveTo
, karena mungkin saja simpul pertama dari lintasan adalah simpul lompatan:
1 |
if (path != null && path.Count > 1) |
2 |
{
|
3 |
for (var i = path.Count - 1; i >= 0; --i) |
4 |
mPath.Add(path[i]); |
5 |
|
6 |
mCurrentNodeId = 1; |
7 |
|
8 |
ChangeState(BotState.MoveTo); |
9 |
|
10 |
mFramesOfJumping = GetJumpFramesForNode(0); |
11 |
}
|
Penanganan Gerakan untuk Mencapai Posisi-Node
Sekarang mari kita menangani gerakan untuk kasus di mana karakter belum mencapai posisi x-node target.
Tidak ada yang rumit di sini; jika tujuannya ke kanan, kita perlu mensimulasikan penekanan tombol kanan. Jika tujuannya ke kiri, maka kita perlu mensimulasikan tombol kiri tekan. Kita hanya perlu memindahkan karakter jika perbedaan posisi lebih dari konstanta cBotMaxPositionError
:
1 |
else if (!reachedX) |
2 |
{
|
3 |
if (currentDest.x - pathPosition.x > Constants.cBotMaxPositionError) |
4 |
mInputs[(int)KeyInput.GoRight] = true; |
5 |
else if (pathPosition.x - currentDest.x > Constants.cBotMaxPositionError) |
6 |
mInputs[(int)KeyInput.GoLeft] = true; |
7 |
}
|
Penanganan Gerakan untuk Mencapai Posisi-Node
Jika karakter telah mencapai target x-posisi tetapi kita tetap melompat ke yang lebih tinggi, kita masih dapat memindahkan karakter ke kiri atau ke kanan tergantung di mana tujuan berikutnya adalah. Ini hanya akan berarti bahwa karakter tidak menempel begitu kaku ke jalan yang ditemukan. Berkat itu, akan jauh lebih mudah untuk mencapai tujuan berikutnya, karena alih-alih hanya menunggu untuk mencapai target y-position, karakter akan secara alami bergerak menuju posisi x node berikutnya ketika melakukan hal itu.
Kami hanya akan memindahkan karakter ke tujuan berikutnya jika ada sama sekali dan tidak ada di tanah. (Jika ada di tanah, maka kita tidak bisa melewatkannya karena ini adalah pos pemeriksaan yang penting — itu mengatur ulang kecepatan vertikal karakter dan memungkinkannya untuk menggunakan lompatan lagi.)
1 |
else if (!reachedY && mPath.Count > mCurrentNodeId + 1 && !destOnGround) |
2 |
{
|
3 |
|
4 |
}
|
Tetapi sebelum kita benar-benar bergerak menuju tujuan berikutnya, kita perlu memeriksa bahwa kita tidak akan merusak jalur dengan melakukannya.
Menghindari Pemutusan Jatuh Prematur
Pertimbangkan skenario berikut:



Di sini, segera setelah karakter berjalan dari langkan di mana ia mulai, ia mencapai posisi x dari simpul kedua, dan jatuh untuk mencapai posisi-y. Karena simpul ketiga berada di sebelah kanan karakter, itu bergerak ke kanan — dan berakhir di terowongan di atas yang kita inginkan.
Untuk memperbaikinya, kita perlu memeriksa apakah ada rintangan antara karakter dan tujuan berikutnya; jika tidak ada, maka kita bebas untuk memindahkan karakter ke arahnya; jika ada, maka kita harus menunggu.
Pertama, mari kita lihat ubin mana yang harus kita periksa. Jika sasaran berikutnya adalah di sebelah kanan yang sekarang, maka kita harus memeriksa ubin di sebelah kanan; jika ke kiri maka kita harus memeriksa ubin ke kiri. Jika mereka berada pada posisi x yang sama, tidak ada alasan untuk melakukan gerakan pre-emptive.
1 |
int checkedX = 0; |
2 |
|
3 |
int tileX, tileY; |
4 |
mMap.GetMapTileAtPoint(pathPosition, out tileX, out tileY); |
5 |
|
6 |
if (mPath[mCurrentNodeId + 1].x != mPath[mCurrentNodeId].x) |
7 |
{
|
8 |
if (mPath[mCurrentNodeId + 1].x > mPath[mCurrentNodeId].x) |
9 |
checkedX = tileX + mWidth; |
10 |
else
|
11 |
checkedX = tileX - 1; |
12 |
}
|
Seperti yang Anda lihat, koordinat-x dari node ke kanan tergantung pada lebar karakter.
Sekarang kita dapat memeriksa apakah ada ubin antara karakter dan posisi simpul berikutnya pada sumbu y:
1 |
if (checkedX != 0 && !mMap.AnySolidBlockInStripe(checkedX, tileY, mPath[mCurrentNodeId + 1].y)) |
2 |
{
|
3 |
}
|
Fungsi AnySolidBlockInStripe
memeriksa apakah ada ubin padat antara dua titik yang diberikan pada peta. Titik-titik harus memiliki x-koordinat yang sama. Koordinat x yang kami periksa adalah ubin yang kami inginkan agar karakter itu dipindahkan, tetapi kami tidak yakin apakah kami bisa, seperti yang dijelaskan di atas.
Inilah implementasi dari fungsi tersebut.
1 |
public bool AnySolidBlockInStripe(int x, int y0, int y1) |
2 |
{
|
3 |
int startY, endY; |
4 |
|
5 |
if (y0 <= y1) |
6 |
{
|
7 |
startY = y0; |
8 |
endY = y1; |
9 |
}
|
10 |
else
|
11 |
{
|
12 |
startY = y1; |
13 |
endY = y0; |
14 |
}
|
15 |
|
16 |
for (int y = startY; y <= endY; ++y) |
17 |
{
|
18 |
if (GetTile(x, y) == TileType.Block) |
19 |
return true; |
20 |
}
|
21 |
|
22 |
return false; |
23 |
}
|
Seperti yang Anda lihat, fungsinya sangat sederhana; hanya iterates melalui ubin di kolom, mulai dari yang lebih rendah.
Sekarang kita tahu kita bisa bergerak menuju tujuan berikutnya, mari kita lakukan:
1 |
if (checkedX != 0 && !mMap.AnySolidBlockInStripe(checkedX, tileY, mPath[mCurrentNodeId + 1].y)) |
2 |
{
|
3 |
if (nextDest.x - pathPosition.x > Constants.cBotMaxPositionError) |
4 |
mInputs[(int)KeyInput.GoRight] = true; |
5 |
else if (pathPosition.x - nextDest.x > Constants.cBotMaxPositionError) |
6 |
mInputs[(int)KeyInput.GoLeft] = true; |
7 |
}
|
Memungkinkan Bot untuk melewati node
Itu hampir - tetapi masih ada satu kasus yang harus dipecahkan. Inilah contohnya:



Seperti yang Anda lihat, sebelum karakter mencapai posisi y simpul kedua, itu menabrak kepalanya di ubin mengambang, karena kami membuatnya bergerak menuju tujuan berikutnya ke kanan. Akibatnya, karakter berakhir tidak pernah mencapai posisi y simpul kedua; alih-alih bergerak lurus ke simpul ketiga. Karena reachedY
adalah false
dalam hal ini, ia tidak dapat melanjutkan dengan path.
Untuk menghindari kasus seperti itu, kami hanya akan memeriksa apakah karakter mencapai tujuan berikutnya sebelum mencapai yang saat ini.
Langkah pertama menuju ini akan memisahkan perhitungan sebelumnya dari reachedX
dan reachedY
menjadi fungsi mereka sendiri:
1 |
public bool ReachedNodeOnXAxis(Vector2 pathPosition, Vector2 prevDest, Vector2 currentDest) |
2 |
{
|
3 |
return (prevDest.x <= currentDest.x && pathPosition.x >= currentDest.x) |
4 |
|| (prevDest.x >= currentDest.x && pathPosition.x <= currentDest.x) |
5 |
|| Mathf.Abs(pathPosition.x - currentDest.x) <= Constants.cBotMaxPositionError; |
6 |
}
|
7 |
|
8 |
public bool ReachedNodeOnYAxis(Vector2 pathPosition, Vector2 prevDest, Vector2 currentDest) |
9 |
{
|
10 |
return (prevDest.y <= currentDest.y && pathPosition.y >= currentDest.y) |
11 |
|| (prevDest.y >= currentDest.y && pathPosition.y <= currentDest.y) |
12 |
|| (Mathf.Abs(pathPosition.y - currentDest.y) <= Constants.cBotMaxPositionError); |
13 |
}
|
Selanjutnya, ganti perhitungan dengan pemanggilan fungsi dalam fungsi GetContext
:
1 |
reachedX = ReachedNodeOnXAxis(pathPosition, prevDest, currentDest); |
2 |
reachedY = ReachedNodeOnYAxis(pathPosition, prevDest, currentDest); |
Sekarang kita dapat memeriksa apakah tujuan berikutnya telah tercapai. Jika sudah, kita bisa hanya menambah mCurrentNode
dan segera kembali melakukan pembaruan status. Ini akan membuat tujuan berikutnya menjadi yang sekarang, dan karena karakter sudah mencapai itu, kita akan dapat melanjutkan:
1 |
if (checkedX != 0 && !mMap.AnySolidBlockInStripe(checkedX, tileY, mPath[mCurrentNodeId + 1].y)) |
2 |
{
|
3 |
if (nextDest.x - pathPosition.x > Constants.cBotMaxPositionError) |
4 |
mInputs[(int)KeyInput.GoRight] = true; |
5 |
else if (pathPosition.x - nextDest.x > Constants.cBotMaxPositionError) |
6 |
mInputs[(int)KeyInput.GoLeft] = true; |
7 |
|
8 |
if (ReachedNodeOnXAxis(pathPosition, currentDest, nextDest) && ReachedNodeOnYAxis(pathPosition, currentDest, nextDest)) |
9 |
{
|
10 |
mCurrentNodeId += 1; |
11 |
goto case BotState.MoveTo; |
12 |
}
|
13 |
}
|
Itu semua untuk pergerakan karakter!
Menangani Kondisi Restart
Ada baiknya untuk memiliki rencana cadangan untuk situasi di mana bot tidak bergerak melalui jalur seperti seharusnya. Ini dapat terjadi jika, misalnya, peta diubah — menambahkan penghalang ke jalur yang sudah dihitung dapat menyebabkan jalur menjadi tidak valid. Apa yang akan kita lakukan adalah me-reset path jika karakter terjebak lebih lama dari sejumlah frame tertentu.
Jadi, mari kita mendeklarasikan variabel yang akan menghitung berapa banyak frame karakter yang telah terjebak dan berapa banyak frame yang mungkin paling banyak terhenti:
1 |
public int mStuckFrames = 0; |
2 |
public const int cMaxStuckFrames = 20; |
Kita perlu mengatur ulang ini ketika kita memanggil fungsi MoveTo
:
1 |
public void MoveTo(Vector2i destination) |
2 |
{
|
3 |
mStuckFrames = 0; |
4 |
/*
|
5 |
...
|
6 |
*/
|
7 |
}
|
Dan akhirnya, di akhir BotState.MoveTo
, mari kita periksa apakah karakternya macet. Di sini, kita hanya perlu memeriksa apakah posisinya saat ini sama dengan yang lama; jika demikian, maka kita juga perlu menaikkan mStuckFrames
dan memeriksa apakah karakter telah terjebak untuk lebih banyak frame daripada cMaxStuckFrames
— dan jika itu memang benar, maka kita perlu memanggil fungsi MoveTo
dengan simpul terakhir dari jalur saat ini sebagai parameter. Tentu saja, jika posisinya berbeda, maka kita perlu mengatur ulang mStuckFrames
ke 0:
1 |
if (mFramesOfJumping > 0 && |
2 |
(!mOnGround || (reachedX && !destOnGround) || (mOnGround && destOnGround))) |
3 |
{
|
4 |
mInputs[(int)KeyInput.Jump] = true; |
5 |
if (!mOnGround) |
6 |
--mFramesOfJumping; |
7 |
}
|
8 |
|
9 |
if (mPosition == mOldPosition) |
10 |
{
|
11 |
++mStuckFrames; |
12 |
if (mStuckFrames > cMaxStuckFrames) |
13 |
MoveTo(mPath[mPath.Count - 1]); |
14 |
}
|
15 |
else
|
16 |
mStuckFrames = 0; |
Sekarang karakter harus mencari jalan alternatif jika tidak bisa menyelesaikan yang awal.
Kesimpulan
Itu seluruh tutorialnya! Sudah banyak pekerjaan, tapi saya harap Anda akan menemukan metode ini berguna. Ini tidak berarti solusi sempurna untuk pathfinding platformer; pendekatan kurva lompatan untuk karakter yang perlu dibuat oleh algoritme sering cukup sulit dilakukan dan dapat menyebabkan perilaku yang salah. Algoritme ini masih bisa diperpanjang — tidak terlalu sulit untuk menambahkan langkan dan jenis lain fleksibilitas gerak yang diperluas — tetapi kami telah membahas mekanika platformer dasar. Juga dimungkinkan untuk mengoptimalkan kode untuk membuatnya lebih cepat serta menggunakan lebih sedikit memori; iterasi algoritma ini tidak sempurna sama sekali ketika menyangkut aspek-aspek tersebut. Ia juga menderita dari perkiraan kurva yang sangat buruk ketika jatuh pada kecepatan besar.
Algoritme ini dapat digunakan dalam banyak cara, terutama untuk meningkatkan musuh AI atau teman AI. Ini juga dapat digunakan sebagai skema kontrol untuk perangkat sentuh — ini akan bekerja pada dasarnya dengan cara yang sama seperti pada demo tutorial, dengan pemutar mengetuk ke mana pun mereka ingin karakter tersebut bergerak. Hal ini menghilangkan tantangan eksekusi yang digunakan oleh banyak platformer, sehingga permainan harus dirancang secara berbeda, untuk lebih memosisikan karakter Anda di tempat yang tepat daripada belajar mengontrol karakter secara akurat.
Terima kasih sudah membaca! Pastikan untuk memberikan umpan balik tentang metode ini dan beri tahu saya jika Anda sudah melakukan perbaikan!