提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- 一、战斗场景Battle Scene相关逻辑处理
- 1.防止玩家走出战斗场景的门
- 2.制作一个简单的战斗场景
- 二、制作游戏第一个BOSS苍蝇之母
- 1.导入素材和制作相关动画
- 2.制作相应的行为控制和生命系统管理
- 3.制作BOSS尸体Corpse
- 总结
前言
hello大家好久没见,之所以隔了这么久才更新并不是因为我又放弃了这个项目,而是接下来要制作的工作太忙碌了,每次我都花了很长的时间解决完一个部分,然后就没力气打开CSDN写文章就直接睡觉去了,现在终于有时间整理下我这半个月都做了什么内容
废话少说,接下来我将介绍我做的第一个BOSS苍蝇之母,其实你看到这个名字可能想不到对应的是游戏里的哪一个BOSS,没事你往下看就知道了,因为我忘了它叫什么名字了好像叫格鲁兹啥的,那么本期内容分为两个部分:一是制作战斗场景Battle Scene,二是制作游戏第一个BOSS苍蝇之母。
另外,我的Github已经更新了,想要查看最新的内容话请到我的Github主页下载工程吧:
GitHub - ForestDango/Hollow-Knight-Demo: A new Hollow Knight Demo after 2 years!
一、制作战斗场景Battle Scene相关逻辑处理
1.制作防止玩家走出战斗场景的门
我们在玩空洞骑士的时候会遇到一些遭遇战,除了进入BOSS房间的时候会有这种门的关闭,我们在一些特定关卡也会遇到这种关,所以在介绍第一个BOSS之前,我们先来做这个战斗场景的逻辑处理:
OK首先我们把门的素材导入tk2dSprtie和tk2dSpriteAnimator当中,
然后我们来先创建好那个特定的场景,每个萌新都知道就是会打两波阿斯匹德的场景:
现在我们做好了场景就该把我们的敌人拖入指定的位置,我们创建一个名字叫Battle Scene的,先给它制作好collider2d,根据设定当玩家进入这个trigger后就相当于进入战斗场景
然后它还有两个子对象Wave1,Wave2分别代表两波攻势
Wave1自然就是场景中央的这只阿斯匹德了:
Wave2则是我们提前生成好放在地图外边的阿斯匹德:
或许你看到上张图就会提问:你怎么创建了一个空的阿斯匹德,原因是在空洞骑士中这里第二波会有两个小蜜蜂看似从场景外飞进来的,说以需要制作从颜色黑色到白色这种视觉效果,而真正的本地则是地图外的这两只阿斯匹德:在完成视觉动画后我们就瞬间让它们进入动画结尾的位置,达到完美的视觉过渡效果:
然后我们先来把门的逻辑处理给完成了:
如图所示做好碰撞箱,音效器,sprite和animator,然后我们还要做一些粒子系统啥的:
然后制作playmakerFSM来处理相关逻辑行为:
初始阶段这些门肯定都是Opened,不然你关上了玩家咋进去:
Collider关掉,等待Battle Scene发送BG Opened,Closed的事件后切换阶段:
关上第一阶段Close 1:这时候就要打开Collider和播放动画和音效了:
第二阶段:
等待发送BG OPEN进入Open阶段后:
还有没有延迟的迅速关闭Quick Close和Quick Open的:
直接摧毁:
完成后做好预制体,拖到地图相应的位置,这里我们需要四个门:
2.制作一个简单的战斗场景
然后就到了制作战斗场景Battle Scene的时候了,还是用我们最爱的playmakerFSM来制作:
激活阶段就是说这个战斗场景玩家已经战胜了,就不会执行重复的战斗关卡 了:
检测玩家是否就位:
第一波攻势阶段:Battle Enemies就是我们总共要战胜多少个敌人,然后向我们上面制作的门的playmakerFSM发送BG CLOSE事件,直到 Battle Enemies少了一个也就是第一波敌人清理干净了就进入到下一波
等个1.5秒到下一波:
第二波阶段:向第二波的敌人发送事件SUMMON产生视觉效果,直到Battle Enemies为0就进入下一个阶段
等个2秒中:
取消场景摄像机锁定,设置为激活状态,向门播放事件BG OPEN
怎么样?什么是不是感觉还挺简单的,逻辑也好理解,但是有个疑问,这个battle scene怎么知道battle enemies已经死了呢,这就要留给enemies公共的脚本:也就是HealthManager.cs来实现了:我们在Die函数被调用后定位到battle scene的playmakerFSM,然后找到变量battle enemies给它减一下,这样就实现了:
using System;
using System.Collections;
using HutongGames.PlayMaker;
using UnityEngine;
using UnityEngine.Audio;
public class HealthManager : MonoBehaviour, IHitResponder
{
[Header("Scene")]
[SerializeField] private GameObject battleScene;
public void Die(float? attackDirection, AttackTypes attackType, bool ignoreEvasion)
{
if (isDead)
{
return;
}
if (sprite)
{
sprite.color = Color.white;
}
FSMUtility.SendEventToGameObject(gameObject, "ZERO HP", false);
if (hasSpecialDeath)
{
NonFatalHit(ignoreEvasion);
return;
}
isDead = true;
if(damageHero != null)
{
damageHero.damageDealt = 0;
}
if(battleScene != null && !notifiedBattleScene)
{
PlayMakerFSM playMakerFSM = FSMUtility.LocateFSM(battleScene, "Battle Control");
if(playMakerFSM != null)
{
FsmInt fsmInt = playMakerFSM.FsmVariables.GetFsmInt("Battle Enemies");
if(fsmInt != null)
{
fsmInt.Value--;
notifiedBattleScene = true;
}
}
}
if (deathAudioSnapshot != null)
{
deathAudioSnapshot.TransitionTo(6f);
}
if (sendKilledTo != null)
{
FSMUtility.SendEventToGameObject(sendKilledTo, "KILLED", false);
}
if(attackType == AttackTypes.Splatter)
{
GameCameras.instance.cameraShakeFSM.SendEvent("AverageShake");
Debug.LogWarningFormat(this, "Instantiate!", Array.Empty<object>());
//TODO:
Instantiate<GameObject>(corpseSplatPrefab, transform.position + effectOrigin, Quaternion.identity);
if (enemyDeathEffects)
{
enemyDeathEffects.EmitSound();
}
Destroy(gameObject);
return;
}
if(attackType != AttackTypes.RuinsWater)
{
float angleMin = megaFlingGeo ? 65 : 80;
float angleMax = megaFlingGeo ? 115 : 100;
float speedMin = megaFlingGeo ? 30 : 15;
float speedMax = megaFlingGeo ? 45 : 30;
int num = smallGeoDrops;
int num2 = mediumGeoDrops;
int num3 = largeGeoDrops;
bool flag = false;
if(GameManager.instance.playerData.equippedCharm_24 && !GameManager.instance.playerData.brokenCharm_24)
{
num += Mathf.CeilToInt(num * 0.2f);
num2 += Mathf.CeilToInt(num2 * 0.2f);
num3 += Mathf.CeilToInt(num3 * 0.2f);
flag = true;
}
GameObject[] gameObjects = FlingUtils.SpawnAndFling(new FlingUtils.Config
{
Prefab = smallGeoPrefab,
AmountMin = num,
AmountMax = num,
SpeedMin = speedMin,
SpeedMax = speedMax,
AngleMin = angleMin,
AngleMax = angleMax
},base.transform,effectOrigin);
if (flag)
{
SetGeoFlashing(gameObjects, smallGeoDrops);
}
gameObjects = FlingUtils.SpawnAndFling(new FlingUtils.Config
{
Prefab = mediumGeoPrefab,
AmountMin = num2,
AmountMax = num2,
SpeedMin = speedMin,
SpeedMax = speedMax,
AngleMin = angleMin,
AngleMax = angleMax
}, transform, effectOrigin);
if (flag)
{
SetGeoFlashing(gameObjects, mediumGeoDrops);
}
gameObjects = FlingUtils.SpawnAndFling(new FlingUtils.Config
{
Prefab = largeGeoPrefab,
AmountMin = num3,
AmountMax = num3,
SpeedMin = speedMin,
SpeedMax = speedMax,
AngleMin = angleMin,
AngleMax = angleMax
}, transform, effectOrigin);
if (flag)
{
SetGeoFlashing(gameObjects, largeGeoDrops);
}
}
if (enemyDeathEffects != null)
{
if (attackType == AttackTypes.Generic)
{
enemyDeathEffects.doKillFreeze = false;
}
enemyDeathEffects.RecieveDeathEvent(attackDirection, deathReset, attackType == AttackTypes.Spell, false);
}
SendDeathEvent();
Destroy(gameObject); //TODO:
}
}
接下来我们来做第二波攻势的两个阿斯匹德的视觉效果:
来波位置和Scale 的平替:
效果我会放到总结处,接下来我们直接来个大的,制作我们这个系列的第一个BOSS——苍蝇之母。
二、制作游戏第一个BOSS苍蝇之母
1.导入素材和制作相关动画
我们先来制作tk2dsprite和tk2dspriteanimator,看到这里你应该这个是什么BOSS了
然后就来制作好场景:
动画有点多,我就一张一张贴出来吧,不然可能大伙看不懂什么意思;
然后就到了五件套的时候了:
除此之外,我们还要预先做的一个笼子,把BOSS死后从肚子里生成的7只苍蝇关起来,等BOSS死后生成在BOSS的位置上:
记得给这7只都添加好Battle Scene:
2.制作相应的行为控制和生命系统管理
首先我们来制作boss睡觉阶段生成的粒子系统Snore:
它的状态也很简单,就是播放粒子系统,让boss在循环播放睡眠动画的时候再设置好粒子系统的emission排放量:
看到右上角的ID:47没?我们就在播放Sleep的最后一帧设置排放量为40,否则设置为0.
再来把它的剩下两个子对象给完成了:
这里还要设置好string变量,就是拿来判断玩家是否在范围内的。
回到主体中,我们来制作boss相应的行为控制,当然还是用playmakerFSM来实现的:
初始阶段:除了初始化一些变量外,我们还需要额外判断这是否是神居里面的BOSS,当然我不可能做那么快,所以肯定不会发送这个事件的,但我们先写个雏形:
using HutongGames.PlayMaker;
[ActionCategory("Hollow Knight/GG")]
public class GGCheckIfBossScene : FsmStateAction
{
public FsmEvent bossSceneEvent;
public FsmEvent regularEvent;
public override void Reset()
{
bossSceneEvent = null;
regularEvent = null;
}
public override void OnEnter()
{
Fsm.Event(regularEvent);
base.Finish();
}
}
直到受到角色骨钉伤害TAKE DAMAGE事件才切换到Wake Sound阶段:
这里本来有个介绍敌人名字的叫Area Title预制体,但我还没做到UI所以先放着,向相机发送事件抖动(别急我还没讲到先放着),然后向battle scene发送START事件,播放动画设置初始速度
下一个阶段就播放Fly动画然后处理音效相关:
先等个2秒多然后就到了决定状态的时候了:
然后就到了二选一攻击行为阶段 了:这个boss有两种攻击方式,分别是冲向玩家charge和上下撞击slam,
我们先来将charge攻击行为:
准备阶段:还是判断是否连续三次使用了这个招式,朝向玩家
冲刺攻击阶段:然后还要检测哪个方向碰到墙或地面的层级了:
从被左边碰撞的恢复阶段:播放音效,生成效果,设置反方向的速度,等待0.3秒进入下一个阶段
其他三个方向都是同理的:
恢复结束阶段:
结束攻击阶段:播放会Fly动画播放音效等会回到Buzz状态
再来看看另一个攻击行为Slam上下撞击:还是连续行为判断,然后朝向玩家,获取玩家方向和距离,自己再抖动一小点距离,设置好撞击持续时间slam time等待进入下一个阶段。
自定义playmakerFSM内容:
using UnityEngine;
namespace HutongGames.PlayMaker.Actions
{
[ActionCategory(ActionCategory.Transform)]
[Tooltip("Jitter an object around using its Transform.")]
public class ObjectJitter : RigidBody2dActionBase
{
[RequiredField]
[Tooltip("The game object to translate.")]
public FsmOwnerDefault gameObject;
[Tooltip("Jitter along x axis.")]
public FsmFloat x;
[Tooltip("Jitter along y axis.")]
public FsmFloat y;
[Tooltip("Jitter along z axis.")]
public FsmFloat z;
[Tooltip("If true, don't jitter around start pos")]
public FsmBool allowMovement;
private float startX;
private float startY;
private float startZ;
public override void Reset()
{
gameObject = null;
x = new FsmFloat
{
UseVariable = true
};
y = new FsmFloat
{
UseVariable = true
};
z = new FsmFloat
{
UseVariable = true
};
}
public override void OnPreprocess()
{
Fsm.HandleFixedUpdate = true;
}
public override void OnEnter()
{
GameObject ownerDefaultTarget = base.Fsm.GetOwnerDefaultTarget(gameObject);
if (ownerDefaultTarget == null)
{
return;
}
startX = ownerDefaultTarget.transform.position.x;
startY = ownerDefaultTarget.transform.position.y;
startZ = ownerDefaultTarget.transform.position.z;
}
public override void OnFixedUpdate()
{
DoTranslate();
}
private void DoTranslate()
{
GameObject ownerDefaultTarget = Fsm.GetOwnerDefaultTarget(gameObject);
if (ownerDefaultTarget == null)
{
return;
}
if (allowMovement.Value)
{
ownerDefaultTarget.transform.Translate(Random.Range(-x.Value, x.Value), Random.Range(-y.Value, y.Value), Random.Range(-z.Value, z.Value));
return;
}
Vector3 position = new Vector3(startX + Random.Range(-x.Value, x.Value), startY + Random.Range(-y.Value, y.Value), startZ + Random.Range(-z.Value, z.Value));
ownerDefaultTarget.transform.position = position;
}
}
}
向上发射阶段,也就是第一次撞击是向上还是向下:
向下发射阶段:
还是根据撞击的方向决定下一次撞击是往上还是往下,还有就是到达战斗区域的边缘后转向
向下撞击:
向上撞击:
Slam结束阶段:设置好速度为0,播放动画slam end,减速,等0.几秒回到super end阶段
其实这样下来感觉这个BOSS的难度还没上一期制作的龙牙哥难度高,攻击行为也是两种,唯一不同的是接下来的死亡尸体需要制作了,
3.制作BOSS尸体Corpse
老规矩拖入Coillider,Sprite和animator:
初始阶段先判断好尸体的Scale有没有问题,因为我们是放大了1.25倍的
找到Battle Scene的playmakerFSM的变量Activated,设置为激活状态,证明玩家已经战胜了这一个BOSS
然后到了生成玩家死亡的特效:
这里为什么我关掉了Set Particle Emission Speed这个行为呢,是因为我才发现这个版本的Unity 不能够在外面通过代码设置粒子系统的speed播放速度,所以只能这样了
准备进入爆炸阶段:
超级大爆阶段Blow:
销毁自己:
然后我们这里生成了第二个尸体预制体就是Corpse Big Fly Burster,这里就是控制生成小苍蝇之类的:
先来看看粒子系统:
一个小小的动画;
死亡后不断上升的蒸汽粒子系统:
然后是主体,这里需要rigibody2d来落地和检测地面了:
新脚本Corpse.cs来处理玩家死亡相关行为的:
using System;
using System.Collections;
using UnityEngine;
public class Corpse : MonoBehaviour
{
private States state;
protected MeshRenderer meshRenderer;
protected tk2dSprite sprite;
protected tk2dSpriteAnimator spriteAnimator;
protected SpriteFlash spriteFlash;
protected Rigidbody2D body;
protected Collider2D bodyCollider;
[SerializeField] protected ParticleSystem corpseFlame;
[SerializeField] protected ParticleSystem corpseSteam;
[SerializeField] protected GameObject landEffects;
[SerializeField] protected AudioSource audioPlayerPrefab;
[SerializeField] protected GameObject deathWaveInfectedPrefab;
[SerializeField] protected GameObject spatterOrangePrefab;
[SerializeField] private AudioEvent startAudio;
[SerializeField] private bool resetRotaion;
[SerializeField] private bool massless;
[SerializeField] private bool instantChunker;
[SerializeField] private bool breaker;
private bool noSteam;
protected bool spellBurn;
protected bool hitAcid;
private float landEffectsDelayRemaining;
private void Awake()
{
meshRenderer = GetComponent<MeshRenderer>();
sprite = GetComponent<tk2dSprite>();
spriteAnimator = GetComponent<tk2dSpriteAnimator>();
spriteFlash = GetComponent<SpriteFlash>();
body = GetComponent<Rigidbody2D>();
bodyCollider = GetComponent<Collider2D>();
}
public void Setup(bool noSteam, bool spellBurn)
{
this.noSteam = noSteam;
this.spellBurn = spellBurn;
}
protected virtual void Start()
{
startAudio.SpawnAndPlayOneShot(audioPlayerPrefab, transform.position);
if (resetRotaion)
{
transform.SetRotation2D(0f);
}
if(noSteam && corpseSteam != null)
{
corpseSteam.gameObject.SetActive(false);
}
if (spellBurn)
{
if(sprite != null)
{
sprite.color = new Color(0.19607843f, 0.19607843f, 0.19607843f, 1f);
}
if(corpseFlame != null)
{
corpseFlame.Play();
}
}
if (massless)
{
state = States.DeathAnimation;
}
else
{
state = States.InAir;
if(spriteAnimator != null)
{
tk2dSpriteAnimationClip clipByName = spriteAnimator.GetClipByName("Death Air");
if(clipByName != null)
{
spriteAnimator.Play(clipByName);
}
}
}
if (instantChunker && !breaker)
{
Land();
}
StartCoroutine(DisableFlame());
}
protected void Update()
{
if(state == States.DeathAnimation)
{
if(spriteAnimator == null || !spriteAnimator.Playing)
{
Complete(true, true);
return;
}
}
else if(state == States.InAir)
{
if (transform.position.y < -10f)
{
Complete(true, true);
return;
}
}
else if(state == States.PendingLandEffects)
{
landEffectsDelayRemaining -= Time.deltaTime;
if(landEffectsDelayRemaining <= 0f)
{
Complete(false,false);
}
}
}
private void Complete(bool detachChildren, bool destroyMe)
{
state = States.Complete;
enabled = false;
if (corpseSteam != null)
{
corpseSteam.Stop();
}
if (corpseFlame != null)
{
corpseFlame.Stop();
}
if (detachChildren)
{
transform.DetachChildren();
}
if (destroyMe)
{
Destroy(gameObject);
}
}
protected void OnCollisionEnter2D(Collision2D collision)
{
OnCollision(collision);
}
protected void OnCollisionStay2D(Collision2D collision)
{
OnCollision(collision);
}
private void OnCollision(Collision2D collision)
{
if(state == States.InAir)
{
Sweep sweep = new Sweep(bodyCollider, 3, 3, 0.1f);
float num;
if(sweep.Check(transform.position,0.08f,LayerMask.GetMask("Terrain"),out num))
{
Land();
}
}
}
private void Land()
{
if (breaker)
{
}
else
{
if(spriteAnimator != null && !hitAcid)
{
tk2dSpriteAnimationClip clipByName = spriteAnimator.GetClipByName("Death Land");
if(clipByName != null)
{
spriteAnimator.Play(clipByName);
}
}
landEffectsDelayRemaining = 1f;
if(landEffects != null)
{
landEffects.SetActive(true);
}
state = States.PendingLandEffects;
if (!hitAcid)
{
LandEffects();
}
}
}
protected virtual void LandEffects()
{
}
private IEnumerator DisableFlame()
{
yield return new WaitForSeconds(5f);
if (corpseFlame)
{
corpseFlame.Stop();
}
}
private enum States
{
NotStarted,
InAir,
DeathAnimation,
Complete,
PendingLandEffects
}
}
然后用playmakerFSM来控制它的相关行为:
首先是变量,原来我上面数错数了,要生成8只小苍蝇
初始化阶段:
生成50块钱(这里我们还没讲到这么快所以先放着)
检测是否碰到地面了:
生成效果和播放动画,苍蝇之母开始挣扎:
进入停止动画阶段:
播放苍蝇之母腹部蠕动阶段:
第二次腹部蠕动:
第三次腹部蠕动:
播放爆炸动画:
这里为什么有个叫CHINESE的事件,没办法为了过审是要这样的,如果是CHINESE build就直接隐藏爆后的尸体:先暂时设置不是CHINESE build
using System;
namespace HutongGames.PlayMaker.Actions
{
public class CheckIsChineseBuild : FSMUtility.CheckFsmStateAction
{
public override bool IsTrue
{
get
{
return false;
}
}
}
}
生成橙汁:
爆橙汁阶段:
激活所有的小苍蝇并设置到boss尸体的位置上:
最后我们来制作battle scene:
检测知道苍蝇之母向他发送START事件:
没有音乐:
开始的时候设置好战斗敌人数量和向门发送BG CLOSE,每帧检测是否敌人数量已经小于等于0了
等个2秒钟,发送开门事件并设置好激活状态。
然后就是两个门,跟本篇文章上半边的battle gate一模一样直接拿来用然后设置好位置即可
总结
OK我们先来看看上半段的效果:
进入Battle Scene后:
击败第一波敌人后:
突然出现两个敌人:视觉效果
击败两个敌人后:
门就开了
然后再来看看BOSS战:
首先是睡觉阶段:粒子系统播放没问题的
A它一下后:苏醒然后门关上
第一个攻击行为:
第二个攻击行为:
转身:
死亡后直接开爆:
爆金币加死亡落地:
腹部蠕动:
超级大爆阶段:生成小苍蝇
全部battle enemies清理完成后,等2秒门就开了:
至此我们制作了一个反正我比较满意的原版第一个BOSS,它已经是我尽力复刻原版的高度了,喜欢的小伙伴赶紧来github下载demo然后点开Crossroads_04场景去体验一番吧!