在Unity中復(fù)刻《超級馬里奧》

本工程難度:★★
作者:Yukine
前言
去年的《超級馬里奧:奧德賽》再次驚艷了游戲圈,多家游戲媒體對本作打出了滿分,給予了極高的評價(jià),廣大圍觀群眾也感慨有幸在2017年見證了任天堂兩款神作的誕生。

相信許多人對奧德賽中3D轉(zhuǎn)2D的設(shè)計(jì)感到極為驚艷,看著2D關(guān)卡又一次出現(xiàn)在屏幕上,是不是有種回到了小時(shí)候在電視機(jī)前握著手柄和水管工一起闖關(guān)的時(shí)光呢?那么在Unity中是不是也可以復(fù)刻當(dāng)年那款風(fēng)靡全球的Super Mario呢?帶著這樣的疑問,咱們利用Unity自帶的2D系統(tǒng)來實(shí)現(xiàn)一下馬里奧的基本操作以及與怪物的基本交互。

實(shí)現(xiàn)流程
1.素材準(zhǔn)備
首先將準(zhǔn)備好的場景圖放入場景中,將其Layer改為Ground,并創(chuàng)建兩個(gè)空子節(jié)點(diǎn),加上Box Collider2D組件,分別作為地面和管道的碰撞體,并將馬里奧大叔和怪物的貼圖素材導(dǎo)入U(xiǎn)nity中,制作好各種狀態(tài)下的幀動(dòng)畫,并創(chuàng)建對應(yīng)的動(dòng)畫狀態(tài)機(jī)備用。

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

玩家類代碼整體如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerCharacter : MonoBehaviour
{
[SerializeField]
Rigidbody2D rig2d; //玩家自身的2D物理模塊
[SerializeField]
Animator anim; //玩家身上的動(dòng)畫控制器
[SerializeField]
Transform checkPoint; //玩家子物體中的監(jiān)測點(diǎn)
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();
}
}
//初始化函數(shù)
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取反來翻轉(zhuǎn)模型,實(shí)現(xiàn)左右轉(zhuǎn)向的效果
void Reverse()
{
if (isGrounded)
{
isFacingRight = !isFacingRight;
var scale = transform.localScale;
scale.x *= -1;
transform.localScale = scale;
}
}
//玩家移動(dòng)函數(shù),運(yùn)用Unity2D物理自帶函數(shù)實(shí)現(xiàn)
void Move(float dic)
{
rig2d.velocity = new Vector2(dic * curSpeed, rig2d.velocity.y);
a*********oat("Speed", Mathf.Abs(dic * curSpeed));
}
//跳躍,同樣運(yùn)用Unity2D物理實(shí)現(xiàn)
void Jump()
{
rig2d.AddForce(new Vector2(0, jumpHeight));
}
//射線檢測是否接觸地面,只有當(dāng)接觸地面的時(shí)候才可以跳躍以免出現(xiàn)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;
}
}
//運(yùn)用2D相交圓檢測腳下是否有怪物
void CheckHit()
{
var check = checkPoint.position;
var hit = Physics2D.OverlapCircle(check, 0.07f, enemyLayer.value);
if (hit != null)
{
if (hit.CompareTag("Normal")) //若踩中普通怪物,則給予玩家一個(gè)反彈力,并觸發(fā)怪物的死亡效果
{
Debug.Log("Hit Normal!");
rig2d.velocity = new Vector2(rig2d.velocity.x, 5f);
hit.GetComponentInParent<EnemyCharacter>().isHit = true;
}
else if (hit.CompareTag("Special")) //若踩中特殊怪物(烏龜),則在敵人相關(guān)代碼中做對應(yīng)變化
{
hitCount += 1;
if (hitCount == 1)
{
rig2d.velocity = new Vector2(rig2d.velocity.x, 5f);
hit.GetComponentInParent<EnemyCharacter>().GetHit(1);
}
}
}
}
public void InitCount()
{
hitCount = 0;
}
//若玩家死亡,則進(jìn)入死亡狀態(tài),出發(fā)死亡動(dòng)畫,停止移動(dòng)
public void Die()
{
Debug.Log("Player Die!");
isDead = true;
playerAnim.SetTrigger("Die");
rig2d.velocity = new Vector2(0, 0);
}
}
將腳本掛在角色身上,試著運(yùn)行一下,我們的馬里奧大叔就可以在屏幕上動(dòng)起來啦。

