Skip to content

Yousazoe/OnOff

Repository files navigation

项目介绍

项目架构

  • 玩家 PlayerController.cs
  • 游戏功能 GameManager.cs
  • 转换颜色 SwitchColor.cs
  • 地板 Ground.cs
  • 终点 Goal.cs

玩家功能

按左右键进行移动
  • 侦测左右键输入

    • float h = Input.GetAxisRaw("Horizontal")
    • 不按键,h=0;按右键,h=1;按左键,h=-1
  • 实际移动

    • 修改速度 rb.velocity
    • 只修改x方向速度
    • rb.velocity=new Vector2(moveSpeed*h,rb.velocity.y)
  • 角色面向随行走方向改变

    • 通过改变transform.localScale.x实现
  • 角色动画

    • 站立、行走、跳跃三个动画之间的转换
向上进行跳跃
  • 向上施加力
  • 不能在空中跳跃
    • 判断是否踩在地板上 isGround

核心功能搭建

角色控制

变量声明

我们首先声明一些变量:

  • moveSpeed:玩家移动速度
  • jumpForce:玩家跳跃速度
  • rb:玩家物理组件
  • anim:玩家动画组件
public float moveSpeed;
public float jumpForce;

private Rigidbody2D rb;
private Animator anim;  

声明变量之后我们先在Start()中获取这些组件,需要注意的是动画组件是用获取子物件的GetComponentInChildren<Animator>()

// Start is called before the first frame update
void Start()
{
  rb = GetComponent<Rigidbody2D>();
  anim = GetComponentInChildren<Animator>();
}
横向移动实现

这里使用GetAxisRaw而不是GetAxis的原因是如果我们使用GetAxis按键松手后h会慢慢过渡回0,而GetAxisRaw是瞬时变化为0。

移动的实现我们选择rb.velocityVector2()中的h * moveSpeed代表了方向乘以移动速度,这样如果按下左键h = -1就会向左移动;如果按下右键h = 1则向右移动;没有输入则保持不动。

// Update is called once per frame
void Update()
{
  //right h =1
  //left h = -1
  //no input h = 0
  //GetAxis GetAxisRaw
  float h = Input.GetAxisRaw("Horizontal");

  rb.velocity = new Vector2(h * moveSpeed,rb.velocity.y);

}   

回到Unity,在PlayerController.cs脚本中设定moveSpeed,测试角色可以正常移动。但相比其他平台跳跃类游戏,我们的角色移动时的朝向并不会改变,所以我们需要接着修改。

移动朝向

朝向问题其实不难解决,我们可以先尝试修改一下角色Transform组件中的Scale,不难发现向右时为1;向左时为-1。这时候联想一下刚才的h,赋值属性非常契合。

在代码中我们需要考虑没有输入的情况,如果依然随着h,那么角色会被压缩到无,所以需要加入判断if (h != 0),然后在判断内通过对Scale赋值为h来改变朝向。

// Update is called once per frame
void Update()
{
  ......
	if (h != 0)
  {
  	transform.localScale = new Vector3(h,1,1);
  }
}   

回到Unity测试,角色已经可以随着移动方向改变而改变朝向。

跳跃

通常来讲一般和物理相关都需要写在FixedUpdate()里,但我们这个小游戏没有很吃性能,所以就写在Update()里面了。

如果按下上键,那么我们会给Rigidbody2D添加一个方向向上、大小为jumpForce,施力模式向ForceMode2D.Impulse是此 rigidbody2D 添加瞬时力冲击。

// Update is called once per frame
void Update()
{
  ......
  if (Input.GetKeyDown(KeyCode.UpArrow))
  {
    rb.AddForce(Vector3.up * jumpForce,ForceMode2D.Impulse);
  }
}   

返回Unity,设置调整jumpForce数值,测试角色跳跃。

但如果多测试几次就会发现,角色可以在空中进行多次跳跃,也就是所谓的“无限跳”。对于这个问题,我们其实只需要判断 角色的脚底是否是地板 就可以了,而踩在地板这个条件则可以通过标签进行判断,如果角色脚底是地板则可以进行跳跃操作,否则则不能跳跃。

所以我们在Player上创建子物件GroundCheck移到角色脚底,作用就是检测角色是否碰到地板。

