在Unity中復刻《超級馬里奧》

本工程難度:

前言

10月27日發售的超級馬里奧:奧德賽再次驚艷了遊戲圈,多家遊戲媒體對本作打出了滿分,給予了極高的評價,玩家們也感慨有幸在2017年見證了任天堂兩款神作的誕生。

嗯,好像沒什麼不對

相信許多人對奧德賽中3D轉2D的設計感到極為驚艷,看著2D關卡又一次出現在屏幕上,是不是有種回到了小時候在電視機前握著手柄和水管工一起闖關的時光呢?那麼在Unity中是不是也可以復刻當年那款風靡全球的Super Mario呢?帶著這樣的疑問,本人做了一些嘗試,雖然我是接觸Unity的時間不長的新手,還無法在短時間內完美復刻,但也已經能利用Unity自帶的2D系統實現馬里奧的基本操作以及與怪物的基本交互。

令人眼前一亮的2D關卡

實現流程

1.素材準備

首先將準備好的場景圖放入場景中,將其Layer改為Ground,並創建兩個空子節點,加上Box Collider2D組件,分別作為地面和管道的碰撞體,並將馬里奧大叔和怪物的貼圖素材導入Unity中,製作好各種狀態下的幀動畫,並創建對應的動畫狀態機備用。

放入場景圖,並加上2D碰撞盒

2.玩家

接下來先處理玩家控制的馬里奧大叔,導入角色模型,在玩家組件上添加2D物理組件、碰撞盒及動畫控制器,改變角色Tag和Layer,添加空子節點,將其位置設置在角色腳下用來檢測地面及敵人。再創建角色腳本,接下來編寫角色的基本邏輯。

為馬里奧大叔添加組件

玩家類代碼整體如下:

using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerCharacter : MonoBehaviour{ [SerializeField] Rigidbody2D rig2d; //玩家自身的2D物理模塊 [SerializeField] Animator anim; //玩家身上的動畫控制器 [SerializeField] Transform checkPoint; //玩家子物體中的監測點 float curSpeed = 3f; float jumpHeight = 350f; bool isFacingRight = true; bool isGrounded = true; float checkDistance = 0.05f; int hitCount = 0; public bool isDead = false; LayerMask groundLayer; //地面層 LayerMask enemyLayer; //敵人層 Animator playerAnim; AnimatorStateInfo stateInfo; void Start () { Init(); } void Update() { stateInfo = anim.GetCurrentAnimatorStateInfo(0); if (isDead && stateInfo.IsName("Die")) { return; } var h = Input.GetAxis("Horizontal"); //獲取玩家在水平方向上的輸入 if (!isDead) { Move(h); } CheckIsGrounded(); if (h > 0 && !isFacingRight) { Reverse(); } else if (h < 0 && isFacingRight) { Reverse(); } if (Input.GetKeyDown(KeyCode.Space) && isGrounded) { Jump(); } if(!isDead) { CheckHit(); } } //初始化函數 void Init() { rig2d = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); checkPoint = transform.Find("GroundCheckPoint"); playerAnim = GetComponent<Animator>(); groundLayer = 1 << LayerMask.NameToLayer("Ground"); enemyLayer = 1 << LayerMask.NameToLayer("Enemy"); } //將角色的localScale取反來翻轉模型,實現左右轉向的效果 void Reverse() { if (isGrounded) { isFacingRight = !isFacingRight; var scale = transform.localScale; scale.x *= -1; transform.localScale = scale; } } //玩家移動函數,運用Unity2D物理自帶函數實現 void Move(float dic) { rig2d.velocity = new Vector2(dic * curSpeed, rig2d.velocity.y); anim.SetFloat("Speed", Mathf.Abs(dic * curSpeed)); } //跳躍,同樣運用Unity2D物理實現 void Jump() { rig2d.AddForce(new Vector2(0, jumpHeight)); } //射線檢測是否接觸地面,只有當接觸地面的時候才可以跳躍以免出現n連跳的情況 void CheckIsGrounded() { Vector2 check = checkPoint.position; RaycastHit2D hit = Physics2D.Raycast(check, Vector2.down, checkDistance, groundLayer.value); if (hit.collider != null) { anim.SetBool("IsGrounded", true); isGrounded = true; } else { anim.SetBool("IsGrounded", false); isGrounded = false; } } //運用2D相交圓檢測腳下是否有怪物 void CheckHit() { var check = checkPoint.position; var hit = Physics2D.OverlapCircle(check, 0.07f, enemyLayer.value); if (hit != null) { if (hit.CompareTag("Normal")) //若踩中普通怪物,則給予玩家一個反彈力,並觸發怪物的死亡效果 { Debug.Log("Hit Normal!"); rig2d.velocity = new Vector2(rig2d.velocity.x, 5f); hit.GetComponentInParent<EnemyCharacter>().isHit = true; } else if (hit.CompareTag("Special")) //若踩中特殊怪物(烏龜),則在敵人相關代碼中做對應變化 { hitCount += 1; if (hitCount == 1) { rig2d.velocity = new Vector2(rig2d.velocity.x, 5f); hit.GetComponentInParent<EnemyCharacter>().GetHit(1); } } } } public void InitCount() { hitCount = 0; } //若玩家死亡,則進入死亡狀態,出發死亡動畫,停止移動 public void Die() { Debug.Log("Player Die!"); isDead = true; playerAnim.SetTrigger("Die"); rig2d.velocity = new Vector2(0, 0); }}

將腳本掛在角色身上,試著運行一下,我們的馬里奧大叔就可以在屏幕上動起來啦。

3.敵人

由於敵人有不同的種類,所以敵人代碼中要對不同性質的敵人進行不同的處理,由於本篇文章中僅涉及兩種怪物的實現邏輯:蘑菇怪(普通怪)和烏龜(特殊怪),故將兩種怪物的邏輯統一在一個腳本中(並不推薦將所有的怪物邏輯都擠在一個腳本中,這樣做的話若再添加新怪物,對代碼的維護和拓展很不方便)。

與玩家的處理方法類似,我們同樣將怪物模型引入,統一Layer為Enemy,但我們要把怪物明星放置在空父節點下作為子節點並添加碰撞盒和動畫控制器;在當前節點下,再繼續添加左右兩個觸發器,當玩家接觸該區域時,玩家死亡;再添加一個空節點,將其位置移出其他觸發碰撞區域,這個節點是檢測碰撞障礙物的出發點。需要注意的是,由於烏龜在踩中第一下時並不會直接死亡,而是變成龜殼,所以烏龜的節點下分別添加了普通狀態和龜殼狀態這兩種模型組件以便進行狀態切換;而且為了區分怪物種類,我們將蘑菇怪的Tag改為Normal,烏龜的Tag改為Special。

為怪物添加組件

初代超級馬里奧初期敵人的行動相對比較簡單,僅有簡單的移動和折返,這些通用的功能代碼如下:

//敵人移動,並沒有運用物理函數,而是直接改變位置void Move(){ this.transform.position += dir * Time.deltaTime * speed;}//向前進方向發射射線檢測,若碰到障礙物則折返public void CheckBorder(){ Vector2 checkPos = checkTran.position; RaycastHit2D borderHit = Physics2D.Raycast(checkPos, checkDir, checkDistance, borderLayer.value); if (borderHit.collider != null) { ChangeMoveDir(); }}//同樣運用射線檢測來判定是否接觸到其他怪物void CheckCharacter(){ Vector2 checkPos = checkTran.position; RaycastHit2D characterHit = Physics2D.Raycast(checkPos, checkDir, checkDistance, enemyLayer.value); if (characterHit.collider != null) { if (characterHit.collider.CompareTag("Normal") || characterHit.collider.CompareTag("Special")) { characterHit.collider.gameObject.GetComponentInParent<EnemyCharacter>().ChangeMoveDir(); } if (charType != EnemyType.Shell) { ChangeMoveDir(); } }}//改變前進方向public void ChangeMoveDir(){ dir.x *= -1; checkDir.x *= -1; Reverse();}//角色模型翻轉方法和玩家的基本一致void Reverse(){ var scale = transform.localScale; scale.x *= -1; transform.localScale = scale;}

先來看一下加入敵人後的效果:

如果玩家碰到了怪物,則玩家死亡。這段邏輯我拿了出來放在單獨的腳本中掛在死亡觸發區上,代碼如下:

using System.Collections;using System.Collections.Generic;using UnityEngine;public class DeathTrigger : MonoBehaviour{ [SerializeField] EnemyCharacter _enemy; PlayerCharacter _player; private void Start() { _player = GameObject.FindGameObjectWithTag("Player").GetComponent<PlayerCharacter>(); } private void OnTriggerEnter2D(Collider2D collision) { if (collision.CompareTag("Player")) { Debug.Log("Hit Player"); _player.Die(); } }}

這樣,當玩家接觸到怪物身上的死亡觸發區域時進死亡狀態,效果如下:

接下來我們繼續處理怪物被馬里奧踩中時的邏輯。

在代碼中,我們使用枚舉對怪物種類和狀態進行:

public enum EnemyType{ Normal, //普通蘑菇怪 Turtle, //烏龜普通狀態 Shell, //龜殼狀態}

若蘑菇怪被踩中則直接觸發被踩扁的動畫並進入死亡狀態:

void NormalEnemyHit(){ enemyAnim.SetTrigger("Hit"); CloseCollidersInChild(this.transform); if (stateInfo.IsName("Hit") && stateInfo.normalizedTime >= 1f) { this.gameObject.SetActive(false); }}

而烏龜的邏輯要複雜一些,普通狀態和龜殼狀態的代碼如下:

public void GetHit(int rStage){ if(charType == EnemyType.Turtle) //若當前為行走狀態,則切換為龜殼靜止狀態,關閉身上的死亡觸發區 { _turtleBody.SetActive(false); _turtleShell.SetActive(true); isHit = true; _dieTrigger.gameObject.SetActive(false); charType = EnemyType.Shell; } else if(charType == EnemyType.Shell) //在龜殼移動狀態下被踩中則恢復為龜殼靜止狀態 { isShellMove = false; isShellAttack = false; isOnTrigger = false; _player.InitCount(); } StartCoroutine("OnRecover");}//運用協程來處理龜殼靜止狀態時的動畫IEnumerator OnRecover(){ yield return new WaitForSeconds(3f); shellAnim.SetTrigger("OnRecover"); //三秒鐘內馬里奧沒有碰到龜殼的話則進入閃爍動畫 yield return new WaitForSeconds(2f); shellAnim.SetBool("IsRecover", true); //閃爍兩秒鐘後恢復為行走狀態 Recover();}//若玩家沒有行動,則恢復為行走狀態void Recover(){ _turtleShell.SetActive(false); _turtleBody.SetActive(true); Debug.Log("dir.x:" + dir.x + " transform.localScale.x:" + transform.localScale.x); if(transform.localScale.x * dir.x == 1) { var scale = transform.localScale; scale.x *= -dir.x; transform.localScale = scale; } isHit = false; isOnTrigger = false; _dieTrigger.gameObject.SetActive(true); charType = EnemyType.Turtle; _player.InitCount();}//若在龜殼靜止狀態時檢測到玩家進入範圍內,龜殼改變為移動狀態void CheckTrigger(){ Vector2 checkPos = transform.position; Vector2 playerPos = _player.transform.position; var hit = Physics2D.OverlapCircle(checkPos, 0.1f, playerLayer.value); if(hit != null) { isShellMove = true; isOnTrigger = true; isCheck = true; isShellAttack = true; var tempDir = checkPos - playerPos; //通過玩家位置和龜殼位置形成的向量來判斷龜殼的移動方向 if(tempDir.x > 0) { shellMoveDir = new Vector3(1, 0, 0); checkDir = new Vector2(1, 0); } else { shellMoveDir = new Vector3(-1, 0, 0); checkDir = new Vector2(-1, 0); } if (checkDir.x * dir.x == -1) { Reverse(); } shellAnim.Play("Shell", 0, 0); StopCoroutine("OnRecover"); }}//龜殼移動和正常行走的邏輯相同,只不過改變了移動速度void ShellMove(){ dir.x = shellMoveDir.x; transform.position += shellMoveDir * Time.deltaTime * shellMoveSpeed;}//龜殼進入移動狀態時,檢測玩家和龜殼的距離,只有當超出規定距離後才開啟死亡觸發區void CheckDistance(){ Vector2 checkPos = transform.position; Vector2 playerPos = _player.transform.position; var distance = (checkPos - playerPos).magnitude; if(distance > 1f) { _dieTrigger.gameObject.SetActive(true); _player.InitCount(); isCheck = false; }}//龜殼移動時,檢測是否接觸到其他怪物void CheckAttack(){ Vector2 checkPos = checkTran.position; RaycastHit2D hit = Physics2D.Raycast(checkPos, checkDir, 0.08f, enemyLayer.value); if(hit.collider != null) { ShellAttack(hit.collider); }} //對其他怪物造成傷害void ShellAttack(Collider2D rCollider){ if (rCollider.CompareTag("Normal") || rCollider.CompareTag("Special")) { rCollider.gameObject.GetComponentInParent<EnemyCharacter>().isDead = true; }}

若龜殼在移動狀態下擊中了其他怪物,就會觸發龜殼擊中時的死亡動畫進入死亡狀態:

void Die() { CloseCollidersInChild(this.transform); enemyAnim.SetTrigger("Die"); if(stateInfo.IsName("Die") && stateInfo.normalizedTime >= 0.9f) { Destroy(this.gameObject); } } //關閉子節點下的所有觸發碰撞器void CloseCollidersInChild(Transform rTran){ var tempTrans = rTran.GetComponentsInChildren<BoxCollider2D>(); foreach(var child in tempTrans) { child.enabled = false; }}

接下來我們看一下效果,首先是玩家踩中怪物時的效果:

接下來是龜殼在不同狀態時的效果:

最後再看一下龜殼擊中其他怪物的效果吧:

嗯,很完美!

到這裡,這些基礎的操作和交互均已實現完畢,完整的工程已上傳至我的GitHub (Yukimine33/MarioProject),歡迎大家查閱。


推薦閱讀:

用好Lua+Unity,讓性能飛起來——Lua與C#交互篇
Unity什麼時候應該手動進行視域Culling?
Billboards 技術在Unity 中的幾種使用方法
300行代碼實現Minecraft(我的世界)大地圖生成
【Unity】工具類系列教程—— 代碼自動化生成!

TAG:Unity游戏引擎 | 游戏开发 | 超级马力欧:奥德赛 |