空洞騎士重現 - Nail Attack

這次的 topic 是骨丁攻擊,是空洞騎士當中做得非常好的一點,他的攻擊手感極佳,不僅是削減敵人血量的方法,更是與整個環境互動的一環,諸如擊碎場景物件、孢子跳跳樂、收集吉歐等等,小小的揮擊卻帶來大大的回饋,本篇就來實踐這個機制。
Sprites
首先介紹本次使用的 sprites 來源:
- Nail Attack - 使用
004, 006~010共包含了兩種平砍與上下劈,與之前的 dash / double jump 一樣,斬擊特效與小騎士的動作是分開的,不過斬擊特效我只找到平砍的,所以上下劈也沿用平砍的特效。
- 場景物件 - 這次使用的物件有點零散,主要被打擊的石柱來自 Dirtmouth,不過有趣的是他被打碎的 spirte 卻是來自 Resting Grounds,至於孢子跳跳樂則是從 Fungal Wastes 取得。

- 場景特效 - 諸如擊碎噴出的石頭以及打到牆壁的火花都是從中擷取的。

上述 sprites 可能會有紅框,可以透過此程式來移除。
Nail Attack
前製作業跟之前提到的 Dash & Climb 一樣,就是新增 Button, Context, State, Animation 等等,不過由於攻擊本身與水平/垂直移動是獨立,所以在此額外新增一個狀態機用於 attack,底下補充幾點骨丁攻擊的特色:
- 兩種水平揮砍 - 水平有兩種揮砍的動作 - slash / slashAlt,slashAlt 只會在快速連砍的時候與 slash 交互出現,除此之外都是 slash 動作,要達到這個效果的狀態機如底下所示,attack 在連擊時間結束後跳回去 none,反之在時間內又按下攻擊鍵則切到 attack alt,對於 attack alt 也是如此。因此除了本來的 attack cooldown 參數,還要新增一個 attack duration 參數來判定連擊。

- 紀錄攻擊方向 - 除了本來的水平揮擊還會有上下劈兩種狀態,每一個狀態都需要紀錄一個 attack direction,也就是上下左右,這是為了讓其他物件產生對應的效果,例如擊中時敵人要往同方向飛出去。
Slash Effect
與之前的 double jump 以及 dash 不同,這邊的 slash effect 其實才是攻擊本體,小騎士本身的動作反而只是動畫,為了能夠偵測到 slash effect 是否與物件產生碰撞,得替它加上 box collider 2D,這邊同樣要透過 animation record 的方式替每一張 slash sprite 調整 collider 的大小與範圍,另外記得 collider 要勾選 Is Trigger。

接者掛載腳本來處理 trigger 事件,首先檢查碰撞的物件是否具備一個
damageable 的 function,這個 function 是由我們替可被攻擊的物件實做出來,並會根據攻擊傷害與攻擊方向而產生不一樣的效果,這樣的實作方式能讓不同物件對攻擊產生不同反應,而不需要直接讀取 slash 本身的設定,同時 slash 也能直接偵測到攻擊的回饋 - valid,是相當好的寫法 ~~~// slash effect script
void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out IDamageable damageable))
{
bool valid = damageable.TakeDamage(SlashDamage, Player.Context.SlashDirection);
...
}
}另外一個非必需的設定是可以調整 project setting 中的 physics 2D collision matrix,這部分是 global 的控制 layer 能跟哪些 layer 產生碰撞,可以減少不必要的物理計算。我是讓 slash 跟 player 同一個 layer,然後只讓他能與 solid (地面牆壁) 跟 damageable 產生碰撞。

Damageable
Component
空洞騎士中一個可被破壞的物體是由三個物件構成: 完整外觀、破碎外觀、碎片 所構成。如下圖所示,破碎外觀準備兩種來隨機挑選,在一開始先把這三種 sprites 都放到同一個父物件底下,然後把破碎外觀跟碎片與完整外觀重疊,但只把完整外觀 enable,其餘都先 disable,並且只有父物件才要套 box colilder 2D,然後父物件的 layer 設定成 damageable,在 collision matrix 才能跟 slash 碰撞。