回到程式我们需要找到这个检测物件GroundCheck,所以首先在前面声明变量:

public class PlayerController : MonoBehaviour
{
	......
	public Transform groundCheck;
	......
}

然后回到跳跃的判断,加入判断函数isGround()来判断我们是否踩在地板上:

- if (Input.GetKeyDown(KeyCode.UpArrow))
+ if (Input.GetKeyDown(KeyCode.UpArrow) && IsGround())
{
  rb.AddForce(Vector3.up * jumpForce,ForceMode2D.Impulse);
  AudioManager.S.PlayPlayerSFX(0);
}

编写判断函数isGround(),这里Physics2D.CircleCast(transform.position,0.3f,Vector2.down,0.35f,whatIsGround)的意思是我们画一个圆在物件本身的位置上,向下0.35个单位画一个半径为0.3的圆,这个圆将只会和被我们判定成Ground的图层碰撞。如果hit碰撞到返回true,否则返回false

bool IsGround()
{
	RaycastHit2D hit = Physics2D.CircleCast(transform.position,0.3f,Vector2.down,0.35f,whatIsGround);

	return hit;
}

这样写就不需要groundCheck了,我们删除之前设置的变量和空物件,改为获取图层LayerMask

public class PlayerController : MonoBehaviour
{
	......
	- public Transform groundCheck;
  + public LayerMask whatIsGround;
	......
}

回到Unity设置检测图层为Ground

动画连接

Move部分是由Move这个Parameter判断的,0播放Player_Idle;1播放Player_Move。

可以发现动画播放和h的关联性,但因为有-1的关系需要取绝对值Mathf.Abs(),这样左右移动取值均为1。

// Update is called once per frame
void Update()
{
	......
  anim.SetFloat("Move",Mathf.Abs(h));
  anim.SetBool("isGround",IsGround());
}

回到Unity,测试站立、移动和跳跃的动画。

整理

对我们PlayerController.cs的代码稍加整理,添加一些注释:

// Update is called once per frame
void Update()
{
  // Move
  //right h =1 left h = -1 no input h = 0
  //GetAxis GetAxisRaw
  float h = Input.GetAxisRaw("Horizontal");
  rb.velocity = new Vector2(h * moveSpeed,rb.velocity.y);

  //Change Face Direction
  if (h != 0)
  {
    transform.localScale = new Vector3(h,1,1);
  }

  //Apply Jump
  if (Input.GetKeyDown(KeyCode.UpArrow) && IsGround())
  {
    rb.AddForce(Vector3.up * jumpForce,ForceMode2D.Impulse);
    AudioManager.S.PlayPlayerSFX(0);
  }

  //Set Animator
  anim.SetFloat("Move",Mathf.Abs(h));
  anim.SetBool("isGround",IsGround());
}

颜色转换

这部分我们来实现游戏的核心功能:颜色转换的部分。首先为所有需要颜色转换功能的物件(玩家、地板等等)新建一个共同的脚本命名为SwitchColor.cs,同时因为这是游戏的核心功能,所以我们新建一个GameManager.cs来进行管理。

为了确保GameManager游戏管理者有且仅有一个,我们使用单例模式:

public class GameManager : MonoBehaviour
{
	public static GameManager S;
	
	private void Awake()
  {
  	S = this;
  }
}

SwitchAllColor()函数用于抓取场景所有有SwitchColor,同时设置数组存储这些物体:

private SwitchColor[] colorObjs;
public void SwitchAllColor(){}

colorObjs则在开始时的Start()中获取,保存在SwitchColor[]中:

void Start()
{
	colorObjs = FindObjectsOfType<SwitchColor>();
}

回到SwitchAllColor()中遍历数组呼叫里面的SwichObjColor()函数:

public void SwitchAllColor()
{
  + foreach (SwitchColor colorObj in colorObjs)
  + {
  + 	colorObj.SwichObjColor();
  + }
}

新建SwitchColor.cs,声明之前在GameManager.cs调用的SwichObjColor()。因为这个脚本需要同时满足玩家、地板、背景、UI等的颜色变换需求,所以我们用枚举的方式区分不同的类别,然后根据枚举的类型决定颜色转换的方式:

对于玩家而言,颜色的改变只需要将spriteRender中的颜色改变即可;而地板则需要考虑碰撞体的激活问题;背景则由相机的Background来控制;imagetext之后会用于UI的颜色转换。

