资源大湿 发表于 2020-11-23 21:16

在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
{
   
    Rigidbody2D rig2d; //玩家自身的2D物理模块
   
    Animator anim; //玩家身上的动画控制器
   
    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
{
   
    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),欢迎大家查阅。
页: [1]
查看完整版本: 在Unity中复刻《超级马里奥》