Script
替剛剛的父物件掛載 script,除了 MonoBehaviour 還要額外讓他實作 interface IDamageable 中的 TakeDamage,也就是 slash effect 中會偵測的 function,至於外部參數的用途依序是:
- maxHealth - 代表此物件的生命值,歸零才會破碎。
- invincibleDuration - 無敵狀態時間,當受到攻擊後一段時間內攻擊會無效,這邊只是用來避免單次攻擊造成數次傷害,因為每一個 slash 是好幾幀組成的,有機率會觸發多次 damage。
- originalObject、brokenObjects、fragmentSamples - 就是前面提到的那些 sprites,不過可以注意 fragmentSamples 是 rigidbody 2D,這點稍後解釋。
- brokenLayer - 這是替被打碎後的物體切換 layer,來減少物理計算。
// stone pillar script
public interface IDamageable
{
bool TakeDamage(int amount, Vector2 direction);
}
public class stone_pillar : MonoBehaviour, IDamageable
{
[SerializeField] int maxHealth;
[SerializeField] float invincibleDuration;
[SerializeField] GameObject originalObject;
[SerializeField] GameObject[] brokenObjects;
[SerializeField] Rigidbody2D[] fragmentSamples;
[SerializeField] LayerMask brokenLayer;
int health;
float invincibleTimer;
public bool IsBroken => health <= 0;
void Awake()
{
health = maxHealth;
}
void Update()
{
if (invincibleTimer > 0f) invincibleTimer -= Time.deltaTime;
}
...
}
整體外部設定如下圖所示:

實際的
TakeDamage 如下所示,先確認物體是否完整以及無敵時間結束了才觸發傷害,並重新設定無敵時間,如果生命歸零就把 sprite 顯示替換成 broken 的其中一張,並把 layer 切成故障狀態,至於那個 SpawnFragments 則是為了創造出噴濺碎片的效果,其中如果物體被打碎了才讓碎片往前飛,不然都是往反向飛行,這樣比較真實 ~~~// stone pillar script
...
public bool TakeDamage(int amount, Vector2 attackDirection)
{
if (IsBroken) return false;
if (invincibleTimer <= 0)
{
health = Mathf.Max(0, health - amount);
if (IsBroken)
{
SpawnFragments(fragmentsOnBreak, attackDirection);
Break();
}
else
{
SpawnFragments(fragmentsPerHit, -attackDirection);
}
invincibleTimer = invincibleDuration;
return true;
}
return true;
}
void Break()
{
originalObject.SetActive(false);
gameObject.layer = brokenLayer;
brokenObjects[Random.Range(0, brokenObjects.Length)].SetActive(true);
}
...SpawnFragments 簡單來說只是隨機創造特定數量的碎片讓他具有不同的位置與旋轉,其中最重要的一點是這些碎片其實都具備 rigidbody 2D !!! 而且還是 dynamic 的 !!! 這是因為碎片飛散要做的逼真還是得有物理引擎介入,這樣我們只需要控制力的方向/大小即可 (Launch),但為了避免過多運算資源介入,記得要設定生命週期 (destroy)。// stone pillar script
...
void SpawnFragments(int count, Vector2 offset)
{
for (int i = 0; i < count; i++)
{
Rigidbody2D sample = fragmentSamples[Random.Range(0, fragmentSamples.Length)];
Vector2 spawnPos = (Vector2)sample.transform.position + Random.insideUnitCircle * spawnJitter;
Quaternion spawnRot = Quaternion.Euler(0f, 0f, Random.Range(0f, 360f));
Rigidbody2D rb = Instantiate(sample, spawnPos, spawnRot, null);
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gameObject.SetActive(true);
Launch(rb, spawnPos, offset);
Destroy(rb.gameObject, fragmentLifetime);
}
}
void Launch(Rigidbody2D rb, Vector2 spawnPos, Vector2 offset)
{
Vector2 direction = offset.sqrMagnitude > 0.0001f
? offset.normalized
: Random.insideUnitCircle.normalized;
direction = (direction + Random.insideUnitCircle * directionVariance).normalized;
float magnitude = burstForce * Random.Range(1f - forceVariance, 1f + forceVariance);
rb.AddForce(direction * magnitude, ForceMode2D.Impulse);
rb.AddTorque(Random.Range(-torqueRange, torqueRange));
}
...Hit Effect
前面說完了打擊物體的效果,現在說說打擊牆壁/地面的效果,空洞騎士中如果打擊一些堅硬的物體會產生出刀光,而這個的實作方式與
TakeDamage 不太一樣,由於刀光本身只是特效,不會對牆壁那些東西造成甚麼影響,所以可以完全由 slash 控制:// slash effect script
...
void OnTriggerEnter2D(Collider2D other)
{
...
if (other.gameObject.CompareTag("Solid"))
{
Vector2 point = other.ClosestPoint(slashCollider.bounds.center);
SpawnHitEffect(point);
}
}
void SpawnHitEffect(Vector2 position)
{
if (HitEffect == null) return;
GameObject effect = Instantiate(HitEffect, position, HitEffect.transform.rotation);
effect.SetActive(true);
Animator effectAnimator = effect.GetComponent<Animator>();
effectAnimator.Play(HitEffectState, 0, 0);
effectAnimator.Update(0f);
AnimatorStateInfo info = effectAnimator.GetCurrentAnimatorStateInfo(0);
float speed = Mathf.Abs(info.speed * info.speedMultiplier);
float lifetime = speed > 0f ? info.length / speed : info.length;
Destroy(effect, lifetime);
}由於我們使用的是 trigger 因此無法直接取得準確的碰撞位置,但依舊能透過
ClosestPoint 取得相近的座標,這樣其實就足夠了,透過在此座標生成物件並播放動畫就能達成刀光劍影的效果,一樣要記得設定生命週期,這邊是動畫播完就刪除。Slash Down
前面都是 slash 對物體產生的效果或動畫,這邊講講攻擊對小騎士的影響,其中最顯著的就是下劈,向下劈砍擊中時可以產生跳躍的效果,還能夠重製 double jump & dash,是空戰的重要一環:
// slash effect script
...
void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out IDamageable damageable))
{
bool valid = damageable.TakeDamage(SlashDamage, Player.Context.SlashDirection);
if (!valid) return;
if (Player.Context.SlashDirection == Vector2.down)
{
if (other.gameObject.CompareTag("Bouncer"))
{
Player.Context.PogoRequested = true;
};
}
}
...
}實作方式就是在本來的有效命中底下插入一個攻擊方向檢查,如果是下劈的話且擊中的物體是可以彈跳的,就設定一個 context,這個 context 能夠讓小騎士狀態機進入到跳躍狀態,並重製 double jump & dash。
成品
本次成品包含了打擊牆壁時的刀光,以及石柱不同方向的碎石噴濺,最後的石柱則是拉高 health 讓他不會一打就碎,能夠看出打碎前的噴濺方向都是反方向,最後下劈的孢子跳跳樂則是在 TakeDamange 內加上 particle system 的播放。有些地方沒有實踐,像是碎石消失的方式該要是縮小並且淡化消失,以及打擊時應該會有的後座力 (除非有裝穩定護符)。
總結
這次透過實踐骨丁攻擊學到了 2D 遊戲中的基礎物件互動,感覺有很多東西都能實現了呢 !!! 不過攻擊的效果呈現有點單調,雖然 sprite 都是用原始遊戲的,但還是少了適當的後處理,下次就來弄弄 shader graph 吧。