3.敵人
由于敵人有不同的種類,所以敵人代碼中要對不同性質(zhì)的敵人進(jìn)行不同的處理,由于本篇文章中僅涉及兩種怪物的實(shí)現(xiàn)邏輯:蘑菇怪(普通怪)和烏龜(特殊怪),故將兩種怪物的邏輯統(tǒng)一在一個(gè)腳本中(并不推薦將所有的怪物邏輯都擠在一個(gè)腳本中,這樣做的話若再添加新怪物,對代碼的維護(hù)和拓展很不方便)。
與玩家的處理方法類似,我們同樣將怪物模型引入,統(tǒng)一Layer為Enemy,但我們要把怪物明星放置在空父節(jié)點(diǎn)下作為子節(jié)點(diǎn)并添加碰撞盒和動(dòng)畫控制器;在當(dāng)前節(jié)點(diǎn)下,再繼續(xù)添加左右兩個(gè)觸發(fā)器,當(dāng)玩家接觸該區(qū)域時(shí),玩家死亡;再添加一個(gè)空節(jié)點(diǎn),將其位置移出其他觸發(fā)碰撞區(qū)域,這個(gè)節(jié)點(diǎn)是檢測碰撞障礙物的出發(fā)點(diǎn)。需要注意的是,由于烏龜在踩中第一下時(shí)并不會(huì)直接死亡,而是變成龜殼,所以烏龜?shù)墓?jié)點(diǎn)下分別添加了普通狀態(tài)和龜殼狀態(tài)這兩種模型組件以便進(jìn)行狀態(tài)切換;而且為了區(qū)分怪物種類,我們將蘑菇怪的Tag改為Normal,烏龜?shù)腡ag改為Special。

初代超級馬里奧初期敵人的行動(dòng)相對比較簡單,僅有簡單的移動(dòng)和折返,這些通用的功能代碼如下:
//敵人移動(dòng),并沒有運(yùn)用物理函數(shù),而是直接改變位置
void Move()
{
this.transform.position += dir * Time.deltaTime * speed;
}
//向前進(jìn)方向發(fā)射射線檢測,若碰到障礙物則折返
public void CheckBorder()
{
Vector2 checkPos = checkTran.position;
RaycastHit2D borderHit = Physics2D.Raycast(checkPos, checkDir, checkDistance, borderLayer.value);
if (borderHit.collider != null)
{
ChangeMoveDir();
}
}
//同樣運(yùn)用射線檢測來判定是否接觸到其他怪物
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();
}
}
}
//改變前進(jìn)方向
public void ChangeMoveDir()
{
dir.x *= -1;
checkDir.x *= -1;
Reverse();
}
//角色模型翻轉(zhuǎn)方法和玩家的基本一致
void Reverse()
{
var scale = transform.localScale;
scale.x *= -1;
transform.localScale = scale;
}
先來看一下加入敵人后的效果:

如果玩家碰到了怪物,則玩家死亡。這段邏輯我拿了出來放在單獨(dú)的腳本中掛在死亡觸發(fā)區(qū)上,代碼如下:
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();
}
}
}
這樣,當(dāng)玩家接觸到怪物身上的死亡觸發(fā)區(qū)域時(shí)進(jìn)死亡狀態(tài),效果如下:

接下來我們繼續(xù)處理怪物被馬里奧踩中時(shí)的邏輯。
在代碼中,我們使用枚舉對怪物種類和狀態(tài)進(jìn)行:
public enum EnemyType
{
Normal, //普通蘑菇怪
Turtle, //烏龜普通狀態(tài)
Shell, //龜殼狀態(tài)
}
若蘑菇怪被踩中則直接觸發(fā)被踩扁的動(dòng)畫并進(jìn)入死亡狀態(tài):
void NormalEnemyHit()
{
enemyAnim.SetTrigger("Hit");
CloseCollidersInChild(this.transform);
if (stateInfo.IsName("Hit") && stateInfo.normalizedTime >= 1f)
{
this.gameObject.SetActive(false);
}
}
而烏龜?shù)倪壿嬕獜?fù)雜一些,普通狀態(tài)和龜殼狀態(tài)的代碼如下:
public void GetHit(int rStage)
{
if(charType == EnemyType.Turtle) //若當(dāng)前為行走狀態(tài),則切換為龜殼靜止?fàn)顟B(tài),關(guān)閉身上的死亡觸發(fā)區(qū)
{
_turtleBody.SetActive(false);
_turtleShell.SetActive(true);
isHit = true;
_dieTrigger.gameObject.SetActive(false);
charType = EnemyType.Shell;
}
else if(charType == EnemyType.Shell) //在龜殼移動(dòng)狀態(tài)下被踩中則恢復(fù)為龜殼靜止?fàn)顟B(tài)
{
isShellMove = false;
isShellAttack = false;
isOnTrigger = false;
_player.InitCount();
}
StartCoroutine("OnRecover");
}
//運(yùn)用協(xié)程來處理龜殼靜止?fàn)顟B(tài)時(shí)的動(dòng)畫
IEnumerator OnRecover()
{
yield return new WaitForSeconds(3f);
shellAnim.SetTrigger("OnRecover"); //三秒鐘內(nèi)馬里奧沒有碰到龜殼的話則進(jìn)入閃爍動(dòng)畫
yield return new WaitForSeconds(2f);
shellAnim.SetBool("IsRecover", true); //閃爍兩秒鐘后恢復(fù)為行走狀態(tài)
Recover();
}
//若玩家沒有行動(dòng),則恢復(fù)為行走狀態(tài)
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();
}
//若在龜殼靜止?fàn)顟B(tài)時(shí)檢測到玩家進(jìn)入范圍內(nèi),龜殼改變?yōu)橐苿?dòng)狀態(tài)
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;
//通過玩家位置和龜殼位置形成的向量來判斷龜殼的移動(dòng)方向
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");
}
}
//龜殼移動(dòng)和正常行走的邏輯相同,只不過改變了移動(dòng)速度
void ShellMove()
{
dir.x = shellMoveDir.x;
transform.position += shellMoveDir * Time.deltaTime * shellMoveSpeed;
}
//龜殼進(jìn)入移動(dòng)狀態(tài)時(shí),檢測玩家和龜殼的距離,只有當(dāng)超出規(guī)定距離后才開啟死亡觸發(fā)區(qū)
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;
}
}
//龜殼移動(dòng)時(shí),檢測是否接觸到其他怪物
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; }
}
若龜殼在移動(dòng)狀態(tài)下?lián)糁辛似渌治铮蜁?huì)觸發(fā)龜殼擊中時(shí)的死亡動(dòng)畫進(jìn)入死亡狀態(tài):
void Die()
{
CloseCollidersInChild(this.transform);
enemyAnim.SetTrigger("Die");
if(stateInfo.IsName("Die") && stateInfo.normalizedTime >= 0.9f)
{
Destroy(this.gameObject);
}
}
//關(guān)閉子節(jié)點(diǎn)下的所有觸發(fā)碰撞器
void CloseCollidersInChild(Transform rTran)
{
var tempTrans = rTran.GetComponentsInChildren<BoxCollider2D>();
foreach(var child in tempTrans)
{
child.enabled = false;
}
}
接下來我們看一下效果,首先是玩家踩中怪物時(shí)的效果:

接下來是龜殼在不同狀態(tài)時(shí)的效果:

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

嗯,很完美!
到這里,這些基礎(chǔ)的操作和交互均已實(shí)現(xiàn)完畢。
完整的工程已上傳至我的GitHub:https://github.com/Yukimine33/MarioProject (Yukimine33/MarioProject),歡迎大家查閱。
有想學(xué)習(xí)游戲開發(fā)的同學(xué),可移步至http://www.levelpp.com圍觀一波。同時(shí),大佬云集的游戲開發(fā)群869551769歡迎加入討(jiao)論(ji)。