Refactor and redo Shattered Prism

This commit is contained in:
2025-11-24 22:08:04 +00:00
parent 9c9698a93d
commit c1ac0161f0
17 changed files with 545 additions and 652 deletions
+2
View File
@@ -1,4 +1,5 @@
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Buffs.Debuffs
@@ -10,6 +11,7 @@ namespace ForgottenWawiTech.Content.Buffs.Debuffs
Main.debuff[Type] = true;
Main.pvpBuff[Type] = true;
Main.buffNoSave[Type] = true;
BuffID.Sets.NurseCannotRemoveDebuff[Type] = true;
}
public override void Update(Player player, ref int buffIndex)
+25
View File
@@ -0,0 +1,25 @@
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Buffs.Debuffs
{
public class Waved : ModBuff
{
public override void SetStaticDefaults()
{
Main.debuff[Type] = true;
Main.pvpBuff[Type] = false;
Main.buffNoSave[Type] = true;
BuffID.Sets.NurseCannotRemoveDebuff[Type] = true;
}
public override string Texture => "Terraria/Images/Buff_" + BuffID.Wet;
public override void Update(Player player, ref int buffIndex)
{
player.wingTimeMax = (int)(player.wingTimeMax * 0f); // Disable wing flight
player.jumpSpeedBoost += 1.0f; // but double jump height
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.Audio;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
using ForgottenWawiTech.Content.Projectiles.FragmentedPrism;
using ForgottenWawiTech.Content.Rarities;
namespace ForgottenWawiTech.Content.Items.Weapons.Magic
{
public class FragmentedPrism : ModItem
{
public override void SetStaticDefaults()
{
Item.ResearchUnlockCount = 1;
}
public override void SetDefaults()
{
Item.damage = 200;
Item.DamageType = DamageClass.Magic;
Item.mana = 36;
Item.width = 54;
Item.height = 54;
Item.useTime = 30;
Item.useAnimation = 30;
Item.useStyle = ItemUseStyleID.Shoot;
Item.noMelee = true;
Item.channel = true;
Item.noUseGraphic = true;
Item.knockBack = 3.5f;
Item.value = Item.buyPrice(platinum: 2, gold: 50);
Item.rare = ModContent.RarityType<SingularityItem>();
Item.UseSound = SoundID.Item15;
Item.autoReuse = true;
Item.shoot = ModContent.ProjectileType<FragmentedPrismController>();
Item.shootSpeed = 0f;
}
public override bool CanUseItem(Player player) => true;
public override bool Shoot(Player player, Terraria.DataStructures.EntitySource_ItemUse_WithAmmo source, Vector2 position, Vector2 velocity, int type, int damage, float knockBack) => false;
public override void HoldItem(Player player)
{
if (player.whoAmI == Main.myPlayer && player.ownedProjectileCounts[Item.shoot] == 0)
{
IEntitySource spawnSource = player.GetSource_ItemUse(Item);
int damage = player.GetWeaponDamage(Item);
float knockBack = player.GetWeaponKnockback(Item, Item.knockBack);
Projectile.NewProjectile(spawnSource, player.Center, Vector2.Zero, Item.shoot, damage, knockBack, player.whoAmI);
}
Lighting.AddLight(player.Center, new Vector3(0.08f, 0.16f, 0.26f));
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,42 +0,0 @@
using ForgottenWawiTech.Content.Projectiles;
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Items.Weapons.Magic
{
public class ShatteredPrism : ModItem
{
public override string Texture => "Terraria/Images/Item_" + ItemID.LastPrism; // TODO: Change
public static Color OverrideColor = new(80,0,80);
public override void SetDefaults() {
Item.CloneDefaults(ItemID.LastPrism);
Item.mana = 4;
Item.damage = 500;
Item.shoot = ModContent.ProjectileType<ShatteredPrismHoldout>();
Item.shootSpeed = 30f;
Item.color = OverrideColor;
}
public override bool CanUseItem(Player player)
{
return player.ownedProjectileCounts[ModContent.ProjectileType<ShatteredPrismHoldout>()] <= 0;
}
public override void AddRecipes()
{
Recipe recipe = CreateRecipe();
// Only add recipe if Calamity Mod is loaded
if (ModLoader.TryGetMod("CalamityMod", out Mod calamityMod))
{
recipe.AddIngredient(ItemID.LastPrism, 1);
recipe.AddIngredient(calamityMod.Find<ModItem>("ShatteredCommunity").Type, 1);
recipe.AddIngredient<Content.Items.Materials.RGBAlloy>(10);
recipe.AddTile<Content.Tiles.CraftingStations.HighTechMarvel>();
recipe.Register();
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

+1 -1
View File
@@ -39,7 +39,7 @@ namespace ForgottenWawiTech.Content.NPCs.OriginalNeptune
IDEA:
Neptune will mostly hover and fly around at high speeds on a ship similiar to the Respawn Ship from SOT.
He will fire projectiles at high speeds which cause high AOE damage and inflict strong crushing debuffs.
All players will receive a debuff that nearly nullifies wing time while fighting him.
All players will receive a debuff (Content.Buffs.Debuffs.Waved) that nullifies wing time while fighting him.
At 50% health, he will "throw" Mr Raccoon off the ship to fight on the ground with the player. His AI will be listed in his file.
During this time, Neptune will become mildly less aggressive, however still dangerous and on the offensive.
At 25% health, he will double his attack speed and movement speed, becoming a frantic danger to players.
@@ -0,0 +1,55 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles.FragmentedPrism
{
public class FragmentedPrismBeam : ModProjectile
{
public override string Texture => "Terraria/Images/Projectile_440";
public override void SetDefaults()
{
Projectile.width = 18;
Projectile.height = 18;
Projectile.friendly = true;
Projectile.DamageType = DamageClass.Magic;
Projectile.penetrate = -1;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.extraUpdates = 12;
Projectile.timeLeft = 26;
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 8;
Projectile.light = 0.55f;
}
public override void AI()
{
if (Projectile.velocity.LengthSquared() < 0.001f)
{
Projectile.Kill();
return;
}
Projectile.rotation = Projectile.velocity.ToRotation();
float progress = 1f - Projectile.timeLeft / 26f;
Projectile.scale = MathHelper.Lerp(0.85f, 1.35f, progress);
Lighting.AddLight(Projectile.Center, 0.2f, 0.35f, 0.55f);
if (Main.rand.NextBool(3))
{
Vector2 dustPos = Projectile.Center - Projectile.velocity * Main.rand.NextFloat(0.2f, 0.6f);
Dust dust = Dust.NewDustPerfect(dustPos, DustID.DungeonSpirit, Projectile.velocity * -0.15f, 150, default, 1.05f);
dust.noGravity = true;
}
}
public override bool? CanHitNPC(NPC target)
{
return base.CanHitNPC(target);
}
}
}
@@ -0,0 +1,151 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.Audio;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles.FragmentedPrism
{
public class FragmentedPrismController : ModProjectile
{
public const int ShardCount = 5;
public const float ChargeTimePerShard = 24f;
private const float ChargeDecayRate = 3f;
private const int ShardSpawnDelay = 4;
public static float MaxChargeTime => ChargeTimePerShard * ShardCount;
private ref float SpawnTimer => ref Projectile.ai[0];
private ref float ChargeTimer => ref Projectile.ai[1];
private ref float LastFiringCount => ref Projectile.localAI[0];
public override string Texture => "Terraria/Images/Projectile_0";
public override void SetDefaults()
{
Projectile.width = 10;
Projectile.height = 10;
Projectile.friendly = false;
Projectile.hostile = false;
Projectile.penetrate = -1;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.hide = true;
Projectile.DamageType = DamageClass.Magic;
}
public override void AI()
{
Player player = Main.player[Projectile.owner];
int itemType = ModContent.ItemType<Items.Weapons.Magic.FragmentedPrism>();
if (!player.active || player.dead || player.HeldItem.type != itemType)
{
Projectile.Kill();
return;
}
player.heldProj = Projectile.whoAmI;
if (player.channel || ChargeTimer > 0f)
{
player.itemAnimation = player.itemTime = 2;
player.SetDummyItemTime(2);
}
Projectile.Center = player.Center;
Projectile.timeLeft = 2;
if (Main.myPlayer == player.whoAmI)
{
SpawnTimer++;
if (SpawnTimer >= ShardSpawnDelay)
{
SpawnTimer = 0f;
TrySpawnShard(player);
}
}
float maxCharge = MaxChargeTime;
if (player.channel)
{
ChargeTimer = MathHelper.Clamp(ChargeTimer + 1f, 0f, maxCharge);
}
else
{
ChargeTimer = MathHelper.Clamp(ChargeTimer - ChargeDecayRate, 0f, maxCharge);
}
int currentFiringCount = (int)MathHelper.Clamp(ChargeTimer / ChargeTimePerShard, 0f, ShardCount);
if (currentFiringCount > LastFiringCount)
{
SoundEngine.PlaySound(SoundID.Item117 with { Volume = 0.45f, Pitch = 0.3f }, player.Center);
Projectile.netUpdate = true;
}
else if (currentFiringCount < LastFiringCount)
{
Projectile.netUpdate = true;
}
LastFiringCount = currentFiringCount;
}
public override void OnKill(int timeLeft)
{
if (Main.player[Projectile.owner] is not Player player)
{
return;
}
for (int i = 0; i < Main.maxProjectiles; i++)
{
Projectile other = Main.projectile[i];
if (other.active && other.owner == Projectile.owner && other.type == ModContent.ProjectileType<FragmentedPrismShard>())
{
other.Kill();
}
}
}
private void TrySpawnShard(Player player)
{
int shardType = ModContent.ProjectileType<FragmentedPrismShard>();
bool[] occupied = new bool[ShardCount];
for (int i = 0; i < Main.maxProjectiles; i++)
{
Projectile shard = Main.projectile[i];
if (shard.active && shard.owner == player.whoAmI && shard.type == shardType)
{
int slot = (int)shard.ai[0];
if (slot >= 0 && slot < ShardCount)
{
occupied[slot] = true;
}
}
}
IEntitySource source = player.GetSource_ItemUse_WithPotentialAmmo(player.HeldItem, AmmoID.None);
bool spawnedAny = false;
for (int index = 0; index < ShardCount; index++)
{
if (occupied[index])
{
continue;
}
int proj = Projectile.NewProjectile(source, player.Center, Vector2.Zero, shardType, Projectile.damage, Projectile.knockBack, player.whoAmI, index);
if (Main.projectile.IndexInRange(proj))
{
Main.projectile[proj].originalDamage = Projectile.damage;
Main.projectile[proj].ai[1] = MathHelper.TwoPi * (index / (float)ShardCount);
}
spawnedAny = true;
}
if (spawnedAny)
{
SoundEngine.PlaySound(SoundID.Item4 with { Volume = 0.35f, Pitch = -0.3f }, player.Center);
}
}
}
}
@@ -0,0 +1,187 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.Audio;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles.FragmentedPrism
{
public class FragmentedPrismShard : ModProjectile
{
private const float OrbitRadius = 72f;
private const float BaseOrbitSpeed = 0.04f;
private const int FireInterval = 1;
private const int VisualSpawnInterval = 18;
private ref float ShardIndex => ref Projectile.ai[0];
private ref float OrbitAngleOffset => ref Projectile.ai[1];
private ref float FireTimer => ref Projectile.localAI[0];
private ref float VisualTimer => ref Projectile.localAI[1];
public override void SetStaticDefaults()
{
Main.projFrames[Type] = 5;
}
public override void SetDefaults()
{
Projectile.width = 22;
Projectile.height = 22;
Projectile.friendly = false;
Projectile.hostile = false;
Projectile.penetrate = -1;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.timeLeft = 2;
Projectile.hide = false;
}
public override bool? CanDamage() => false;
public override void AI()
{
Player player = Main.player[Projectile.owner];
if (!player.active || player.dead)
{
Projectile.Kill();
return;
}
int itemType = ModContent.ItemType<Items.Weapons.Magic.FragmentedPrism>();
if (player.HeldItem.type != itemType)
{
Projectile.Kill();
return;
}
if (OrbitAngleOffset == 0f && ShardIndex >= 0f)
{
OrbitAngleOffset = MathHelper.TwoPi * (ShardIndex / FragmentedPrismController.ShardCount);
}
if (!TryGetController(player, out Projectile controller))
{
Projectile.Kill();
return;
}
float chargeTimer = controller.ai[1];
float chargeProgress = FragmentedPrismController.MaxChargeTime > 0f
? MathHelper.Clamp(chargeTimer / FragmentedPrismController.MaxChargeTime, 0f, 1f)
: 0f;
int firingShardCount = (int)MathHelper.Clamp(chargeTimer / FragmentedPrismController.ChargeTimePerShard, 0f, FragmentedPrismController.ShardCount);
bool canFire = ShardIndex < firingShardCount;
float orbitSpeed = BaseOrbitSpeed + chargeProgress * 0.06f;
float orbitRadius = OrbitRadius + chargeProgress * 12f;
float rotation = (float)(Main.GameUpdateCount * orbitSpeed) + OrbitAngleOffset;
Vector2 desiredPosition = player.Center + rotation.ToRotationVector2() * orbitRadius;
Projectile.Center = desiredPosition;
Vector2 aim = Main.MouseWorld - Projectile.Center;
if (aim.LengthSquared() < 0.001f)
{
aim = Vector2.UnitX * player.direction;
}
Projectile.rotation = aim.ToRotation() + MathHelper.PiOver2;
Projectile.spriteDirection = -1;
if (canFire && Main.netMode != NetmodeID.Server)
{
VisualTimer++;
if (VisualTimer >= VisualSpawnInterval)
{
VisualTimer = 0f;
SpawnBlackHoleVisual(player);
}
}
else
{
VisualTimer = 0f;
}
Animate();
if (Projectile.owner != Main.myPlayer)
{
Projectile.timeLeft = 10;
return;
}
if (canFire)
{
FireTimer++;
if (FireTimer >= FireInterval)
{
FireTimer = 0f;
FireBeam(player);
}
}
else
{
FireTimer = 0f;
}
Projectile.timeLeft = 10;
}
private void Animate()
{
Projectile.frameCounter++;
if (Projectile.frameCounter >= 6)
{
Projectile.frameCounter = 0;
Projectile.frame = (Projectile.frame + 1) % Main.projFrames[Type];
}
}
private void FireBeam(Player player)
{
Vector2 aimDirection = Main.MouseWorld - Projectile.Center;
if (aimDirection.LengthSquared() < 4f)
{
aimDirection = Vector2.UnitX * player.direction;
}
aimDirection.Normalize();
Vector2 velocity = aimDirection * 30f;
int beamType = ModContent.ProjectileType<FragmentedPrismBeam>();
int damage = Projectile.originalDamage > 0 ? Projectile.originalDamage : player.GetWeaponDamage(player.HeldItem);
float knockBack = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
Projectile.NewProjectile(Projectile.GetSource_FromThis(), Projectile.Center, velocity, beamType, damage, knockBack, player.whoAmI);
SoundEngine.PlaySound(SoundID.Item91 with { Volume = 0.2f, Pitch = 0.55f }, Projectile.Center);
}
private void SpawnBlackHoleVisual(Player player)
{
if (Projectile.owner != Main.myPlayer)
{
return;
}
int visualType = ModContent.ProjectileType<FragmentedPrismSingularity>();
Vector2 toMouse = Main.MouseWorld - Projectile.Center;
Vector2 offset = toMouse.SafeNormalize(Vector2.UnitY) * -16f;
Projectile.NewProjectile(Projectile.GetSource_FromThis(), Projectile.Center + offset, Vector2.Zero, visualType, 0, 0f, Projectile.owner);
}
private bool TryGetController(Player player, out Projectile controller)
{
int controllerType = ModContent.ProjectileType<FragmentedPrismController>();
for (int i = 0; i < Main.maxProjectiles; i++)
{
Projectile proj = Main.projectile[i];
if (proj.active && proj.owner == player.whoAmI && proj.type == controllerType)
{
controller = proj;
return true;
}
}
controller = null;
return false;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -0,0 +1,42 @@
using Microsoft.Xna.Framework;
using Terraria;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles.FragmentedPrism
{
public class FragmentedPrismSingularity : ModProjectile
{
public override void SetDefaults()
{
Projectile.width = 12;
Projectile.height = 12;
Projectile.friendly = false;
Projectile.hostile = false;
Projectile.penetrate = -1;
Projectile.tileCollide = false;
Projectile.ignoreWater = true;
Projectile.timeLeft = 24;
Projectile.alpha = 80;
Projectile.hide = false;
}
public override bool? CanDamage() => false;
public override void AI()
{
float progress = 1f - Projectile.timeLeft / 24f;
Projectile.scale = MathHelper.Lerp(0.8f, 1.2f, progress);
Projectile.alpha = (int)MathHelper.Lerp(80f, 220f, progress);
Lighting.AddLight(Projectile.Center, 0.05f, 0.1f, 0.25f);
if (Main.rand.NextBool(4))
{
Dust dust = Dust.NewDustDirect(Projectile.Center - new Vector2(8f), 16, 16, DustID.BlueCrystalShard, 0f, 0f, 150, default, 0.8f);
dust.velocity *= 0.2f;
dust.noGravity = true;
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

-364
View File
@@ -1,364 +0,0 @@
using ForgottenWawiTech.Content.Projectiles;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.IO;
using Terraria;
using Terraria.Enums;
using Terraria.GameContent;
using Terraria.GameContent.Shaders;
using Terraria.Graphics.Effects;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles
{
public class ShatteredPrismBeam : ModProjectile
{
// A helpful math constant for performing beam angling calculations.
private const float PiBeamDivisor = MathHelper.Pi / ShatteredPrismHoldout.NumBeams;
// How much more damage the beams do when the Prism is fully charged. Damage smoothly scales up to this multiplier.
private const float MaxDamageMultiplier = 20f;
// Beams increase their scale from 0 to this value as the Prism charges up.
private const float MaxBeamScale = 1.8f;
// Beams reduce their spread to zero as the Prism charges up. This controls the maximum spread.
private const float MaxBeamSpread = 2f;
// The maximum possible range of the beam. Don't set this too high or it will cause significant lag.
private const float MaxBeamLength = 2400f;
// The width of the beam in pixels for the purposes of tile collision.
// This should generally be left at 1, otherwise the beam tends to stop early when touching tiles.
private const float BeamTileCollisionWidth = 1f;
// The width of the beam in pixels for the purposes of entity hitbox collision.
// This gets scaled with the beam's scale value, so as the beam visually grows its hitbox gets wider as well.
private const float BeamHitboxCollisionWidth = 22f;
// The number of sample points to use when performing a collision hitscan for the beam.
// More points theoretically leads to a higher quality result, but can cause more lag. 3 tends to be enough.
private const int NumSamplePoints = 3;
// How quickly the beam adjusts to sudden changes in length.
// Every frame, the beam replaces this ratio of its current length with its intended length.
// Generally you shouldn't need to change this.
// Setting it too low will make the beam lazily pass through walls before being blocked by them.
private const float BeamLengthChangeFactor = 0.75f;
// The charge percentage required on the host prism for the beam to begin visual effects (e.g. impact dust).
private const float VisualEffectThreshold = 0.1f;
// Each Last Prism beam draws two lasers separately: an inner beam and an outer beam. This controls their opacity.
private const float OuterBeamOpacityMultiplier = 0.75f;
private const float InnerBeamOpacityMultiplier = 0.1f;
// The maximum brightness of the light emitted by the beams. Brightness scales from 0 to this value as the Prism's charge increases.
private const float BeamLightBrightness = 0f;
// These variables control the beam's potential coloration.
// As a value, hue ranges from 0f to 1f, both of which are pure red. The laser beams vary from 0.57 to 0.75, which winds up being a blue-to-purple gradient.
// Saturation ranges from 0f to 1f and controls how greyed out the color is. 0 is fully grayscale, 1 is vibrant, intense color.
// Lightness ranges from 0f to 1f and controls how dark or light the color is. 0 is pitch black. 1 is pure white.
private const float BeamColorHue = 0f;
private const float BeamHueVariance = 0f;
private const float BeamColorSaturation = 0f;
private const float BeamColorLightness = 0f;
// This property encloses the internal AI variable Projectile.ai[0]. It makes the code easier to read.
private float BeamID {
get => Projectile.ai[0];
set => Projectile.ai[0] = value;
}
// This property encloses the internal AI variable Projectile.ai[1].
private float HostPrismIndex {
get => Projectile.ai[1];
set => Projectile.ai[1] = value;
}
// This property encloses the internal AI variable Projectile.localAI[1].
// Normally, localAI is not synced over the network. This beam manually syncs this variable using SendExtraAI and ReceiveExtraAI.
private float BeamLength {
get => Projectile.localAI[1];
set => Projectile.localAI[1] = value;
}
public override void SetDefaults() {
Projectile.width = 18;
Projectile.height = 18;
Projectile.DamageType = DamageClass.Magic;
Projectile.penetrate = -1;
Projectile.alpha = 255;
// The beam itself still stops on tiles, but its invisible "source" Projectile ignores them.
// This prevents the beams from vanishing if the player shoves the Prism into a wall.
Projectile.tileCollide = false;
// Using local NPC immunity allows each beam to strike independently from one another.
Projectile.usesLocalNPCImmunity = true;
Projectile.localNPCHitCooldown = 10;
}
// Send beam length over the network to prevent hitbox-affecting and thus cascading desyncs in multiplayer.
public override void SendExtraAI(BinaryWriter writer) => writer.Write(BeamLength);
public override void ReceiveExtraAI(BinaryReader reader) => BeamLength = reader.ReadSingle();
public override void AI() {
// If something has gone wrong with either the beam or the host Prism, destroy the beam.
Projectile hostPrism = Main.projectile[(int)HostPrismIndex];
if (Projectile.type != ModContent.ProjectileType<ShatteredPrismBeam>() || !hostPrism.active || hostPrism.type != ModContent.ProjectileType<ShatteredPrismHoldout>()) {
Projectile.Kill();
return;
}
// Grab some variables from the host Prism.
Vector2 hostPrismDir = Vector2.Normalize(hostPrism.velocity);
float chargeRatio = MathHelper.Clamp(hostPrism.ai[0] / ShatteredPrismHoldout.MaxCharge, 0f, 1f);
// Update the beam's damage every frame based on charge and the host Prism's damage.
Projectile.damage = (int)(hostPrism.damage * GetDamageMultiplier(chargeRatio));
// The beam cannot strike enemies until the host Prism is at a certain charge level.
Projectile.friendly = hostPrism.ai[0] > ShatteredPrismHoldout.DamageStart;
// This offset is used to make each individual beam orient differently based on its Beam ID.
float beamIdOffset = BeamID - ShatteredPrismHoldout.NumBeams / 2f + 0.5f;
float beamSpread;
float spinRate;
float beamStartSidewaysOffset;
float beamStartForwardsOffset;
// Variables scale smoothly while the host Prism is charging up.
if (chargeRatio < 1f) {
Projectile.scale = MathHelper.Lerp(0f, MaxBeamScale, chargeRatio);
beamSpread = MathHelper.Lerp(MaxBeamSpread, 0f, chargeRatio);
beamStartSidewaysOffset = MathHelper.Lerp(20f, 6f, chargeRatio);
beamStartForwardsOffset = MathHelper.Lerp(-21f, -17f, chargeRatio);
// For the first 2/3 of charge time, the opacity scales up from 0% to 40%.
// Spin rate increases slowly during this time.
if (chargeRatio <= 0.66f) {
float phaseRatio = chargeRatio * 1.5f;
Projectile.Opacity = MathHelper.Lerp(0f, 0.4f, phaseRatio);
spinRate = MathHelper.Lerp(20f, 16f, phaseRatio);
}
// For the last 1/3 of charge time, the opacity scales up from 40% to 100%.
// Spin rate increases dramatically during this time.
else {
float phaseRatio = (chargeRatio - 0.66f) * 3f;
Projectile.Opacity = MathHelper.Lerp(0.4f, 1f, phaseRatio);
spinRate = MathHelper.Lerp(16f, 6f, phaseRatio);
}
}
// If the host Prism is already at max charge, don't calculate anything. Just use the max values.
else {
Projectile.scale = MaxBeamScale;
Projectile.Opacity = 1f;
beamSpread = 0f;
spinRate = 6f;
beamStartSidewaysOffset = 6f;
beamStartForwardsOffset = -17f;
}
// The amount to which the angle changes reduces over time so that the beams look like they are focusing.
float deviationAngle = (hostPrism.ai[0] + beamIdOffset * spinRate) / (spinRate * ShatteredPrismHoldout.NumBeams) * MathHelper.TwoPi;
// This trigonometry calculates where the beam is supposed to be pointing.
Vector2 unitRot = Vector2.UnitY.RotatedBy(deviationAngle);
Vector2 yVec = new Vector2(4f, beamStartSidewaysOffset);
float hostPrismAngle = hostPrism.velocity.ToRotation();
Vector2 beamSpanVector = (unitRot * yVec).RotatedBy(hostPrismAngle);
float sinusoidYOffset = unitRot.Y * PiBeamDivisor * beamSpread;
// Calculate the beam's emanating position. Start with the Prism's center.
Projectile.Center = hostPrism.Center;
// Add a fixed offset to align with the Prism's sprite sheet.
Projectile.position += hostPrismDir * 16f + new Vector2(0f, -hostPrism.gfxOffY);
// Add the forwards offset, measured in pixels.
Projectile.position += hostPrismDir * beamStartForwardsOffset;
// Add the sideways offset vector, which is calculated for the current angle of the beam and scales with the beam's sideways offset.
Projectile.position += beamSpanVector;
// Set the beam's velocity to point towards its current spread direction and sanity check it. It should have magnitude 1.
Projectile.velocity = hostPrismDir.RotatedBy(sinusoidYOffset);
if (Projectile.velocity.HasNaNs() || Projectile.velocity == Vector2.Zero) {
Projectile.velocity = -Vector2.UnitY;
}
Projectile.rotation = Projectile.velocity.ToRotation();
// Update the beam's length by performing a hitscan collision check.
float hitscanBeamLength = PerformBeamHitscan(hostPrism, chargeRatio >= 1f);
BeamLength = MathHelper.Lerp(BeamLength, hitscanBeamLength, BeamLengthChangeFactor);
// This Vector2 stores the beam's hitbox statistics. X = beam length. Y = beam width.
Vector2 beamDims = new Vector2(Projectile.velocity.Length() * BeamLength, Projectile.width * Projectile.scale);
// Only produce dust and cause water ripples if the beam is above a certain charge level.
Color beamColor = GetOuterBeamColor();
if (chargeRatio >= VisualEffectThreshold) {
ProduceBeamDust(beamColor);
// If the game is rendering (i.e. isn't a dedicated server), make the beam disturb water.
if (Main.netMode != NetmodeID.Server) {
ProduceWaterRipples(beamDims);
}
}
// Make the beam cast light along its length. The brightness of the light scales with the charge.
// v3_1 is an unnamed decompiled variable which is the color of the light cast by DelegateMethods.CastLight.
DelegateMethods.v3_1 = beamColor.ToVector3() * BeamLightBrightness * chargeRatio;
Utils.PlotTileLine(Projectile.Center, Projectile.Center + Projectile.velocity * BeamLength, beamDims.Y, new Utils.TileActionAttempt(DelegateMethods.CastLight));
}
// Uses a simple polynomial (x^3) to get sudden but smooth damage increase near the end of the charge-up period.
private float GetDamageMultiplier(float chargeRatio) {
float f = chargeRatio * chargeRatio * chargeRatio;
return MathHelper.Lerp(1f, MaxDamageMultiplier, f);
}
private float PerformBeamHitscan(Projectile prism, bool fullCharge) {
// By default, the hitscan interpolation starts at the Projectile's center.
// If the host Prism is fully charged, the interpolation starts at the Prism's center instead.
Vector2 samplingPoint = Projectile.Center;
if (fullCharge) {
samplingPoint = prism.Center;
}
// Overriding that, if the player shoves the Prism into or through a wall, the interpolation starts at the player's center.
// This last part prevents the player from projecting beams through walls under any circumstances.
Player player = Main.player[Projectile.owner];
if (!Collision.CanHitLine(player.Center, 0, 0, prism.Center, 0, 0)) {
samplingPoint = player.Center;
}
// Perform a laser scan to calculate the correct length of the beam.
// Alternatively, if you want the beam to ignore tiles, just set it to be the max beam length with the following line.
// return MaxBeamLength;
float[] laserScanResults = new float[NumSamplePoints];
Collision.LaserScan(samplingPoint, Projectile.velocity, 0 * Projectile.scale, MaxBeamLength, laserScanResults);
float averageLengthSample = 0f;
for (int i = 0; i < laserScanResults.Length; ++i) {
averageLengthSample += laserScanResults[i];
}
averageLengthSample /= NumSamplePoints;
return averageLengthSample;
}
// Determines whether the specified target hitbox is intersecting with the beam.
public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox) {
// If the target is touching the beam's hitbox (which is a small rectangle vaguely overlapping the host Prism), that's good enough.
if (projHitbox.Intersects(targetHitbox)) {
return true;
}
// Otherwise, perform an AABB line collision check to check the whole beam.
float _ = float.NaN;
Vector2 beamEndPos = Projectile.Center + Projectile.velocity * BeamLength;
return Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(), targetHitbox.Size(), Projectile.Center, beamEndPos, BeamHitboxCollisionWidth * Projectile.scale, ref _);
}
public override bool PreDraw(ref Color lightColor) {
// If the beam doesn't have a defined direction, don't draw anything.
if (Projectile.velocity == Vector2.Zero) {
return false;
}
Texture2D texture = TextureAssets.Projectile[Type].Value;
Vector2 centerFloored = Projectile.Center.Floor() + Projectile.velocity * Projectile.scale * 10.5f;
Vector2 drawScale = new Vector2(Projectile.scale);
// Reduce the beam length proportional to its square area to reduce block penetration.
float visualBeamLength = BeamLength - 14.5f * Projectile.scale * Projectile.scale;
DelegateMethods.f_1 = 1f; // f_1 is an unnamed decompiled variable whose function is unknown. Leave it at 1.
Vector2 startPosition = centerFloored - Main.screenPosition;
Vector2 endPosition = startPosition + Projectile.velocity * visualBeamLength;
// Draw the outer beam.
DrawBeam(Main.spriteBatch, texture, startPosition, endPosition, drawScale, GetOuterBeamColor() * OuterBeamOpacityMultiplier * Projectile.Opacity);
// Draw the inner beam, which is half size.
drawScale *= 0.5f;
DrawBeam(Main.spriteBatch, texture, startPosition, endPosition, drawScale, GetInnerBeamColor() * InnerBeamOpacityMultiplier * Projectile.Opacity);
// Returning false prevents Terraria from trying to draw the Projectile itself.
return false;
}
private void DrawBeam(SpriteBatch spriteBatch, Texture2D texture, Vector2 startPosition, Vector2 endPosition, Vector2 drawScale, Color beamColor) {
Utils.LaserLineFraming lineFraming = new Utils.LaserLineFraming(DelegateMethods.RainbowLaserDraw);
// c_1 is an unnamed decompiled variable which is the render color of the beam drawn by DelegateMethods.RainbowLaserDraw.
DelegateMethods.c_1 = beamColor;
Utils.DrawLaser(spriteBatch, texture, startPosition, endPosition, drawScale, lineFraming);
}
private Color GetOuterBeamColor() {
// This hue calculation produces a unique color for each beam based on its Beam ID.
float hue = (BeamID / ShatteredPrismHoldout.NumBeams) % BeamHueVariance + BeamColorHue;
// Main.hslToRgb converts Hue, Saturation, Lightness into a Color for general purpose use.
Color c = Main.hslToRgb(hue, BeamColorSaturation, BeamColorLightness);
// Manually reduce the opacity of the color so beams can overlap without completely overwriting each other.
c.A = 64;
return c;
}
private Color GetInnerBeamColor() => Color.Black;
private void ProduceBeamDust(Color beamColor) {
// Create one dust per frame a small distance from where the beam ends.
const int type = 15;
Vector2 endPosition = Projectile.Center + Projectile.velocity * (BeamLength - 14.5f * Projectile.scale);
// Main.rand.NextBool is used to give a 50/50 chance for the angle to point to the left or right.
// This gives the dust a 50/50 chance to fly off on either side of the beam.
float angle = Projectile.rotation + (Main.rand.NextBool() ? 1f : -1f) * MathHelper.PiOver2;
float startDistance = Main.rand.NextFloat(1f, 1.8f);
float scale = Main.rand.NextFloat(0.7f, 1.1f);
Vector2 velocity = angle.ToRotationVector2() * startDistance;
Dust dust = Dust.NewDustDirect(endPosition, 0, 0, type, velocity.X, velocity.Y, 0, beamColor, scale);
dust.color = beamColor;
dust.noGravity = true;
// If the beam is currently large, make the dust faster and larger to match.
if (Projectile.scale > 1f) {
dust.velocity *= Projectile.scale;
dust.scale *= Projectile.scale;
}
}
private void ProduceWaterRipples(Vector2 beamDims) {
WaterShaderData shaderData = (WaterShaderData)Filters.Scene["WaterDistortion"].GetShader();
// A universal time-based sinusoid which updates extremely rapidly. GlobalTime is 0 to 3600, measured in seconds.
float waveSine = 0.1f * (float)Math.Sin(Main.GlobalTimeWrappedHourly * 20f);
Vector2 ripplePos = Projectile.position + new Vector2(beamDims.X * 0.5f, 0f).RotatedBy(Projectile.rotation);
// WaveData is encoded as a Color. Not really sure why.
Color waveData = new Color(0.5f, 0.1f * Math.Sign(waveSine) + 0.5f, 0f, 1f) * Math.Abs(waveSine);
shaderData.QueueRipple(ripplePos, waveData, beamDims, RippleShape.Square, Projectile.rotation);
}
// Automatically iterates through every tile the laser is overlapping to cut grass at all those locations.
public override void CutTiles() {
// tilecut_0 is an unnamed decompiled variable which tells CutTiles how the tiles are being cut (in this case, via a Projectile).
DelegateMethods.tilecut_0 = TileCuttingContext.AttackProjectile;
Utils.TileActionAttempt cut = new Utils.TileActionAttempt(DelegateMethods.CutTiles);
Vector2 beamStartPos = Projectile.Center;
Vector2 beamEndPos = beamStartPos + Projectile.velocity * BeamLength;
// PlotTileLine is a function which performs the specified action to all tiles along a drawn line, with a specified width.
// In this case, it is cutting all tiles which can be destroyed by Projectiles, for example grass or pots.
Utils.PlotTileLine(beamStartPos, beamEndPos, Projectile.width * Projectile.scale, cut);
}
}
}
@@ -1,245 +0,0 @@
using ForgottenWawiTech.Content.Items.Weapons.Magic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Terraria;
using Terraria.Audio;
using Terraria.GameContent;
using Terraria.ID;
using Terraria.ModLoader;
namespace ForgottenWawiTech.Content.Projectiles
{
public class ShatteredPrismHoldout : ModProjectile
{
public override string Texture => "Terraria/Images/Projectile_" + ProjectileID.LastPrism;
// The vanilla Last Prism is an animated item with 5 frames of animation. We copy that here.
private const int NumAnimationFrames = 5;
// This controls how many individual beams are fired by the Prism.
public const int NumBeams = 10;
// This value controls how many frames it takes for the Prism to reach "max charge". 60 frames = 1 second.
public const float MaxCharge = 180f;
// This value controls how many frames it takes for the beams to begin dealing damage. Before then they can't hit anything.
public const float DamageStart = 30f;
// This value controls how sluggish the Prism turns while being used. Vanilla Last Prism is 0.08f.
// Higher values make the Prism turn faster.
private const float AimResponsiveness = 0.5f;
// This value controls how frequently the Prism emits sound once it's firing.
private const int SoundInterval = 20;
// These values place caps on the mana consumption rate of the Prism.
// When first used, the Prism consumes mana once every MaxManaConsumptionDelay frames.
// Every time mana is consumed, the pace becomes one frame faster, meaning mana consumption smoothly increases.
// When capped out, the Prism consumes mana once every MinManaConsumptionDelay frames.
private const float MaxManaConsumptionDelay = 15f;
private const float MinManaConsumptionDelay = 5f;
// This property encloses the internal AI variable Projectile.ai[0]. It makes the code easier to read.
private float FrameCounter {
get => Projectile.ai[0];
set => Projectile.ai[0] = value;
}
// This property encloses the internal AI variable Projectile.ai[1].
private float NextManaFrame {
get => Projectile.ai[1];
set => Projectile.ai[1] = value;
}
// This property encloses the internal AI variable Projectile.localAI[0].
// localAI is not automatically synced over the network, but that does not cause any problems in this case.
private float ManaConsumptionRate {
get => Projectile.localAI[0];
set => Projectile.localAI[0] = value;
}
public override void SetStaticDefaults() {
Main.projFrames[Projectile.type] = NumAnimationFrames;
// Signals to Terraria that this Projectile requires a unique identifier beyond its index in the Projectile array.
// This prevents the issue with the vanilla Last Prism where the beams are invisible in multiplayer.
ProjectileID.Sets.NeedsUUID[Projectile.type] = true;
// Prevents jitter when stepping up and down blocks and half blocks
ProjectileID.Sets.HeldProjDoesNotUsePlayerGfxOffY[Type] = true;
}
public override void SetDefaults() {
// Use CloneDefaults to clone all basic Projectile statistics from the vanilla Last Prism.
Projectile.CloneDefaults(ProjectileID.LastPrism);
}
// Held projectiles should set this to false otherwise they will deal contact damage.
public override bool? CanDamage() => false;
public override void AI() {
Player player = Main.player[Projectile.owner];
Vector2 rrp = player.RotatedRelativePoint(player.MountedCenter, true);
// Update the Prism's damage every frame so that it is dynamically affected by Mana Sickness.
UpdateDamageForManaSickness(player);
// Update the frame counter.
FrameCounter += 1f;
// Update Projectile visuals and sound.
UpdateAnimation();
PlaySounds();
// Update the Prism's position in the world and relevant variables of the player holding it.
UpdatePlayerVisuals(player, rrp);
// Update the Prism's behavior: project beams on frame 1, consume mana, and despawn if out of mana.
if (Projectile.owner == Main.myPlayer) {
// Slightly re-aim the Prism every frame so that it gradually sweeps to point towards the mouse.
UpdateAim(rrp, player.HeldItem.shootSpeed);
// player.CheckMana returns true if the mana cost can be paid. Since the second argument is true, the mana is actually consumed.
// If mana shouldn't consumed this frame, the || operator short-circuits its evaluation player.CheckMana never executes.
bool manaIsAvailable = !ShouldConsumeMana() || player.CheckMana(player.HeldItem.mana, true, false);
// The Prism immediately stops functioning if the player is Cursed (player.noItems) or "Crowd Controlled", e.g. the Frozen debuff.
// player.channel indicates whether the player is still holding down the mouse button to use the item.
bool stillInUse = player.channel && manaIsAvailable && !player.noItems && !player.CCed;
// Spawn in the Prism's lasers on the first frame if the player is capable of using the item.
if (stillInUse && FrameCounter == 1f) {
FireBeams();
}
// If the Prism cannot continue to be used, then destroy it immediately.
else if (!stillInUse) {
Projectile.Kill();
}
}
// This ensures that the Prism never times out while in use.
Projectile.timeLeft = 2;
}
private void UpdateDamageForManaSickness(Player player) {
Projectile.damage = (int)player.GetDamage(DamageClass.Magic).ApplyTo(player.HeldItem.damage);
}
private void UpdateAnimation() {
Projectile.frameCounter++;
// As the Prism charges up and focuses the beams, its animation plays faster.
int framesPerAnimationUpdate = FrameCounter >= MaxCharge ? 2 : FrameCounter >= (MaxCharge * 0.66f) ? 3 : 4;
// If necessary, change which specific frame of the animation is displayed.
if (Projectile.frameCounter >= framesPerAnimationUpdate) {
Projectile.frameCounter = 0;
if (++Projectile.frame >= NumAnimationFrames) {
Projectile.frame = 0;
}
}
}
private void PlaySounds() {
// The Prism makes sound intermittently while in use, using the vanilla Projectile variable soundDelay.
if (Projectile.soundDelay <= 0) {
Projectile.soundDelay = SoundInterval;
// On the very first frame, the sound playing is skipped. This way it doesn't overlap the starting hiss sound.
if (FrameCounter > 1f) {
SoundEngine.PlaySound(SoundID.Item15, Projectile.position);
}
}
}
private void UpdatePlayerVisuals(Player player, Vector2 playerHandPos) {
// Place the Prism directly into the player's hand at all times.
Projectile.Center = playerHandPos;
// The beams emit from the tip of the Prism, not the side. As such, rotate the sprite by pi/2 (90 degrees).
Projectile.rotation = Projectile.velocity.ToRotation() + MathHelper.PiOver2;
Projectile.spriteDirection = Projectile.direction;
// The Prism is a holdout Projectile, so change the player's variables to reflect that.
// Constantly resetting player.itemTime and player.itemAnimation prevents the player from switching items or doing anything else.
player.ChangeDir(Projectile.direction);
player.heldProj = Projectile.whoAmI;
player.itemTime = 2;
player.itemAnimation = 2;
// If you do not multiply by Projectile.direction, the player's hand will point the wrong direction while facing left.
player.itemRotation = (Projectile.velocity * Projectile.direction).ToRotation();
}
private bool ShouldConsumeMana() {
// If the mana consumption timer hasn't been initialized yet, initialize it and consume mana on frame 1.
if (ManaConsumptionRate == 0f) {
NextManaFrame = ManaConsumptionRate = MaxManaConsumptionDelay;
return true;
}
// Should mana be consumed this frame?
bool consume = FrameCounter == NextManaFrame;
// If mana is being consumed this frame, update the rate of mana consumption and write down the next frame mana will be consumed.
if (consume) {
// MathHelper.Clamp(X,A,B) guarantees that A <= X <= B. If X is outside the range, it will be set to A or B accordingly.
ManaConsumptionRate = MathHelper.Clamp(ManaConsumptionRate - 1f, MinManaConsumptionDelay, MaxManaConsumptionDelay);
NextManaFrame += ManaConsumptionRate;
}
return consume;
}
private void UpdateAim(Vector2 source, float speed) {
// Get the player's current aiming direction as a normalized vector.
Vector2 aim = Vector2.Normalize(Main.MouseWorld - source);
if (aim.HasNaNs()) {
aim = -Vector2.UnitY;
}
// Change a portion of the Prism's current velocity so that it points to the mouse. This gives smooth movement over time.
aim = Vector2.Normalize(Vector2.Lerp(Vector2.Normalize(Projectile.velocity), aim, AimResponsiveness));
aim *= speed;
if (aim != Projectile.velocity) {
Projectile.netUpdate = true;
}
Projectile.velocity = aim;
}
private void FireBeams() {
// If for some reason the beam velocity can't be correctly normalized, set it to a default value.
Vector2 beamVelocity = Vector2.Normalize(Projectile.velocity);
if (beamVelocity.HasNaNs()) {
beamVelocity = -Vector2.UnitY;
}
// This UUID will be the same between all players in multiplayer, ensuring that the beams are properly anchored on the Prism on everyone's screen.
int uuid = Projectile.GetByUUID(Projectile.owner, Projectile.whoAmI);
int damage = Projectile.damage;
float knockback = Projectile.knockBack;
for (int b = 0; b < NumBeams; ++b) {
Projectile.NewProjectile(Projectile.GetSource_FromThis(), Projectile.Center, beamVelocity, ModContent.ProjectileType<ShatteredPrismBeam>(), damage, knockback, Projectile.owner, b, uuid);
}
// After creating the beams, mark the Prism as having an important network event. This will make Terraria sync its data to other players ASAP.
Projectile.netUpdate = true;
}
// Because the Prism is a holdout Projectile and stays glued to its user, it needs custom drawcode.
public override bool PreDraw(ref Color lightColor) {
SpriteEffects effects = Projectile.spriteDirection == -1 ? SpriteEffects.FlipHorizontally : SpriteEffects.None;
Texture2D texture = TextureAssets.Projectile[Type].Value;
int frameHeight = texture.Height / Main.projFrames[Projectile.type];
int spriteSheetOffset = frameHeight * Projectile.frame;
Vector2 sheetInsertPosition = (Projectile.Center + Vector2.UnitY * Projectile.gfxOffY - Main.screenPosition).Floor();
// The Prism is always at full brightness, regardless of the surrounding light. This is equivalent to it being its own glowmask.
// It is drawn in a non-white color to distinguish it from the vanilla Last Prism.
Color drawColor = ShatteredPrism.OverrideColor;
Main.EntitySpriteDraw(texture, sheetInsertPosition, new Rectangle?(new Rectangle(0, spriteSheetOffset, texture.Width, frameHeight)), drawColor, Projectile.rotation, new Vector2(texture.Width / 2f, frameHeight / 2f), Projectile.scale, effects, 0f);
return false;
}
}
}