public class SwitchColor : MonoBehaviour
{
	public void SwichObjColor(){}
}

public enum ComponentType
{
   player,spriteRender,image,text,camera,ground
}

回到Unity对相应物件的组件类型ComponentType进行赋值。返回SwitchColor.cs,声明作为选项的组件类型和变换前后的两种颜色:

public class SwitchColor : MonoBehaviour
{
    + public ComponentType componentType;
    + public Color startColor;
    + public Color endColor;
  
  ......
}

有了上面的变量,我们就可以根据不同的组件类型利用Switch对转换颜色进行分支操作:

  • player:获取精灵渲染器改变颜色,color == startColor ? endColor : startColor做一个颜色的翻转
  • spriteRender:同player
  • image:涉及UI部分需要引入using UnityEngine.UI
  • text:同image
  • camera:同player
  • ground:在player的基础上对是否启用BoxCollider2D进行翻转
public void SwichObjColor()
{
  switch (componentType)
  {
    case ComponentType.player:
      {
        //if black
        //change to white
        //else
        //change to black
        GetComponentInChildren<SpriteRenderer>().color =
          GetComponentInChildren<SpriteRenderer>().color == startColor ? endColor : startColor;
        break;
      }

    case ComponentType.spriteRender:
      {
        GetComponent<SpriteRenderer>().color =
          GetComponent<SpriteRenderer>().color == startColor ? endColor : startColor;
        break;
      }

    case ComponentType.image:
      {
        GetComponent<Image>().color =
          GetComponent<Image>().color == startColor ? endColor : startColor;
        break;
      }

    case ComponentType.text:
      {
        GetComponent<Text>().color =
          GetComponent<Text>().color == startColor ? endColor : startColor;
        break;
      }

    case ComponentType.camera:
      {
        Camera.main.backgroundColor = Camera.main.backgroundColor == startColor ? endColor : startColor;
        break;
      }

    case ComponentType.ground:
      {
        GetComponent<BoxCollider2D>().enabled = !GetComponent<BoxCollider2D>().enabled;

        GetComponentInChildren<SpriteRenderer>().color =
          GetComponentInChildren<SpriteRenderer>().color == startColor ? endColor : startColor;
        break;
      }
  }
}

回到PlayerController.cs,在Update()函数中调用GameManager.cs中的SwitchAllColor(),再经由此跳转到SwitchColor.cs中的SwichObjColor()方法:

// Update is called once per frame
void Update()
{
  ......

  //Switch Color
  if (Input.GetKeyDown(KeyCode.Space))
  {
  	GameManager.S.SwitchAllColor();
	}
}

回到Unity,对组件的起始、结束的颜色进行赋值,注意透明度调至255:

ComponentType startColor endColor
Main Camera 255 255 255 0 0 0
Player 0 0 0 255 255 255
BlackGround 51 51 51 71 71 71
WhiteGround 235 235 235 255 255 255

测试颜色转换功能:

死亡及死亡特效

//Switch Color
if (Input.GetKeyDown(KeyCode.Space))
{
  GameManager.S.SwitchAllColor();

  + if (insideBlock)
  + {
  +	 Die();
  + }
}
void Update()
{
  ......
	+ if (transform.position.y < seftDestructionHeight && sr.enabled)
  + {
  + 	Die();
  + }
}  
public void Die()
{
  GameManager.S.PlayerDie();
  sr.enabled = false;
  GetComponent<BoxCollider2D>().enabled = false;

  GameObject dfx = Instantiate(deadEffect, transform.position, Quaternion.identity);
  Destroy(dfx,2f);
}

加载关卡

为目标添加Goal.cs

  • OnTriggerEnter2D(Collider2D collision):检测碰撞玩家,播放动画;为防止再次触发过关手动把CircleCollider2D关掉
  • ToNextStage():调用GameManager中的NextStage()函数进入下一关
  • EnableCollider():将碰撞体激活
using UnityEngine;

public class Goal : MonoBehaviour
{
    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
            GetComponent<Animator>().SetTrigger("Goal");
            GetComponent<CircleCollider2D>().enabled = false;
        }
    }

    public void ToNextStage()
    {
        GameManager.S.NextStage();
    }

    public void EnableCollider()
    {
        GetComponent<CircleCollider2D>().enabled = true;
    }
}

