Unity2D:从零开始制作一款跑酷游戏!
目录
成品展示
美术资源
制作步骤
场景预布设:
实现人物基础功能:
移动背景——横向卷轴:
生成障碍物:
生成敌人与攻击逻辑:
UI制作与重新开始:
导出游戏:
小结
大家小时候都玩过《天天酷跑》吧?这类游戏的原理很简单,非死即跑,主要用到的是2D卷轴技术。现在,我们一起来把经典复刻,从零开始做一款简单的跑酷类小游戏。
成品展示
回家的路
关于这款游戏的源文件与exe文件我已经同步上传到了github,想游玩的话复制下面的地址双击下载zip文件即可。
https://github.com/starmatch9/simple-parkour-game-Ascendant-Path-/releases/tag/v1.0.0
这里也提供了百度网盘的下载链接,点击最下方阅读原文即可下载:
https://pan.baidu.com/s/1AuaV1R0l9dwORnzzMSs1rg?pwd=y4mu
提取码: y4mu
美术资源
这里采用的都是Unity资源商店中的免费资源,可以根据喜好自行选择,在此列出我使用的资源。
背景:2D Adventure Backgrounds Pack
https://assetstore.unity.com/packages/2d/environments/2d-adventure-backgrounds-pack-309711
人物:Cute 2D - College Student
https://assetstore.unity.com/packages/2d/characters/cute-2d-college-student-198684
障碍物:Free Platform Game Assets
https://assetstore.unity.com/packages/2d/environments/free-platform-game-assets-85838
怪物:Fantasy Monster Pack: 5 Handcrafted 2D Creatures
https://assetstore.unity.com/packages/2d/characters/fantasy-monster-pack-5-handcrafted-2d-creatures-296211
GUI:Buttons Set
https://assetstore.unity.com/packages/2d/gui/buttons-set-211824
字体:Bubble Font (Free Version)
https://assetstore.unity.com/packages/2d/fonts/bubble-font-free-version-24987
制作步骤
场景预布设:
我们可以在包管理器:我的资产界面看到我们下载好的资源,现在将准备好的资源导入到编辑器中,能够看到名称为资源名称的文件夹出现在Asset目录下。
然后就可以先创建背景了:在场景中创建一个名为“Background”的空物体,用来管理我们的背景精灵。然后把我们的背景资源依次拖入到该物体的下方。因为我们的背景用不到Unity的物理系统,所以就可以不用更改精灵导入PPU,要调整大小的话,放入场景后改变Scale即可。
现在添加名为“Background”的排序图层,将所有的背景精灵放在这个排序图层上,用于以后确定这些背景图像渲染在最下方。
并调整图层顺序以获得正确的遮盖方式,对每个部分的位置进行移动以达到最佳状态。
当然不能忘记设置摄像机的正交大小,因为我选取的角色是带有骨骼动画的,覆盖了PPU等精灵导入设置,所以我们调整摄像机的正交大小以显示人物合适的大小。
按照同样的方法将角色导入,然后将预制件拖入场景中,调整摄像机正交大小,也别忘了缩放我们背景(父物体)的大小。
现在我们把平台放入场景,即人物脚踩的地方。因为需要在平台拼接时保持平滑,我采用了瓦片地图的方式。在场景中创建一个瓦片地图,会自动生成名为Grid的父物体,用来确定瓦片地图使用的网格坐标系。将瓦片地图改名为Platforms用来放置平台。
然后创建瓦片调色板(即平铺调色板),目标瓦片地图设置为我们刚刚创建的Platforms。将瓦片调色板的资源分文件存放便于区分。
然后用调色板绘制平台,注意要将平台地图的排序图层设置为Background,图层顺序放置中景与前景之间以达到预期效果。
到现在,我们的场景布局就完成了,现在开始考虑游戏各种功能的实现。
实现人物基础功能:
在这种跑酷类游戏中,是不需要真正控制角色移动的,只需要让背景、平台和障碍物不断朝左边运动即可。所以我们的人物只需要能够原地奔跑以及原地跳跃就足够了。
首先角色得能跑吧?创建一个动画控制器专门用来控制角色的动画。然后将场景中人物的控制器替换为我们自己创建的控制器,将资源中的奔跑动画拖入其中。
现在开始实现角色跳跃。详细的实现在之前的文章谈到过,所以这里不作过多说明。为我们的平台添加碰撞器,并添加一个复合碰撞器让碰撞器们平滑连接。
为角色添加胶囊碰撞器以及刚体组件,并调整其大小,用于参与物理模拟。同时为了防止其移动,我们冻结其水平方向的位置与旋转。
然后在动画动画器中添加跳跃动画,同时创建bool参数isJump判断是否要从奔跑切换为跳跃。在奔跑和跳跃之间添加过渡,一定不能勾选有退出时间,且过渡持续时间为0。
然后编写以下角色跳跃脚本。
Animator animator;
Rigidbody2D body;
public float jumpVelocity = 20;
//用来判断角色是否在平台上
bool onPlatform = true;
void Awake()
{animator = GetComponent<Animator>();body = GetComponent<Rigidbody2D>();
}
void Update()
{//按键J用来检测跳跃(暂定)if (onPlatform && Input.GetKeyDown(KeyCode.J)){body.velocity = new Vector2 (body.velocity.x, jumpVelocity);}
}
//这里同之前的文章一样,记得改平台的图层名称用来识别
private void OnCollisionEnter2D(Collision2D collision)
{if(collision.gameObject.layer == LayerMask.NameToLayer("Platform")){onPlatform = true;animator.SetBool("isJump", false);}
}
private void OnCollisionExit2D(Collision2D collision)
{if (collision.gameObject.layer == LayerMask.NameToLayer("Platform")){onPlatform = false;animator.SetBool("isJump", true);}
}
然后我们的角色跳跃功能就做好了。
因为这个角色资源里也包含了攻击动画(Attack)与滑板车动画(KickBoard),所以我们用相同的方式也加入这两个动画。
然后编写脚本:滑板车设置为按住空格键就一直滑行,松开就回到跑步状态。攻击就通过协程的方式让动作持续一段时间,后续可以加入攻击效果。
bool isAttack = false;
void Update()
{//按键J用来检测跳跃(暂定)if (onPlatform && Input.GetKeyDown(KeyCode.J)){body.velocity = new Vector2 (body.velocity.x, jumpVelocity);}//按住空格检测滑板车if (Input.GetKey(KeyCode.Space)){animator.SetBool("isKickBoard", true);}else{animator.SetBool("isKickBoard", false);}if (!isAttack && Input.GetKey(KeyCode.F)){isAttack = true;StartCoroutine(Attack());}
}
IEnumerator Attack()
{animator.SetBool("isAttack", true);yield return new WaitForSeconds(0.8f);animator.SetBool("isAttack", false);//等等一段时间后才能进行下一次攻击,相当于冷却时间yield return new WaitForSeconds(0.5f);isAttack = false;
}
那么现在角色的基本操作就已经实现完毕了。
移动背景——横向卷轴:
我们现在需要考虑的是角色的以上功能会用在什么场景下,跳跃用来避开障碍物,攻击用来打倒怪物,可以是释放出一个强大的冲击波,滑板车就暂且当一个装饰吧。
但想到这些物体都是随着背景朝角色移动的,我们就先来利用横向卷轴实现背景的移动吧。
其原理很简单,就是两个相同的背景图像,并在一起。为了方便理解称左边的为A,右边的为B,A与B一起滚动,当A已经滚过摄像头时,让A赶紧跟在B的后面,继续参与滚动。
那么我们复制粘贴一下我们的背景出来。
调整之后,当A的水平坐标处于0,B的水平坐标处于40时,摄像机只有A的画面,A与B无缝衔接。那么说明在滚动过程中,如果A的水平坐标为-40,那么B的水平坐标就一定为0,这时间摄像机的画面是B,赶紧把A的坐标更改为40,那么前后两者之间的差依然是40,依然无缝衔接,我们简单的卷轴滚动就做好了。
实现的脚本如下。
public class Background_Roll : MonoBehaviour
{public float rollSpeed = 10.0f;void Update(){if (transform.position.x <= -40){transform.position = new Vector3(40, transform.position.y, transform.position.z);}transform.Translate(Vector2.left * rollSpeed * Time.deltaTime);}
}
当然平台的滚动也可以这样写,但是平台可能涉及到角色碰撞等因素,最好通过移动刚体来实现。这样的话记住不能冻结X轴上的位置,也要排除Platform层防止平台间发生碰撞。
public class Platform_Roll : MonoBehaviour
{public float rollSpeed = 10.0f;//因为涉及到碰撞器更新,使用需要移动刚体Rigidbody2D rb;void Start(){rb = GetComponent<Rigidbody2D>();}void Update(){if (transform.position.x <= -40){transform.position = new Vector3(40, transform.position.y, transform.position.z);}}void FixedUpdate(){rb.MovePosition(rb.position + Vector2.left * rollSpeed * Time.fixedDeltaTime);}
}
现在再来看看我们角色跑动的效果。
发现角色动不动就给我跳一下。这是因为A与B平台的碰撞器是分开,当离开一个平台的碰撞器时,就会触发跳跃。解决方法也十分简单,加入碰撞体的持续检测即可。
private void OnCollisionStay2D(Collision2D collision)
{if (collision.gameObject.layer == LayerMask.NameToLayer("Platform")){onPlatform = true;animator.SetBool("isJump", false);}
}
可以看到基本的效果已经有了。
生成障碍物:
现在该考虑角色要靠跳跃才能越过的障碍物了。
这里选择的是素材中的Mace与Saw图像。那么我们先来调整这两个障碍物的预制件,让他们以更动态的方式生成在场景中,便于后续随机生成。将Saw拖入场景,首先我们在资产下创建一个名为Saw的动画,用来编辑这个锯齿的动画,并将其附在场景中的Saw上,并同时生成了动画控制器。
双击该动画资产,就可以进行编辑了。我们点击右下角的“曲线”,一般默认的动画编辑数值改变是曲线的,我们让我们的锯齿持续转动我们需要设置为线性。然后在动画中添加Saw的旋转属性,将关键帧添加在30帧的位置,Z轴设置为360度,同下图一样设置为线性。
记住在检查其中将循环时间选中,这样我们的齿轮就可以转动起来了。
然后以同样的方式,为我们的Mace添加一上一下的动态效果。
但要注意,动画系统中位置的动画不能单独对一个轴编辑,所以为了防止动画系统对我们后来的物理系统产生影响,我们为Mace创建一个父物体,在后续,不管是制成预制件还是添加物理逻辑,都对这个父物体操作。
为二者添加合适的碰撞器以及刚体,冻结y轴位置以及旋转,排除Platform层,同时将这二者的标签更改为“Obstacle”,以便后续逻辑的进行。然后将这二者拖入资产当中作为预制件。然后可以将场景中的两个物体删掉了。
现在考虑如何将障碍物生成在场景中。这里使用C#中的标准队列结构将这两个预制件存放在对象池中,通过其先进先出的特性让物体能够回收利用,避免多次销毁,增加性能。将生成点设置在摄像机范围右侧,在生成的已经存在的物体消失后,生成下一个物体,达到效果。
创建一个名为“SpawnPoint”的空物体用来确定生成障碍物的位置,编写以下脚本挂载到这个生成点上。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Spawn_Obstacle : MonoBehaviour
{public GameObject obstacle1;public GameObject obstacle2;GameObject current;public float rollSpeed = 10.0f;//因为涉及到碰撞器更新,使用需要移动刚体Rigidbody2D rb;//创建对象池public Queue<GameObject> pool = new Queue<GameObject>();//初始化对象池void InitializePool(){//在池中创建六个对象for (int i = 0; i < 3; i++){GameObject obj1 = Instantiate(obstacle1);obj1.SetActive(false);pool.Enqueue(obj1);//放入队列GameObject obj2 = Instantiate(obstacle2);obj2.SetActive(false);pool.Enqueue(obj2);//放入队列}}//从对象池里获取对象public GameObject GetObject(){return pool.Dequeue();//娶一个队列里的元素}//回收对象public void ReturnObject(GameObject obj){obj.SetActive(false);pool.Enqueue(obj);}void Start(){InitializePool();current = GetObject();current.SetActive(true);rb = current.GetComponent<Rigidbody2D>();//让物体的位置在我们设置的生成点的位置rb.position = transform.position;}void Update(){//-20为摄像机范围左侧的少许距离if (current.transform.position.x <= -20){ReturnObject(current);current = GetObject();current.SetActive(true);rb = current.GetComponent<Rigidbody2D>();//让物体的位置在我们设置的生成点的位置rb.position = transform.position;}}private void FixedUpdate(){rb.MovePosition(rb.position + Vector2.left * rollSpeed * Time.fixedDeltaTime);}
}
将预制件拖入脚本组件,我们的障碍物的基本行为就做好了。
为了更便于角色撞到障碍物就死掉,我们将障碍物的碰撞器都设置为触发器。我们准备好角色的死亡动画,开始添加角色的死亡逻辑。注意这里的过渡不要选可以过渡到自己,会发生错误。
public class Player_Die : MonoBehaviour
{Animator animator;void Awake(){animator = GetComponent<Animator>();}private void OnTriggerEnter2D(Collider2D collision){if (collision.gameObject.CompareTag("Obstacle")){animator.SetBool("die", true);}}
}
现在我们的角色死亡也就安排好了。
生成敌人与攻击逻辑:
下一步要生成敌人了,为了简单起见,我们将敌人的图像同上文中的Mace一样,父物体添加碰撞器刚体,制为预制件,将标签改为“Monster”,因为原资产的文件怪物是朝左的,我们我们选择反转x轴。
然后在原本生成障碍物的脚本里添加几行,好让生成点交替生成怪物。
public GameObject obstacle1;public GameObject obstacle2;public GameObject monster;void InitializePool(){//在池中创建六个对象for (int i = 0; i < 2; i++){GameObject obj1 = Instantiate(obstacle1);obj1.SetActive(false);pool.Enqueue(obj1);//放入队列GameObject obj2 = Instantiate(obstacle2);obj2.SetActive(false);pool.Enqueue(obj2);//放入队列GameObject obj3 = Instantiate(monster);obj3.SetActive(false);pool.Enqueue(obj3);//放入队列}}
然后修改角色死亡的逻辑。
private void OnTriggerEnter2D(Collider2D collision)
{if (collision.gameObject.CompareTag("Obstacle") || collision.gameObject.CompareTag("Monster")){animator.SetBool("die", true);}
}
现在怪物就会在两个障碍物后出现。
现在我们考虑角色的攻击。毕竟我们手上没有现成的素材,不如让我们搓一个螺旋丸吧。创建一个空物体,放置在角色前方。在其下随意制作我们的武器,这里我随便搓了一个,并加上了和锯齿同款的旋转。
为这个武器添加名为“Weapon”的标签,用于处理后续攻击怪物的逻辑,添加圆形碰撞器。然后为怪物预制件添加如下脚本,记得加上怪物的死亡动画。
public class Monster_Die : MonoBehaviour
{Animator animator;void Awake(){//因为要挂载到父物体上,所以要获取子物体的动画器animator = GetComponentInChildren<Animator>();}private void OnTriggerEnter2D(Collider2D collision){if (collision.gameObject.CompareTag("Weapon")){animator.SetBool("die", true);//需要在生成点脚本中重新使其恢复Collider2D collider = GetComponent<Collider2D>();collider.enabled = false;}}
}
这样一来,我们的螺旋丸就可以将怪物击倒了。然后我们更改角色控制脚本,编写使用武器攻击的逻辑。
public GameObject weapon;
IEnumerator Attack()
{animator.SetBool("isAttack", true);weapon.SetActive(true);yield return new WaitForSeconds(0.8f);animator.SetBool("isAttack", false);weapon.SetActive(false);//等等一段时间后才能进行下一次攻击,相当于冷却时间yield return new WaitForSeconds(0.5f);isAttack = false;
}
现在我们攻击怪物的逻辑就做好了。
UI制作与重新开始:
现在我们基本的游戏内容就已经做好了,也该进入收尾阶段了,现在就该讲讲如何在角色死亡后如何重置我们的场景以及GUI布置。
先来实现重新开始的逻辑,因为我们这个简陋的小游戏并不需要保存状态什么的需求,所以直接通过重置整个场景即可。我们在场景中创建一个名为“GameManager”的空物体,专门用来挂载需要对游戏全局控制的脚本。然后挂载以下脚本。
public class GameManager : MonoBehaviour
{public void ResetScene(){//等一秒再重新开始StartCoroutine(ReloadWithFade());}IEnumerator ReloadWithFade(){yield return new WaitForSeconds(1f);SceneManager.LoadScene(SceneManager.GetActiveScene().name);}
}
由于我们的角色是和游戏结束关联的,所以需要在角色死亡脚本中添加我们GameManager类的实例。保存后将场景GameManager游戏对象拖入该脚本组件即可。
public class Player_Die : MonoBehaviour
{private void OnTriggerEnter2D(Collider2D collision){if (collision.gameObject.CompareTag("Obstacle") || collision.gameObject.CompareTag("Monster")){animator.SetBool("die", true);//游戏结束gameManager.ResetScene();}}
}
现在我们就完成了极其简单的场景重置。如果想要添加什么效果(如淡入淡出)可以直接在游戏管理脚本中的协程中添加。
现在开始添加GUI,我想让我们游戏一开始,角色处于待机状态,场景也没有变化,需要点击“开始游戏”以后才能够让场景与各种对象进行移动。那么将思路转换为具体方法,就是角色刚进入游戏时,需要禁用背景滚动脚本、平台滚动脚本、角色控制脚本,以及生成点脚本。
为了批量控制这些脚本,我们将所有目标脚本所挂载的游戏对象的标签更改为“ScriptControl”,然后修改GameManager脚本,禁用所有脚本,具体如下。
public class GameManager : MonoBehaviour
{GameObject[] targets;private void Awake(){//初始化所有的带有标签的物体targets = GameObject.FindGameObjectsWithTag("ScriptControl");DisableScripts();}//批量禁用脚本public void DisableScripts(){foreach (GameObject obj in targets){//禁用所有继承自MonoBehaviour脚本MonoBehaviour[] scripts = obj.GetComponents<MonoBehaviour>();foreach (MonoBehaviour script in scripts){if (script != null && script.enabled){script.enabled = false;//这个方法可以用来Debug是否真的禁用了Debug.Log($"已禁用 {obj.name} 上的 {script.GetType().Name}");}}}}public void ResetScene(){//等一秒再重新开始StartCoroutine(ReloadWithFade());}IEnumerator ReloadWithFade(){yield return new WaitForSeconds(1f);SceneManager.LoadScene(SceneManager.GetActiveScene().name);}
}
虽然这时检查我们的脚本确实被禁用了,但是会发现我们的控制台会持续出现报错。
查阅后得知,可以是因为OnCollisionStay2D等方法是Unity物理系统的回调,不受脚本启用状态影响。所以我们要同时禁用他们的碰撞器与刚体。
//批量禁用碰撞器和刚体public void DisableColliders(){foreach (GameObject obj in targets){//禁用所有碰撞器和刚体(防止人物掉落)if (obj.GetComponent<Collider2D>()){Rigidbody2D[] rbs = obj.GetComponents<Rigidbody2D>();foreach (Rigidbody2D rb in rbs){if (rb != null && rb.simulated){//Rigidbody2D特有的方法,用来定义刚体是否参与物理模拟rb.simulated = false;//这个方法可以用来Debug是否真的禁用了Debug.Log($"已禁用 {obj.name} 上的 {rb.GetType().Name}");}}Collider2D[] colliders = obj.GetComponents<Collider2D>();foreach (Collider2D collider in colliders){if (collider != null && collider.enabled){collider.enabled = false;//这个方法可以用来Debug是否真的禁用了Debug.Log($"已禁用 {obj.name} 上的 {collider.GetType().Name}");}}}}}
然后我们再将角色一开始的动画设置为“闲置”,即创建默认过渡为idel动画。
那么我们游戏一开始的场景就布置完成了。
现在就在我们的右半边屏幕布设GUI。在场景中创建一块画布。选择屏幕空间覆盖渲染,按屏幕大小缩放。在这个物体下创建文本,用来写我们的标题,字体格式用我们导入的资源。同时创建一个按钮,这个用来让我们开始游戏。
现在我们来编写按钮按下后触发的逻辑,那就是让我们刚刚禁用的脚本还有碰撞器刚体什么的统统恢复原状,还有要想之前一样,让角色的动画过渡到奔跑动画。对我们的GameManager脚本作如下修改。
//批量激活脚本public void EnableScripts(){foreach (GameObject obj in targets){//禁用所有继承自MonoBehaviour脚本MonoBehaviour[] scripts = obj.GetComponents<MonoBehaviour>();foreach (MonoBehaviour script in scripts){if (script != null && !script.enabled){script.enabled = true;}}}}//批量激活碰撞器和刚体public void EnableColliders(){foreach (GameObject obj in targets){//禁用所有碰撞器和刚体(防止人物掉落)if (obj.GetComponent<Collider2D>()){//先激活碰撞器Collider2D[] colliders = obj.GetComponents<Collider2D>();foreach (Collider2D collider in colliders){if (collider != null && !collider.enabled){collider.enabled = true;}}Rigidbody2D[] rbs = obj.GetComponents<Rigidbody2D>();foreach (Rigidbody2D rb in rbs){if (rb != null && !rb.simulated){//Rigidbody2D特有的方法,用来定义刚体是否参与物理模拟rb.simulated = true;}}}}}
然后在角色控制脚本中,添加如下代码。
public void startRun(){animator = GetComponent<Animator>();animator.SetBool("start", true);}
然后再在我们的按钮组件中,依次使用这些方法,再将画布禁用,好让我们的玩家回到游戏状态。
哦,不能忘了告诉玩家操作方法。为了手感舒适一点,我将跳跃改为了W键,将滑板车改为了S键,攻击改为了D键。将这些信息写在我们的UI界面中。
那么我们禁用脚本的方法当然也不是白写的,在角色死亡之后,可以让场景中的物体都停止移动一段时间,再加载我们的新场景。对加载新场景的协程作如下更改。
IEnumerator ReloadWithFade()
{DisableColliders();DisableScripts();yield return new WaitForSeconds(2f);SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
那么到现在我们的跑酷小游戏就大功告成了!
导出游戏:
终于到了激动人心的导出游戏时刻。点击生成设置。
在“玩家设置“中调整分辨率等参数后(一般默认就行),点击构建和运行,我们的游戏就到处完成了。
小结
当然,这款小游戏还有许多许多可以优化的地方,这种多层背景也可以做出像《空洞骑士》那样的景深效果,更有立体感;经典的设置生命值增加受伤次数,增加容错率;在到达一定时间后可以将场景切换为别的,更有新鲜感;在屏幕上方显示这一次跑酷坚持了多长时间,更有成就感;让障碍物与敌人随机生成,更有可玩性;甚至可以加入bgm,更具舒适感……这些方法的实践,我会陆续写在后续的开发笔记中,也会在github中同步更新,感兴趣的话请多多关注。
如有补充纠正,欢迎留言。