提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- 一、制作游戏第二个BOSS燥郁的毛里克
- 1.导入素材和制作相关动画
- 1.5处理玩家受到战吼相关行为逻辑处理
- 2.制作相应的行为控制和生命系统管理
- 3.制作战斗场景和战斗门
- 4.制作BOSS死亡行为
- 总结
前言
hello大家好久没见,之所以隔了这么久才更新并不是因为我又放弃了这个项目,而是接下来要制作的工作太忙碌了,每次我都花了很长的时间解决完一个部分,然后就没力气打开CSDN写文章就直接睡觉去了,现在终于有时间整理下我这半个月都做了什么内容
废话少说,接下来我将介绍我做的第二个BOSS苍蝇之母,因为我上一期制作的苍蝇之母反响还不错,大晚上的还有一百来人在看,那么直接乘胜追击制作第二个BOSS燥郁的毛里克。
另外,我的Github已经更新了,想要查看最新的内容话请到我的Github主页下载工程吧:
GitHub - ForestDango/Hollow-Knight-Demo: A new Hollow Knight Demo after 2 years!
一、制作游戏第二个BOSS燥郁的毛里克
1.导入素材和制作相关动画
还是先制作好tk2dsprite和tk2dspriteanimator吧:
可能你已经注意到了,提供的tk2dsprite有毛里克的不同躯干,没错,我们要做的就是类似于骨骼动画一样的,每一个部分处理对应部分的行为,在这里我们分为四个部分:分别是左右手臂,嘴巴,身体,先来制作隐藏状态下的动画:
然后是手臂:
身体方面的动画:
头部动画:
还有就是作为整体的跳跃动画:
作为整体的喷射子弹动画:
作为整体的战吼动画:
死亡动画;
1.5处理玩家受到战吼相关行为逻辑处理
可能你看到这标题不知道我想表达什么,其实这也是我自己打这个BOSS的时候突然发现的,就是玩家在面临强大的BOSS时,会被BOSS的气场给震住,被迫失去控制的朝向BOSS,然后BOSS发射阵阵战吼冲击波,其实你看到下图小骑士的动画你就明白是什么了:
然后给小骑士一个playmakerFSM叫“Roar Lock”来实现相关行为:
初始阶段找到特效文件夹和里面需要的特效:
通过Tag名字叫 Roar的判断是否进入下一个状态
判断是否允许进入Roar行为:
玩家锁定状态下,发送事件,同时RelinquishControl取消控制以及AffectedByGravity受到重力影响,停止播放其他动画,使玩家朝向敌人
自定义playmakerFSM脚本:
using System;
using UnityEngine;
namespace HutongGames.PlayMaker.Actions
{
[ActionCategory(ActionCategory.Logic)]
[Tooltip("Tests if all the given Bool Variables are are equal to thier Bool States.")]
public class BoolTestMulti : FsmStateAction
{
[RequiredField]
[UIHint(UIHint.Variable)]
[Tooltip("This must be the same number used for Bool States.")]
public FsmBool[] boolVariables;
[RequiredField]
[Tooltip("This must be the same number used for Bool Variables.")]
public FsmBool[] boolStates;
public FsmEvent trueEvent;
public FsmEvent falseEvent;
[UIHint(UIHint.Variable)]
public FsmBool storeResult;
public bool everyFrame;
public override void Reset()
{
boolVariables = null;
boolStates = null;
trueEvent = null;
falseEvent = null;
storeResult = null;
everyFrame = false;
}
public override void OnEnter()
{
DoAllTrue();
if (!everyFrame)
{
Finish();
}
}
public override void OnUpdate()
{
DoAllTrue();
}
private void DoAllTrue()
{
if (boolVariables.Length == 0 || boolStates.Length == 0)
{
return;
}
if (boolVariables.Length != boolStates.Length)
{
return;
}
bool flag = true;
for (int i = 0; i < boolVariables.Length; i++)
{
if(boolVariables[i].Value != boolStates[i].Value)
{
flag = false;
break;
}
}
storeResult.Value = flag;
if (flag)
{
Fsm.Event(trueEvent);
return;
}
Fsm.Event(falseEvent);
}
}
}
看看是否要翻转玩家X方向:
处理粒子系统相关:
判断玩家是否在地面:
在空中锁定:
在地面被锁定:
等待发送ROAR EXIT事件:玩家重新获得输入和动画控制
取消粒子播放效果:回到Detect状态中。
2.制作相应的行为控制和生命系统管理
终于来到我们最爱的处理BOSS相应行为的时候,但在此之前还是把该要的组件导入来:
然后再来看看它有什么子对象,首先自然是它的身体部件了,首先介绍头部:
然后是左右臂:
还有一个子对象Attack Range,给左右两只手检测玩家是否进入攻击范围的:
右手同理:
接着是身体部分:
Boss的警戒范围:Alert Range New
然后是一些粒子系统:
喷射效果:
一个简单的playmakerFSM,叫你PLAY的时候你再play:
我们先来看看毛里克的头部是怎么控制行为的,其实也很简单,就是不停的喷子弹就完事了,
这个特别说明的变量是喷射速度,你可以根据情况自己调:
初始化阶段就是获得玩家,获得自己孩子和父母
初始化完整后就进入待苏醒阶段:
苏醒阶段:设置攻击间隔为0.3-0.6秒之间
喷射准备阶段:播放动画,等一下会
检测玩家位置:
开喷!
这里有个预制体叫做Shot Mawlek No Drip,也就是毛里克头部发射的子弹,
记得添加DamageHero.cs脚本,这样它才能伤害到玩家
这里有一个新脚本叫:EnemyBullet.cs,代码段如下:主要分为Trigger碰到玩家,以及Collision碰到Terrian层级的墙壁和地面的行为
using System.Collections;
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class EnemyBullet : MonoBehaviour
{
public float scaleMin = 1.15f;
public float scaleMax = 1.45f;
private float scale;
[Space]
public float stretchFactor = 1.2f;
public float stretchMinX = 0.75f;
public float stretchMaxY = 1.75f;
[Space]
public AudioSource audioSourcePrefab;
public AudioEvent impactSound;
private bool active;
private Rigidbody2D body;
private tk2dSpriteAnimator anim;
private Collider2D col;
private void Awake()
{
body = GetComponent<Rigidbody2D>();
anim = GetComponent<tk2dSpriteAnimator>();
col = GetComponent<Collider2D>();
}
private void OnEnable()
{
active = true;
scale = Random.Range(scaleMin, scaleMax);
col.enabled = true;
body.isKinematic = false;
body.velocity = Vector2.zero;
body.angularVelocity = 0f;
anim.Play("Idle");
}
private void Update()
{
if (active)
{
float rotation = Random.Range(body.velocity.y,body.velocity.x) * 57.295776f;
transform.SetRotation2D(rotation);
float num = 1f - body.velocity.magnitude * stretchFactor * 0.01f;
float num2 = 1f + body.velocity.magnitude * stretchFactor * 0.01f;
if (num2 < stretchMinX)
{
num2 = stretchMinX;
}
if (num > stretchMaxY)
{
num = stretchMaxY;
}
num *= scale;
num2 *= scale;
transform.localScale = new Vector3(num2, num, transform.localScale.z);
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (active)
{
active = false;
StartCoroutine(Collision(collision.GetSafeContact().Normal, true));
}
}
private void OnTriggerEnter2D(Collider2D collision)
{
if(active && collision.tag == "HeroBox")
{
active = false;
StartCoroutine(Collision(Vector2.zero, false));
}
}
public void OrbitShieldHit(Transform shield)
{
if (active)
{
active = false;
Vector2 normal = transform.position - shield.position;
normal.Normalize();
StartCoroutine(Collision(normal, true));
}
}
private IEnumerator Collision(Vector2 normal, bool doRotation)
{
transform.localScale = new Vector3(scale, scale, transform.localScale.z);
body.isKinematic = true;
body.velocity = Vector2.zero;
body.angularVelocity = 0f;
tk2dSpriteAnimationClip impactAnim = anim.GetClipByName("Impact");
anim.Play(impactAnim);
if (!doRotation || (normal.y >= 0.75f && Mathf.Abs(normal.x) < 0.5f))
{
transform.SetRotation2D(0f);
}
else if (normal.y <= 0.75f && Mathf.Abs(normal.x) < 0.5f)
{
transform.SetRotation2D(180f);
}
else if (normal.x >= 0.75f && Mathf.Abs(normal.y) < 0.5f)
{
transform.SetRotation2D(270f);
}
else if (normal.x <= 0.75f && Mathf.Abs(normal.y) < 0.5f)
{
transform.SetRotation2D(90f);
}
impactSound.SpawnAndPlayOneShot(audioSourcePrefab, transform.position);
yield return null;
col.enabled = false;
yield return new WaitForSeconds((impactAnim.frames.Length - 1) / impactAnim.fps);
Destroy(gameObject);//TODO:
}
}
我们把它的tk2dSprite和tk2dSpriteAnimator完成一下吧:
最后再加个子对象,给它一点亮光:
然后我们来处理它的身体Dummy行为:没啥好说的,就是攻击的时候身体flash发光一下
所以它需要SpriteFlash.cs脚本。
然后是它的左右手行为处理:这里我以左手为例
我们通过SetProperty开启左右手臂的MeshRenderer
在待苏醒阶段我们肯定要关闭它的Collider2d,否则你待苏醒阶段突然给玩家扣了一滴血玩家肯定要喷你的
判断是否进入攻击距离:
准备攻击:
攻击阶段:这时候就到了开启Collider2d的时候了
然后再攻击冷却阶段再关上collider2d
准备下一次攻击,回到Idle状态。
然后右手臂也同理,我就不贴出来了。
处理完各个部分的行为逻辑,接下来就是整体的逻辑行为处理了:
看到下面这两个变量你可能会好奇,我头部不是已经设置了Shot Speed了吗,怎么这里还有,这是因为头部的那个只能一次喷一颗子弹,而这个是作为整体开大招喷半个屏幕的子弹的,两者还是有差别的。
初始化阶段:找自己,找孩子,停止自己移动,播放动画
休眠阶段:播放动画,如果是神居里的BOSS,不用等WAKE事件直接苏醒,否则就要等到Alert Range New发送WAKE事件
回到Alert Range New,和上一期讲到的一样,还是等到Trigger2d检测到后发送WAKE事件给父对象
苏醒阶段:给Battle Scene发送事件START,假身Dummy播放动画
苏醒跳跃阶段: 给正Y轴一个向上的速度
苏醒在空中阶段:改变Z轴大小,检测是否落地面
苏醒落地阶段:
介绍BOSS阶段,但我还没做到这里,所以直接跳过
苏醒战吼阶段:向小骑士发送事件ROAR ENTER,设置Roar Object为自己
战吼结束阶段:向小骑士发送事件ROAR EXIT
音乐起:
相关代码如下:
using System;
using UnityEngine;
using UnityEngine.Audio;
[CreateAssetMenu(fileName = "MusicCue", menuName = "Hollow Knight/Music Cue", order = 1000)]
public class MusicCue : ScriptableObject
{
[SerializeField] private string originalMusicEventName;
[SerializeField] private int originalMusicTrackNumber;
[SerializeField] private AudioMixerSnapshot snapshot;
[SerializeField]
[ArrayForEnum(typeof(MusicChannels))]
private MusicCue.MusicChannelInfo[] channelInfos;
[SerializeField] private MusicCue.Alternative[] alternatives;
public string OriginalMusicEventName
{
get
{
return originalMusicEventName;
}
}
public int OriginalMusicTrackNumber
{
get
{
return originalMusicTrackNumber;
}
}
public AudioMixerSnapshot Snapshot
{
get
{
return snapshot;
}
}
public MusicChannelInfo GetChanelInfo(MusicChannels channel)
{
if (channel < MusicChannels.Main || channel >= (MusicChannels)channelInfos.Length)
{
return null;
}
return channelInfos[(int)channel];
}
public MusicCue ResolveAlternatives(PlayerData playerData)
{
if (alternatives != null)
{
int i = 0;
while (i < alternatives.Length)
{
MusicCue.Alternative alternative = alternatives[i];
if (playerData.GetBool(alternative.PlayerDataBoolKey))
{
MusicCue cue = alternative.Cue;
if (!(cue != null))
{
return null;
}
return cue.ResolveAlternatives(playerData);
}
else
{
i++;
}
}
}
return this;
}
[Serializable]
public class MusicChannelInfo
{
[SerializeField] private AudioClip clip;
[SerializeField] private MusicChannelSync sync;
public AudioClip Clip
{
get
{
return clip;
}
}
public bool IsEnabled
{
get
{
return clip != null;
}
}
public bool IsSyncRequired
{
get
{
if(sync == MusicChannelSync.Implicit)
{
return clip != null;
}
return sync == MusicChannelSync.ExplicitOn;
}
}
}
[Serializable]
public struct Alternative
{
public string PlayerDataBoolKey;
public MusicCue Cue;
}
}
using UnityEngine;
using System;
namespace HutongGames.PlayMaker.Actions
{
[ActionCategory(ActionCategory.Audio)]
[ActionTarget(typeof(MusicCue), "musicCue", false)]
[Tooltip("Plays music cues.")]
public class ApplyMusicCue : FsmStateAction
{
[Tooltip("Music cue to play.")]
[ObjectType(typeof(MusicCue))]
public FsmObject musicCue;
[Tooltip("Delay before starting transition")]
public FsmFloat delayTime;
[Tooltip("Transition duration.")]
public FsmFloat transitionTime;
public override void Reset()
{
musicCue = null;
delayTime = 0f;
transitionTime = 0f;
}
public override void OnEnter()
{
MusicCue x = musicCue.Value as MusicCue;
GameManager instance = GameManager.instance;
if (!(x == null))
{
if (instance == null)
{
Debug.LogErrorFormat(Owner, "Failed to play music cue, because the game manager is not ready", Array.Empty<object>());
}
else
{
instance.AudioManager.ApplyMusicCue(x, delayTime.Value, transitionTime.Value, false);
}
}
Finish();
}
}
}
然后假身利用完了就设置为空白的动画 ,此时就从整体变成各个分部,自身设置为Body Idle动画
等个2到3秒钟准备开大招:
那么到底要到什么时候才能进入开大招阶段呢?你总不可能一边左右手攻击一边飞起来吧,所以我打算让它们三分之二同时满足条件,也就是active同时激活的时候才进入开大招阶段
关闭头部collider,同时经典二选一:
作为整体拥有两种攻击行为,一是吐半个屏幕的子弹,还有一种是跳到玩家头上,这里我先介绍前一种:
又是判断是否重复执行同一种攻击行为多次,和判断玩家位置
设置好假身大小朝向:
喷半个屏幕行为:这里的SLEEP事件是发送给是自己的其他分体部件的playmakerFSM上的,就是当整体在执行行为的时候其他部分是不能动的
喷射子弹
大招结束后的冷却时间:
这里我给了四分之一的概率是否重复释放大招
重复执行大招:
否则的话回到Start状态重新 WAKE身体的各个部件,然后从此整体又变成了各个部件
然后再来看看跳跃攻击行为:
判断玩家方向:
超级大跳行为状态:
在空中行为状态:Collision判断是否落地
落地行为状态:
然后还要回到起跳点的位置:
然后就是二段跳阶段:我们在前面记录的第一次起跳前x方向的位置,然后我们就故技重施重新调回去就好了,别忘了来点粒子效果和音效啥的
至此我们完成了一个完整的BOSS相应的行为控制
3.制作战斗场景和战斗门
我觉得战斗门就不用我多说了,因为我上一期已经讲过了,直接预制体一拖然后设置好位置就OK,
我们重点是制作战斗场景,首先创建同名,设置好tag,创建playmakerFSM名字叫:Battle Control
这个heart piece就是游戏的面具碎片,由于我还没做到所以先空着不管:
如果是已经击败过的BOSS就进入activate状态,生成面具碎片以及效果boss房的camera lock
战斗开始后,设置好Camera lock和敌人数量为1,还有就是关门
战斗结束后设置为激活状态,等个五秒多:
结束后记得开门
4.制作BOSS死亡行为
最后我们还要制作BOSS的尸体:
首先我们来制作BOSS尸体爆炸后变成一堆躯干:
还有一些躯干掉落后的橙汁蒸汽之类的:
我们先隐藏好上述的躯干,回到主体当中,需要rb2d和box2d:同是别忘了设置好layer
它的playmaker也很简单,跟我们上期讲到苍蝇之母一样:
告诉battle Scene战斗结束的事件:BATTLE END
初始化生成好波澜:
冒气Steam阶段和上面的差不多:
准备阶段:
最后阶段:boss尸体爆炸,生成躯干和果冻:
果冻就是这个orange Glob:
我们先把它的tk2dsprite和tk2dspriteanimator做好:
这里为什么有两个collider2d呢?因为我要一个来判断果冻是否落地,另一个判断玩家是否用骨钉攻击它:
这个是用来判断有无敌人踩到它:踩到了就执行动画wobbleAnim
攻击它后的效果:
我们来写一个脚本名字就叫:
GlobControl.cs
using System.Collections;
using UnityEngine;
[RequireComponent(typeof(Collider2D))]
public class GlobControl : MonoBehaviour
{
public Renderer rend;
[Space]
public float minScale = 0.6f;
public float maxScale = 1.6f;
[Space]
public string landAnim = "Glob Land";
public string wobbleAnim = "Glob Wobble";
public string breakAnim = "Glob Break";
[Space]
public AudioSource audioPlayerPrefab;
public AudioEvent breakSound;
public Color bloodColorOverride = new Color(1f, 0.537f, 0.188f);
[Space]
public GameObject splatChild;
[Space]
public Collider2D groundCollider;
private bool landed;
private bool broken;
private tk2dSpriteAnimator anim;
private void Awake()
{
anim = GetComponent<tk2dSpriteAnimator>();
}
private void OnEnable()
{
float num = Random.Range(minScale, maxScale);
transform.localScale = new Vector3(num, num, 1f);
if (splatChild)
{
splatChild.SetActive(false);
}
landed = false;
broken = false;
}
private void Start()
{
CollisionEnterEvent collision = GetComponent<CollisionEnterEvent>();
if (collision)
{
collision.OnCollisionEnteredDirectional += delegate (CollisionEnterEvent.Direction direction, Collision2D col)
{
if (!landed)
{
if(direction == CollisionEnterEvent.Direction.Bottom)
{
landed = true;
collision.doCollisionStay = false;
if (CheckForGround()) //检测是否碰到地面
{
anim.Play(landAnim);
return;
}
StartCoroutine(Break());
return;
}
else
{
collision.doCollisionStay = true;
}
}
};
}
TriggerEnterEvent componentInChildren = GetComponentInChildren<TriggerEnterEvent>();
if (componentInChildren)
{
componentInChildren.OnTriggerEntered += delegate (Collider2D col, GameObject sender)
{
if (!landed || broken)
{
return;
}
if (col.gameObject.layer == LayerMask.NameToLayer("Enemies"))
{
anim.Play(wobbleAnim); //检测如果敌人碰到了执行动画wobbleAnim
}
};
}
}
private void OnTriggerEnter2D(Collider2D col)
{
if (!landed || broken)
{
return;
}
if (col.tag == "Nail Attack") //如果是骨钉攻击,执行break函数
{
StartCoroutine(Break());
return;
}
if (col.tag == "HeroBox") //如果是玩家碰到了执行wobble的动画
{
anim.Play(wobbleAnim);
}
}
private IEnumerator Break()
{
broken = true;
breakSound.SpawnAndPlayOneShot(audioPlayerPrefab, transform.position);
GlobalPrefabDefaults.Instance.SpawnBlood(transform.position, 4, 5, 5f, 20f, 80f, 100f, new Color?(bloodColorOverride));
if (splatChild)
{
splatChild.SetActive(true); //生成一些效果和子对象splatChild
}
yield return anim.PlayAnimWait(breakAnim);
if (rend)
{
rend.enabled = false;
}
yield break;
}
private bool CheckForGround()
{
if (!groundCollider)
{
return true;
}
Vector2 vector = groundCollider.bounds.min;
Vector2 vector2 = groundCollider.bounds.max;
float num = vector2.y - vector.y;
vector.y = vector2.y;
vector.x += 0.1f;
vector2.x -= 0.1f;
RaycastHit2D raycastHit2D = Physics2D.Raycast(vector, Vector2.down, num + 0.25f, LayerMask.GetMask("Terrain"));
RaycastHit2D raycastHit2D2 = Physics2D.Raycast(vector2, Vector2.down, num + 0.25f, LayerMask.GetMask("Terrain"));
return raycastHit2D.collider != null && raycastHit2D2.collider != null;
}
}
别忘了回到编辑器添加好参数:
总结
至此我们完成了制作的第二个BOSS燥郁的毛里克,就在这里展示一下成果吧:
(顺便提一下我把血量设置太高了没打过,只能自己打二周目了)
这里我的spit speed设置太低了,因为我后面改了一下游戏的重力大小,所以喷不远,你们记得根据实际情况来填参数:
右边的爪子也没有问题:
这里没生成躯干是因为不知道为什么我的相机不会渲染的sprite-default的material了,所以没办法只能换个material:
OK终于做完一期了,刚好最近有空整理一下最近做的内容。