回到GameManager.cs编写进入关卡的逻辑,直接调用SceneManager读取我们的关卡数,+1后就是下一关。

using UnityEngine;
using UnityEngine.SceneManagement;

public class GameManager : MonoBehaviour
{
    + public void NextStage()
    + {   
    +    SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
    + }
}

回到Unity将结束动画中最后帧中的Function赋值给ToNextStage()以触发:

用户界面及数据继承

搭建UI组件,放入素材中的图片,挂载颜色转换脚本SwitchColor.cs同时将类型更改为ImageText

为了便于管理,我们新建一个管理UI的脚本UIManager.cs

首先像之前一样使用单例模式创建一个UIManager,再声明两个Text类型的变量(死亡数和星星数),最后声明两个 Refresh刷新函数用于接收新数据并更新UI界面的数据:

using UnityEngine.UI;

public class UIManager : MonoBehaviour
{
    public static UIManager S;

    private void Awake()
    {
        S = this;
    }

    public Text skullText;
    public Text startText;

    public void RefreshSkullText(int amount)
    {
        skullText.text = amount.ToString();
    }

    public void RefreshStarText(int amount)
    {
        startText.text = amount.ToString();
    }
}

回到GameManager.cs新增统计死亡数和星星数的变量:

public class GameManager : MonoBehaviour
{
		......
    + public static int deadCount;
    + public static int starCount;
    ......
}    

分别在死亡和过关的函数中计数并调用UIManager:

public void PlayerDie()
{
  + deadCount++;
  + UIManager.S.RefreshSkullText(deadCount);

  StartCoroutine(PlayerRevive());
}

public void NextStage()
{
  + starCount++;
  + UIManager.S.RefreshStarText(starCount);

  SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
}

最后在Star()函数中调用刷新函数确保数据能够刷新:

void Start()
{
  colorObjs = FindObjectsOfType<SwitchColor>();

  + UIManager.S.RefreshSkullText(deadCount);
  + UIManager.S.RefreshStarText(starCount);
}

音乐与音效

using UnityEngine;

public class AudioManager : MonoBehaviour
{
    public static AudioManager S;
    private void Awake() 
    {
        if (S == null) 
        {
            S = this;
        }
        else
        {
            Destroy(gameObject);
        }
    }
    
    public AudioSource playerAudio;
    public AudioSource stageAudio;

    public AudioClip[] playerSfx;
    public AudioClip[] stageSfx;
    
    // Start is called before the first frame update
    void Start()
    {
        DontDestroyOnLoad(gameObject);
    }

    public void PlayPlayerSFX(int index)
    {
        playerAudio.clip = playerSfx[index];
        playerAudio.Play();
    }
    
    public void PlayStageSFX(int index)
    {
        stageAudio.clip = stageSfx[index];
        stageAudio.Play();
    }
}

效果完善总结

开始界面

残影效果

代码逻辑

我们首先需要声明一些变量:

  • dashSpeed:冲刺速度
  • dashTime:冲刺持续时间
  • startDashTime:倒计时的计时器
  • isDashing:冲刺状态的记录量
  • dashObj:保存残影物体

冲刺的逻辑是:

if(不是冲刺状态)
{
	if(按下左Shift键)
	{
		进入冲刺状态;
		启用残影对象;
		启用计时器;
	}
}
else
{
	if(还有时间剩余)
	{
		velocity向前乘以向前的冲刺速度
	}
	else
	{
		改变冲刺状态;
		隐藏残影的游戏物体;		
	}
}

通过上面的伪代码,我们可以轻松写出业务逻辑:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
		public float dashSpeed;
  	public float dashTime;
    public GameObject dashObj;
    
 		private bool isDashing;
    private float startDashTime;
    
    
    void Update()
    {
        BASIC MOVEMENT

        
        if (!isDashing)
        {
            if (Input.GetKeyDown(KeyCode.LeftShift))
            {
                dashObj.SetActive(true);
                
                isDashing = true;
                startDashTime = dashTime;
            }
        }
        else
        {
            startDashTime -= Time.deltaTime;
            if (startDashTime <= 0)
            {
                isDashing = false;
                dashObj.SetActive(false);
            }
            else
            {
                rb.velocity = transform.right * dashSpeed;
            }
        }
      
        ......
    }
}    
粒子效果

