Forum begins after the advertisement:
[General] Creating a King Bible Weapon
Home › Forums › Video Game Tutorial Series › Creating a Rogue-like Shoot-em Up in Unity › [General] Creating a King Bible Weapon
- This topic has 4 replies, 2 voices, and was last updated 2 months, 1 week ago by kyle.
-
AuthorPosts
-
October 13, 2024 at 9:38 pm #16051::Update 15 October 2024 – Fixed KingBibleWeapon.cs being incomplete, thanks to user Grim Rubbish for pointing this out!Hi all! Recently, viewer NHZC suggested in the comments of one of the Vampire Survivors tutorials that we make a forum post implementing the King Bible into our game, so here’s a step-by-step guide on creating a basic King Bible Weapon. A showcase of the King Bible weapon we created destroying some evil flowers.
Before we start, ensure that you’ve completed the series all the way up to Creating a Rogue-like (like Vampire Survivors) in Unity — Part 18: Implementing a Dynamic Stats System and UI, as we’ve made major improvements to it such as character stats affecting the weapon stats and much more.
Here’s what we will be covering.
1. Creating the King Bible Prefab 2. Creating the KingBibleWeapon.cs script 3. Creating the King Bible Weapon Data 4. Creating the King Bible Character (Dommario) 5. Changing the Weapon.cs and Projectile.cs script 6. Creating the KingBibleProjectile.cs script
1. Creating the King Bible Prefab.
To start off, let’s first make the Bible Prefab and add its VFX.- Create an empty GameObject in your scene and name it “King Bible Projectile”. Add a
Sprite Renderer
component to it and drag the sprite you’d like to use for your Bible (I usedWeapon Sprite Sheet_47
from the default Weapon Spritesheet in our project.) - Next, add a
Particle System
component. We’ll recreate the pages falling out effect from the original game. - For the first few settings, here’s the suggested configurations. Of course, you can tweak these values however you want.
- Duration – 3
- Looping – on
- Start Lifetime – 0.3
- Start Speed – 1
- Start Size – 0.75
- Start Rotation – 15
- Simulation Space – World (Allows the particles to move freely around after being emitted)
- Scaling Mode – Hierarchy (Scales the particles with the scale of the Game Object)
- Next, we’ll go over to “Emission” and set the Rate over Time to 3.5.
- Going onto “Shape”, we’ll set the Shape to Edge, set the Radius to 0.0001, and set the Z-rotation to 45.
- After that, we’ll apply a fade over time using “Color over Lifetime”. Click on the Gradient Editor and set the Alpha at the end to 0.
- To replace the default particle with the falling page sprite, let’s tick “Texture Sheet Animation” and drop it down. We’ll set the mode to Sprites and add the ‘Paper’ sprite into the slot.
- To end off the VFX, go to “Renderer” and configure these following settings. Remember to also configure the Order in Layer properly so the particles do not get blocked!
- Min Particle Size – 0
- Max Particle Size – 99
- Render Alignment – Local
- After that, add a
Rigidbody2D
component, and set the Body Type to Kinematic. Finally, add aBox Collider
, check the Is Trigger box, and resize it to fit the sprite.
2. Creating the KingBibleWeapon.cs script.
Here, we’ll set up the controller that will fire the projectiles. We’re going to create a new C# script calledKingBibleWeapon.cs
. Here’s the script with an explanation further below:using System.Collections; using System.Collections.Generic; using UnityEngine; public class KingBibleWeapon : ProjectileWeapon { public void SpawnRing(PlayerStats player = null) { // Only activate this if the player is present. if (player) { float angleOffset = 2 * Mathf.PI / Mathf.Max(1, currentStats.number); // 2 * Mathf.PI = 360. We are just calculating how far of an angle to space out the prefabs from each other on a circle's circumference. float currentAngle = 0; for (int i = 0; i < currentStats.number; i++) { // Convert the spawn angle onto a point on the circle's circumference and space it away relative to the player. Vector3 spawnPosition = player.transform.position + new Vector3( GetArea() * Mathf.Cos(currentAngle) , GetArea() * Mathf.Sin(currentAngle) ); // Spawn the book at the calculated position, and parents it to the owner so it follows them around. Projectile prefab = Instantiate(currentStats.projectilePrefab, spawnPosition, Quaternion.identity,owner.transform); prefab.owner = owner; currentAngle += angleOffset; prefab.weapon = this; } } } protected override bool Attack(int attackCount = 1) { // If no projectile prefab is assigned, leave a warning message. if (!currentStats.projectilePrefab) { Debug.LogWarning(string.Format("Projectile prefab has not been set for {0}", name)); ActivateCooldown(true); return false; } // Can we attack? if (!CanAttack()) return false; SpawnRing(owner); ActivateCooldown(true); attackCount--; // Do we perform another attack? if (attackCount > 0) { currentAttackCount = attackCount; currentAttackInterval = ((WeaponData)data).baseStats.projectileInterval; } return true; } public override bool ActivateCooldown(bool strict = false) { // When 'strict' is enabled and the cooldown is not yet finished, // do not refresh the cooldown. if (strict && currentCooldown > 0) return false; // Calculate what the cooldown is going to be, factoring in the cooldown // reduction stat in the player character. // Cooldown is dependent on lifespan, so we add it. float actualCooldown = (currentStats.lifespan + currentStats.cooldown) * Owner.Stats.cooldown; // Limit the maximum cooldown to the actual cooldown, so we cannot increase // the cooldown above the cooldown stat if we accidentally call this function // multiple times. currentCooldown = Mathf.Min(actualCooldown, currentCooldown + actualCooldown); return true; } public override bool DoLevelUp() { base.DoLevelUp(); // Spawn a ring after every upgrade option, just like in the original game. SpawnRing(owner); ActivateCooldown(false); return true; } }
- We’re going to make it inherit from the
ProjectileWeapon
class, since it’s a weapon that uses Projectiles. This means we can override some functions to make the King Bible work as intended.
The
SpawnRing()
function- This function spawns the evenly spaced out King Bible projectiles in a circle. There is some math involved, so here’s a quick rundown of the function:
- First off, we’ll check if there’s a
player
we can attach the spawned projectiles to. - We calculate
angleOffset
to find out the space we need between each projectile. - After that, we get the distance from the Player’s transform to the point on the circle’s circumference where the projectile is spawned using
Mathf.Cos(currentAngle)
andMathf.Sin(currentAngle)
and multiplying each of them by the radius (GetArea()
). - With the math done, we instantiate the assigned prefab at the calculated spawnPosition, and set its parent to be the Player who owns the weapon, so it follows them wherever they go.
- We then increment the
currentAngle
by theangleOffset
to space the projectiles evenly, and repeat the process until the correct amount of projectiles has been spawned.
- First off, we’ll check if there’s a
How the Math in
If you want to know how the circular spawning inSpawnRing()
works…SpawnRing()
works, here’s how. Although it looks a bit daunting, if you have done basic trigonometry before, it’ll be easy to follow through.- The
angleOffset
formula is 2 * Pi divided by the amount of projectiles the weapon spawns. 2 * Pi is just a way of representing 360° in radian form. - If we have 3 projectiles, the
angleOffset
of each projectile in degrees would be 360°/3 = 120°. In radian form, 120° is about 2.094 radians. angleOffset
needs to be in radians as the next functions we’ll use,Mathf.Cos()
andMathf.Sin()
take in radian values only.- Here’s where the trigonometry comes in. The explanation is quite long, so you can check this illustrated Imgur gallery out!
The Overriden
Attack()
function- The main thing we’ve changed is instead of just spawning 1 projectile prefab, we make it call the
SpawnRing()
function instead to spawn multiple King Bibles. We’ve also removed theGetSpawnAngle()
call as the projectiles aren’t shot-based.
The Overriden
ActivateCooldown()
function- We’ve overriden the
ActivateCooldown()
function to factor in the lifespan of the projectile, since according to its Vampire Survivors Wiki Page, its cooldown is extended by its duration (lifespan). - Because of this, you’ll notice if your duration stat is high enough, you’ll be able to spawn more than one layer of King Bibles at once, which also happens in the original game!
The Overriden
DoLevelUp()
function- If you’ve noticed in the original game, whenever you upgrade the King Bible, a new ring with the new stats spawns, so we’ll just spawn in a new ring with
SpawnRing()
and reset the cooldown afterwards.
3. Creating the King Bible Weapon Data
Here, we’ll set up the Weapon Data Scriptable Object that will give our King Bible its stats.- Navigate to your
Scriptable Objects>Weapons
folder, right-click and go toCreate>2D Top-Down Rogue-Like>Weapon Data
. Rename the newly created Scriptable Object to “King Bible”. - Set the Behaviour to “KingBibleWeapon” and you should be able to configure your King Bible accordingly. Here’s the wiki page for it if you’d like to have the exact stats.
4. Creating the King Bible Character (Dommario)
Now that we’ve got our King Bible’s Weapon Data set, we can create the character that mains it.- Go to your
Scriptable Objects>Characters
folder, right-click and go toCreate>2D Top-Down Rogue-Like>Character Data
. - Configure the character by assigning its Starting Weapon to be the “King Bible” scriptable object, and setting the stats accordingly. If you’d like to configure the character according to his Vampire Survivors counterpart, you can refer to his Wiki page.
5. Changing the Projectile.cs and Weapon.cs script
Before we move on to adding a Projectile script for the King Bible, we need to modify theWeapon.cs
andProjectile.cs
scripts.Changes to the
Just copy and paste these 2 functions into yourWeapon.cs
scriptWeapon.cs
script.public virtual float GetSpeed() { return currentStats.speed * owner.Stats.speed; } public virtual float GetLifespan() { return currentStats.lifespan * owner.Stats.duration; }
- The new functions,
GetSpeed()
andGetLifespan()
, retrieve the speed and lifespan stats multiplied by any stat boosts that the owner has.
Changes to the
Projectile.cs
scriptusing UnityEngine; /// <summary> /// Component that you attach to all projectile prefabs. All spawned projectiles will fly in the direction /// they are facing and deal damage when they hit an object. /// </summary> [RequireComponent(typeof(Rigidbody2D))] public class Projectile : WeaponEffect { public enum DamageSource { projectile, owner }; public DamageSource damageSource = DamageSource.projectile; public bool hasAutoAim = false; public Vector3 rotationSpeed = new Vector3(0, 0, 0); protected Rigidbody2D rb; protected int piercing; protected float area; // Start is called before the first frame update protected virtual void Start() { rb = GetComponent<Rigidbody2D>(); Weapon.Stats stats = weapon.GetStats(); if (rb.bodyType == RigidbodyType2D.Dynamic) { rb.angularVelocity = rotationSpeed.z; rb.velocity = transform.right * weapon.GetSpeed(); } // Prevent the area from being 0, as it hides the projectile. area = weapon.GetArea(); if (area <= 0) area = 1; transform.localScale = new Vector3( area * Mathf.Sign(transform.localScale.x), area * Mathf.Sign(transform.localScale.y), 1 ); // Set how much piercing this object has. piercing = stats.piercing; // Destroy the projectile after its lifespan expires. if (weapon.GetLifespan() > 0) Destroy(gameObject, weapon.GetLifespan()); // If the projectile is auto-aiming, automatically find a suitable enemy. if (hasAutoAim) AcquireAutoAimFacing(); } // If the projectile is homing, it will automatically find a suitable target // to move towards. public virtual void AcquireAutoAimFacing() { float aimAngle; // We need to determine where to aim. // Find all enemies on the screen. EnemyStats[] targets = FindObjectsOfType<EnemyStats>(); // Select a random enemy (if there is at least 1). // Otherwise, pick a random angle. if (targets.Length > 0) { EnemyStats selectedTarget = targets[Random.Range(0, targets.Length)]; Vector2 difference = selectedTarget.transform.position - transform.position; aimAngle = Mathf.Atan2(difference.y, difference.x) * Mathf.Rad2Deg; } else { aimAngle = Random.Range(0f, 360f); } // Point the projectile towards where we are aiming at. transform.rotation = Quaternion.Euler(0, 0, aimAngle); } // Update is called once per frame protected virtual void FixedUpdate() { // Only drive movement ourselves if this is a kinematic. if (rb.bodyType == RigidbodyType2D.Kinematic) { Weapon.Stats stats = weapon.GetStats(); transform.position += transform.right * stats.speed * weapon.Owner.Stats.speed * Time.fixedDeltaTime; rb.MovePosition(transform.position); transform.Rotate(rotationSpeed * Time.fixedDeltaTime); } } protected virtual void OnTriggerEnter2D(Collider2D other) { EnemyStats es = other.GetComponent<EnemyStats>(); BreakableProps p = other.GetComponent<BreakableProps>(); // Only collide with enemies or breakable stuff. if (es) { // If there is an owner, and the damage source is set to owner, // we will calculate knockback using the owner instead of the projectile. Vector3 source = damageSource == DamageSource.owner && owner ? owner.transform.position : transform.position; // Deals damage. es.TakeDamage(GetDamage(), source); // Get the weapon's stats. Weapon.Stats stats = weapon.GetStats(); weapon.ApplyBuffs(es); // Apply all assigned buffs to the target. // Reduce the piercing value, and destroy the projectile if it runs of out of piercing. piercing--; if (stats.hitEffect) { Destroy(Instantiate(stats.hitEffect, transform.position, Quaternion.identity), 5f); } } else if (p) { p.TakeDamage(GetDamage()); piercing--; Weapon.Stats stats = weapon.GetStats(); if (stats.hitEffect) { Destroy(Instantiate(stats.hitEffect, transform.position, Quaternion.identity), 5f); } } // Destroy this object if it has run out of health from hitting other stuff. if (piercing <= 0) Destroy(gameObject); } }
- We’ve moved the
area
variable out ofStart()
and made it protected, so we can use it outside ofbase.Start()
in any inherited classes. - We’ve replaced
stats.speed
andstats.lifespan
with theGetSpeed()
andGetLifespan()
functions.
6. Creating the KingBibleProjectile.cs script.
Now, all that’s left is to create a script to give our King Bible Projectiles functionality. Since the King Bible is quite a unique projectile, we’ll be overriding and modifying quite a bit of code. Here’s the script with an explanation further below.using System.Collections; using System.Collections.Generic; using UnityEngine; public class KingBibleProjectile : Projectile { Dictionary<EnemyStats, float> affectedTargets = new Dictionary<EnemyStats, float>(); List<EnemyStats> targetsToUnaffect = new List<EnemyStats>(); public float hitDelay = 1.7f; //We multiply this with the base speed, as the default values by themselves rotate too slowly. public float speedMultiplier = 5f; // We need these multipliers for area since we scale the radius and projectile size differently. // If we don't, we will have really big books as the radius increases which is not the case in-game. public float radiusMultiplier = 1.1f; public float projectileSizeMultiplier = 0.9f; //How much time does it take for the projectile to grow/shrink to its needed size public float transitionTime = 0.5f; private float currentLifespan; Vector3 startScale; float startLifespan; bool isAlive = false; private float angle; // Start is called before the first frame update protected override void Start() { base.Start(); startScale = new Vector3(area, area,1); startLifespan = weapon.GetLifespan(); transform.localScale = Vector3.zero; StartCoroutine(BibleGrow()); Vector3 offset = transform.position - owner.transform.position; //Get the initial spawn position angle = Mathf.Atan2(offset.y, offset.x); // Get the angle in radians } protected override void FixedUpdate() { if (rb && rb.bodyType == RigidbodyType2D.Kinematic) { float x = owner.transform.position.x + Mathf.Cos(angle) * startScale.x * radiusMultiplier; float y = owner.transform.position.y + Mathf.Sin(angle) * startScale.y * radiusMultiplier; rb.MovePosition(new Vector3(x, y, 0)); angle -= weapon.GetStats().speed * speedMultiplier * Time.fixedDeltaTime; } } private void Update() { HitDelay(); currentLifespan += Time.deltaTime; if (!rb) { float x = owner.transform.position.x + Mathf.Cos(angle) * startScale.x * radiusMultiplier; float y = owner.transform.position.y + Mathf.Sin(angle) * startScale.y * radiusMultiplier; transform.position = new Vector3(x, y, 0); angle -= weapon.GetStats().speed * speedMultiplier * Time.deltaTime; } if (currentLifespan > startLifespan-transitionTime && isAlive) { StartCoroutine(BibleShrink()); isAlive = false; } if (!weapon && isAlive) { StartCoroutine(BibleShrink()); isAlive = false; } } public void HitDelay() { Dictionary<EnemyStats, float> affectedTargsCopy = new Dictionary<EnemyStats, float>(affectedTargets); // Loop through every target that has been hit by this projectile, and reduce the cooldown // of the projectile for it. If the cooldown reaches 0, deal damage to it. foreach (KeyValuePair<EnemyStats, float> pair in affectedTargsCopy) { if (pair.Key) { Vector3 source = damageSource == DamageSource.owner && owner ? owner.transform.position : transform.position; affectedTargets[pair.Key] -= Time.deltaTime; if (pair.Value <= 0) { if (targetsToUnaffect.Contains(pair.Key)) { // If the target is marked for removal, remove it. affectedTargets.Remove(pair.Key); targetsToUnaffect.Remove(pair.Key); } else { // Reset the cooldown and deal damage. Weapon.Stats stats = weapon.GetStats(); affectedTargets[pair.Key] = hitDelay; pair.Key.TakeDamage(GetDamage(), source, stats.knockback); weapon.ApplyBuffs(pair.Key); // Apply all assigned buffs to the target. // Play the hit effect if it is assigned. if (stats.hitEffect) { Destroy(Instantiate(stats.hitEffect, pair.Key.transform.position, Quaternion.identity).gameObject, 5f); } piercing--; } } } } } public IEnumerator BibleShrink() { Vector3 currentScale = transform.localScale; // Waits for a single frame. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0; // This is a loop that fires every frame. while (t < transitionTime) { yield return w; t += Time.deltaTime; // Reduce the current size to 0 within transitionTime transform.localScale = new Vector3(currentScale.x - (t/ transitionTime), currentScale.y - (t / transitionTime), 1f); } if (!weapon) Destroy(gameObject); } public IEnumerator BibleGrow() { if (!isAlive) { isAlive = true; // Waits for a single frame. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0; // This is a loop that fires every frame. while (t < transitionTime) { yield return w; t += Time.deltaTime; // Grow the size from 0 to the scale from the weapon's area multiplied by the projectileSizeMultiplier, within the transitionTime. transform.localScale = new Vector3(0 + (t/ transitionTime * startScale.x) * projectileSizeMultiplier, 0 + (t/ transitionTime * startScale.y) * projectileSizeMultiplier, 1f); } } //Destroy(gameObject); } protected override void OnTriggerEnter2D(Collider2D other) { if (other.TryGetComponent(out EnemyStats es)) { // If the target is not yet affected by this bible, add it // to our list of affected targets. if (!affectedTargets.ContainsKey(es)) { // Always starts with an interval of 0, so that it will get // damaged in the next Update() tick. affectedTargets.Add(es, 0); } } else if (other.TryGetComponent(out BreakableProps p)) { p.TakeDamage(GetDamage()); piercing--; Weapon.Stats stats = weapon.GetStats(); if (stats.hitEffect) { Destroy(Instantiate(stats.hitEffect, transform.position, Quaternion.identity).gameObject, 5f); } } // Destroy this object if it has run out of health from hitting other stuff. if (piercing <= 0) Destroy(gameObject); } }
- We’re going to make it inherit from the
Projectile.cs
class so we can easily change the core functionality through overriding.
Variable Overview
affectedTargets
andtargetsToUnaffect
– Used in implementing a hit delay system.hitDelay
– Used to prevent enemies from being hit by the same Bible projectile while the delay is active.speedMultiplier
– Multiplied with the speed stat to control how fast the Bible projectiles revolves around the player. Without this stat, the base speed of 1 is too slow for a revolution.radiusMultiplier
andprojectileSizeMultiplier
– We apply these multipliers to the area stat to control the distance of the Bibles from the Player and the Bible size respectively. We need different multipliers for each as having the same scaling for both radius and Bible size would lead to really big Bibles further down the upgrade chain.transitionTime
is how long it takes for the Bible projectiles to scale up to their size/shrink and disappear during the spawning and lifespan ending phases respectively.
The overriden
Start()
function- In the
Start()
function, we callbase.Start()
. What this does is it runs the Start function in the script we’re inheriting from (Projectile.cs
), since it helps us initialise variables likeRigidbody rb
which is used to determine the type of movement. - After that, we zero out the
transform.localScale
of the projectile since we’re going to make it appear by calling theBibleGrow()
coroutine. - Finally, we calculate the
angle
variable, which is used to make sure that the projectiles are offset properly when they start spinning around.
The overriden
FixedUpdate()
function- Since the King Bibles are stationary projectiles that just hover around, we’ll override the original code that makes it fly in one direction and instead replace it with a
Rigidbody.MovePosition()
that moves the projectile in a circle. - We’ve also modified the circular movement code here to use
Time.fixedDeltaTime
since we are inFixedUpdate
.
The overriden
Update()
function- A
HitDelay()
function is implemented to prevent the same bible from hitting an enemy too often. - If there is no
Rigidbody2D
attached to the projectile, we move it using the transform.position instead. - We have 2 conditions that control the calling of the
BibleShrink()
coroutine. One occurs if the Bible Projectile is about to end its lifespan, while the other occurs if the King Bible Weapon Controller is destroyed (i.e. the Weapon has evolved).
The
HitDelay()
function- This works similarly to the
Aura.cs
script that we implemented a while back, where it stores an enemy on contact, keeps it in a Dictionary and pairs it with a value, and prevents the enemy from receiving damage until that value has depleted. - The only difference is that it doesn’t require the
OnTriggerExit2D()
, since it is hit-based instead of area-of-effect.
The
BibleGrow()
coroutine- This coroutine is only called once in
Start()
. - Scales the Bible projectile up within the
transitionTime
.
The
BibleShrink()
coroutine- Shrinks the bible down within the
transitionTime
. - The Bible gets destroyed through the
Destroy()
function inbase.Start()
. - If the coroutine was called because the Weapon Controller was destroyed, we just destroy the projectile as soon as it ends regardless of the lifespan remaining.
The overriden
OnTriggerEnter2D()
function- Overriden to allow enemies that have been hit be affected by the
HitDelay()
function.
Before You Test…
Remember to add the King Bible Weapon Data into the “Available Weapons” section of the PlayerInventory, or else it won’t appear in the shop.Now, you should be able to have a basic, working King Bible that can rotate, spawn. Since you’ve already got the weapon, you can check out how we implemented its evolution, the Unholy Vespers, here!
As always, if you encounter any problems in creating the King Bible, you can submit a forum post about in detail for further assistance. If you have your own implementation of the King Bible, or have any improvements you’d want to see, feel free to make a post as well!
October 14, 2024 at 10:39 pm #16074::Thanks for this new weapon! The “KingBibleWeapon.cs” seems to create some errors, there are two missing “}” on lines 24 and 25. Also “float currentAngle” is never used and line 17 “currentAttackCount = attackCount;” creates an error. I’m currently at Part 23: Buff / Debuff System – maybe I miss something… ;)
has upvoted this post. October 15, 2024 at 3:10 pm #16079::Hi Grim, no problem, Thanks for highlighting this issue! Part of the “KingBibleWeapon.cs” code got cut off when I copied and pasted the script onto here so I added in the missing parts. Hopefully it works now, it was I who missed something :D.
October 15, 2024 at 5:56 pm #16084October 16, 2024 at 3:32 am #16092 - Create an empty GameObject in your scene and name it “King Bible Projectile”. Add a
-
AuthorPosts
- You must be logged in to reply to this topic.
Advertisement below: