这可以算是一个关于unity的基础笔记,用于记录在实现一个简单的roguelike游戏的过程中遇到的问题与解决方法。
参考B站up@M_Studio的教程
这是一个基础的房间,包含一个绘制场景的Tilemap组件、四个门、一个用于标出房间范围的RoomArea对象。将这个房间作为预制体。
这里没有选择将房间连带着墙作为预制体,而是把墙单独拎出来,在生成房间后再在各个房间生成相应形状的墙
基本思路为:
1.首先在原点生成一个房间
2.在上下左右四个方向随机选择一个方向,间隔一段距离(xoffset或yoffset)再生成一个房间
3.若在预生成的位置上已存在一个房间,则不生成房间,但是基准点仍移动到这个房间上
4.持续步骤3、4,直到生成预定数量的房间时结束
如果选择回退算法,即在步骤3中若检测到目标位置已存在房间,则将基准点回退,选择其他三个方向进行生成,则有可能会导致生成房间进入一个死胡同,在四个方向上都存在已生成的房间。解决这个问题相对麻烦一些,而且生成的地图较为“线性”,所以舍弃了这种方法。
利用一个RoomGenerator对象,绑定脚本进行房间的生成。每生成一个房间,就将其放入一个列表,方便后续的操作。
// Start is called before the first frame update
void Start()
{
for (int i = 0; i < roomNumber; i++)
{
//生成对象,并将其放入列表
rooms.Add(Instantiate(roomPrefab, generatorPoint.position, Quaternion.identity).GetComponent<Room>()); //直接获取basicRoom的Room脚本
//改变point位置
ChangePointPos();
}
//上色(开始与结束)
rooms[0].GetComponent<SpriteRenderer>().color = startColor;
rooms[roomNumber-1].GetComponent<SpriteRenderer>().color = endColor;
//显示房间之间的门
foreach (var item in rooms)
{
SetupRoom(item, item.transform.position);
}
}
暂时将第一个生成的房间作为起始房间,最后一个房间作为结束房间。
在房间生成之后,进行门的生成。原则是:相邻的房间之间必有门。
给基础房间的预制体绑定脚本,规定如下参数,上面四个代表四个方向的门对象,下面四个表示是否存在门。(门对象的显示与隐藏就要根据后面四个参数确定)
上一部分代码中,对每个房间进行了如下操作,简单来说就是:
1.检测每个房间的四周是否有房间,并对参数作相应调整。
2.根据参数选择是否显示各个方向的门
3.根据参数为各个房间添加相应形状的围墙
//检测周围房间及数量
public void SetupRoom(Room newRoom,Vector3 roomPosition)
{
for (int i = 0; i < rooms.Count; i++)
{
for (int j = 0; j < 4; j++) //检测目标房间的四个方向是否存在房间
{
if(rooms[i].transform.position.x==roomPosition.x && rooms[i].transform.position.y == roomPosition.y + yOffset) //在上方
{
newRoom.roomUp = true;
}else if (rooms[i].transform.position.x == roomPosition.x && rooms[i].transform.position.y == roomPosition.y - yOffset) //下
{
newRoom.roomDown = true;
}
else if (rooms[i].transform.position.y == roomPosition.y && rooms[i].transform.position.x == roomPosition.x + xOffset) //右
{
newRoom.roomRight = true;
}
else if (rooms[i].transform.position.y == roomPosition.y && rooms[i].transform.position.x == roomPosition.x - xOffset) //左
{
newRoom.roomLeft = true;
}
}
}
······
(代码太长,省略了不重要的部分)
将所有形状的墙都分别做成一个预制体。其中还包括一个WallMap对象,包含碰撞体组件和刚体组件,用于检测玩家是否进入当前房间,从而对小地图的显示进行调整。
将各个形状的围墙作为预制体,绑定给RoomGenerator以便其调用生成。
小地图的基本原理就是:将一个摄像机放在一个能看到全图的高度,然后将画面传到屏幕的小地图显示区域。如果想看到简略版的地图,可以在摄像机上选择只映射某些图层。具体操作在这里不作啰嗦。
一个小技巧:若想要小地图的中心始终显示当前所在房间,只要将小地图摄像机放到MainCamera之下即可
为摄像机添加一个脚本CameraController:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
public static CameraController instance;
public float speed;
public Transform target;
private void Awake()
{
instance = this;
}
// Update is called once per frame
void Update()
{
if (target != null)
{
transform.position = Vector3.MoveTowards(transform.position, new Vector3(target.position.x, target.position.y, transform.position.z), speed * Time.deltaTime); //从一点移动到另一点
}
}
//更新目标
public void ChangeTarget(Transform newTarget)
{
target = newTarget;
}
}
然后在Room的脚本里加入:
//player进入时,将新房间的坐标给摄像机
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
CameraController.instance.ChangeTarget(transform);
}
}
使得Player进入房间时(碰撞到房间的碰撞体时),将新房间的坐标给摄像机,摄像机发现坐标改变则移动到新的房间。
暂时先设置这些组件,PlayerController脚本必不可少,需要注意的是,碰撞盒BoxCollider的范围只需要框定在人物的下部,因为这是一个俯视视角的2D游戏。
目前先制作站立Idle与移动Run两个动作,通过一个参数speed进行切换
动画切换的相关设置如下,像素游戏不需要过渡动画
对于动画参数,Player的脚本里一般这样设置:(这里为了方便下面的叙述,直接展示PlayerController里的全部代码)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerController : MonoBehaviour
{
Rigidbody2D rb;
Animator anim;
public GameObject littleMap;
public float speed;
Vector2 movement; //用一个二维向量控制人物移动
// Start is called before the first frame update
void Start()
{
//开始时获取自身的刚体组件与动画机组件
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
// Update is called once per frame
void Update()
{
movement.x = Input.GetAxisRaw("Horizontal"); //获取水平方向的运动命令
movement.y = Input.GetAxisRaw("Vertical"); //获取垂直方向的运动命令
if (movement.x != 0)
{
transform.localScale = new Vector3(movement.x,1,1); //保证人物朝向始终面对水平移动的方向
}
//保持动画的切换
SwitchAnim();
//打开与关闭小地图
if (Input.GetKeyDown(KeyCode.M))
{
if (littleMap.activeSelf == false)
{
littleMap.SetActive(true);
}
else
{
littleMap.SetActive(false);
}
}
}
private void FixedUpdate()
{
rb.MovePosition(rb.position + movement * speed * Time.fixedDeltaTime); //移动:位置=原位置+移动方向单位向量*速度*修正时间
}
void SwitchAnim()
{
anim.SetFloat("speed", movement.magnitude); //将速度传给动画控制机
}
}
可以看到,通过
anim.SetFloat("speed", movement.magnitude)
这个方法可以对动画机的控制参数进行赋值
通过一个二维向量movement记录移动命令,并通过如下方法保证人物朝向正确。
movement.x = Input.GetAxisRaw("Horizontal"); //获取水平方向的运动命令
movement.y = Input.GetAxisRaw("Vertical"); //获取垂直方向的运动命令
if (movement.x != 0)
{
transform.localScale = new Vector3(movement.x,1,1); //保证人物朝向始终面对水平移动的方向
}
利用FixedUpdate进行移动
private void FixedUpdate()
{
rb.MovePosition(rb.position + movement * speed * Time.fixedDeltaTime); //移动:位置=原位置+移动方向单位向量*速度*修正时间
}
至于为什么使用FixedUpdate,根据Unity用户手册:
FixedUpdate:调用 FixedUpdate 的频度常常超过 Update。如果帧率很低,可以每帧调用该函数多次;如果帧率很高,可能在帧之间完全不调用该函数。在 FixedUpdate 之后将立即进行所有物理计算和更新。在 FixedUpdate 内应用运动计算时,无需将值乘以 Time.deltaTime。这是因为 FixedUpdate 的调用基于可靠的计时器(独立于帧率)。
Update:每帧调用一次 Update。这是用于帧更新的主要函数。
导入攻击动画,并与Idle和Run动作建立转换关系,各自通过一个参数attacking进行切换。
参数的作用为:当按下攻击按键,参数变为true;当参数为true时,表示正在进行攻击动作;在攻击动作结束之后,调用EndAttack函数,将参数改为true
小技巧:为了保证攻击的连贯性,我们可以在动作还未播放完时就调用一个函数代表攻击结束,可以接受新的攻击命令,但是当前攻击动画仍在继续播放(后面会优化)。
参考B站视频:使用Unity实现动作游戏的打击感
public void EndAttack()
{
anim.SetBool("attacking", false);
anim.SetBool("attacking2", false);
anim.SetBool("attacking3", false);
}
若要在攻击的过程中保持面朝方向不改变,可以在改变方向的操作之前检查当前是否处于攻击状态,若是则直接return
if (anim.GetBool("attacking") || anim.GetBool("attacking2") || anim.GetBool("attacking3"))
{
return;
}
if (movement.x != 0)
{
transform.localScale = new Vector3(movement.x,1,1); //保证人物朝向始终面对水平移动的方向
}
利用随机数随机选择攻击动作
//攻击
if (Input.GetKeyDown(KeyCode.Mouse0))
{
randomAttack = Random.Range(0,3);
switch (randomAttack)
{
case 0:
anim.SetBool("attacking", true);
break;
case 1:
anim.SetBool("attacking2", true);
break;
case 2:
anim.SetBool("attacking3", true);
break;
}
}
基本方法为:
在人物前部放置一个攻击判定物(如下图白圆),在人物进行攻击动作时激活此物体,如果怪物碰撞到此物体则怪物受到攻击,在攻击动作结束时再关闭此物体。
同样地,怪物的普通近战攻击也是如此。
在一段动画中,我们可以插入事件,在动画进行到某一帧时调用事件对应的函数,如下图,在攻击动作的结束帧调用EndAttack函数,隐藏用于攻击判定的白色圆圈。事件调用函数只需要在人物脚本中以public的形式给出即可。
public void EndAttack()
{
anim.SetBool("attacking", false);
anim.SetBool("attacking2", false);
anim.SetBool("attacking3", false);
isAttacking = false;
}
以人物受伤判定为例。由于人物进行攻击时,往往距离怪物较近且怪物可能也同时在攻击,所以这里在进行攻击动作时不进行受伤判定,也就是攻击时相当于无敌。
//受伤
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("EnemyAttackRange") && !isAttacking)
{
if (health > 0)
{
anim.SetBool("isHitten", true);
health--;
}
}
}
目前制作的基础怪物包含以下两个子物体,用于移动控制和攻击判定。
怪物的动画制作与人物动画的制作过程完全一致,这里不再赘述。这里只展示怪物的基础移动与攻击逻辑。
void Update()
{
nowTime = Time.time;
if (nowTime - startTime >= 1) //每隔一秒改变一次移动方向
{
if (Mathf.Sqrt(Mathf.Pow(rb.transform.position.x - birth_x, 2) + Mathf.Pow(rb.transform.position.y - birth_y, 2)) <= activeDistance)
{
movement.x = Random.Range(-1f, 1f);
movement.y = Random.Range(-1f, 1f);
startTime = nowTime;
}
else //怪物超出活动范围则向出生点移动
{
movement.x = birth_x - rb.transform.position.x;
movement.y = birth_y - rb.transform.position.y;
startTime = nowTime;
}
}
//保证人物朝向始终面对水平移动的方向
if (movement.x > 0)
{
transform.localScale = new Vector3(1, 1, 1);
}else if (movement.x < 0)
{
transform.localScale = new Vector3(-1, 1, 1);
}
//玩家在探测范围内时,追踪玩家
if(Mathf.Sqrt(Mathf.Pow(rb.transform.position.x - player.transform.position.x, 2) + Mathf.Pow(rb.transform.position.y - player.transform.position.y, 2)) <= detectDistance)
{
movement.x = player.transform.position.x - rb.transform.position.x;
movement.y = player.transform.position.y - rb.transform.position.y;
}
//玩家在攻击范围内时,攻击玩家
if(Mathf.Sqrt(Mathf.Pow(rb.transform.position.x - player.transform.position.x, 2) + Mathf.Pow(rb.transform.position.y - player.transform.position.y, 2)) <= attackDistance)
{
attackNowTime = Time.time;
if (attackNowTime-attackStartTime > 1)
{
anim.SetBool("isAttacking", true);
attackStartTime=attackNowTime;
}
}
}
以及怪物的受击反馈:
//怪物受到攻击
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("PlayerAttackRange"))
{
health--;
if (health <= 0)
{
anim.SetBool("isDead", true);
}
else
{
anim.SetBool("isHitten", true);
isHurt = true;
movement.x = rb.transform.position.x - player.transform.position.x;
movement.y = rb.transform.position.y - player.transform.position.y;
rb.velocity = new Vector2(movement.x , movement.y );
}
}
}
小技巧:为了增强怪物的受击反馈,我们可以给怪物添加上受击的特效,绑定在怪物身上当受击时显示并开始播放;此外,还可以添加攻击停顿效果,使用协程实现:
目前的思路是使用一个SoundManager对象用来集成各种音乐的播放。
创建一个空对象,取名为SoundManager,然后添加一个AudioSource组件,绑定一个脚本。
在脚本中,我们要获取到自身对象的AudioSource组件,然后获取要播放的AudioClip。再创建一个自身的静态类,利用静态类进行音频的播放。我们可以根据需要播放音频的不同来给audioSource.clip赋不同的值,以达到播放不同音频的效果。
public class SoundManager : MonoBehaviour
{
public static SoundManager instance;
public AudioSource audioSource;
public AudioClip attackAudio1, attackAudio2, hurtAudio, dieAudio;
private void Awake()
{
instance = this;
}
// Start is called before the first frame update
public void playAttackAudio()
{
int temp = Random.Range(0, 2);
if (temp == 0)
{
audioSource.clip = attackAudio1;
}else if (temp == 1)
{
audioSource.clip = attackAudio2;
}
audioSource.Play();
}
}
Post Processing视觉效果
直接看这个吧!反正不需要代码!
至此,包含简单战斗的游戏基础框架已经基本完成,接下来是一些重要的模块,比如:背包系统、物品系统等等。