回到Unity编辑器,新建一个空的游戏对象并为其添加一个粒子系统组件

粒子系统(Particle System)主模块

Particle System 模块包含影响整个系统的全局属性。大多数这些属性用于控制新创建的粒子的初始状态。

  • Duration:系统运行的时间长度。取值与冲刺时间相近避免残影持续太长时间

  • Start LifeTime:粒子的初始生命周期。与上面同理

  • Start Size:每个粒子的初始大小。根据自己的大小来调整

  • StartColor:每个粒子的初始颜色。这里我们选择由浅绿色到浅蓝色的随机渐变

  • Simulation Space:控制粒子的运动位置是在父对象的局部空间中(因此与父对象一起移动)、在世界空间中还是相对于自定义对象(与您选择的自定义对象一起移动)。选择世界空间(World)使残影生成时留在原地,不会和角色移动有任何关系

  • Max Particles:系统中同时允许的最多粒子数。可以适当调小一些

Emission 模块

此模块中的属性会影响粒子系统发射的速率和时间。

  • Rate over Distance:每个移动距离单位发射的粒子数。这样粒子系统就会随距离而不是时间生成粒子

Shape模块

此模块用于定义可发射粒子的体积或表面以及起始速度的方向。Shape 属性定义发射体积的形状,其余模块属性根据您选择的 Shape 值而变化。

  • Shape:发射体积的形状。选择圆形(Circle
Color Over Lifetime 模块

此模块指定粒子的颜色和透明度在其生命周期中如何变化。

  • Color:粒子在其生命周期内的颜色渐变,渐变条的左侧点表示粒子寿命的开始,而渐变条的右侧表示粒子寿命的结束。我们这里设置渐变透明,透明度由高到低
Texture Sheet Animation 模块

粒子的图形不必是静止图像。此模块允许您将纹理视为可作为动画帧进行播放的一组单独子图像。

  • Mode:弹出菜单。2D残影选择Sprites模式,图片选择切分好的站立的精灵即可

效果

最后我们按下LeftShift就有一个由绿到蓝的渐变透明残影的效果了:

尖刺和二段跳

尖刺

创建一个三角形物体并拖拽至精灵文件夹Sprites中,命名为Triangle

制作预制体,为其添加组件BoxCollider2D,可以在Edit Collider中变更碰撞判定。在实际游戏开发中不可能将碰撞体制作修正的和本体一模一样,大部分做法是根据感觉选择一个折中的碰撞判定区域,所以我们框选一个差不多的碰撞体即可。和之前的地板一致,在初始状态下白色的尖刺碰撞体应该是取消勾选碰撞组件的。

为其添加颜色转换组件SwitchColor.cs

  • Commponent Type:选择Ground,性质与Ground一致
  • Start Color:颜色235235235;透明度 255
  • End Color:颜色255255255;透明度255

对于尖刺的脚本只需要附载OnTriggerEnter2D(Collider2D collision),通过判断碰撞体标签是否为Player来判断是否碰到玩家,如果碰到则触发玩家控制器PlayerController.cs中的玩家死亡Die()即可。

public class Trap : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
            FindObjectOfType<PlayerController>().Die();
        }
    }
}

同理我们可以复制粘贴白色尖刺,通过更改变换颜色SwitchColor.cs脚本的颜色和重新勾选碰撞体来制作黑色尖刺:

  • Commponent Type:选择Ground,性质与Ground一致
  • Start Color:颜色515151;透明度 255
  • End Color:颜色717171;透明度255

最后将黑白两个尖刺都拖至预制体文件夹Prefab中以备之后关卡搭建使用。

项目导出

分辨率

直接导出为可执行程序会导致之前设计的UI位置会发生变化,所以我们新建脚本Resolution.cs,固定分辨率为1024x768:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Resolution : MonoBehaviour
{
    void Awake()
    {
        Screen.SetResolution(1024,768,true);
    }
}

这里1024x768的分辨率来源是根据UI的Canvas Scaler中的分辨率:

回到Unity将其挂在到主相机Main Camera上,测试运行,UI就恢复了正常,回到原位。

Packages

No packages published

Languages