diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escDodge.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escDodge.asset
new file mode 100644
index 000000000..a601e2f7b
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escDodge.asset
@@ -0,0 +1,35 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escDodge
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.Dodge, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: duration
+ fieldValue:
+ stringValue: 0.5
+ objectValue: {fileID: 0}
+ - fieldName: initialSpeedCoefficient
+ fieldValue:
+ stringValue: 6.5
+ objectValue: {fileID: 0}
+ - fieldName: finalSpeedCoefficient
+ fieldValue:
+ stringValue: 1.5
+ objectValue: {fileID: 0}
+ - fieldName: dodgeFOV
+ fieldValue:
+ stringValue: -1
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonDetonation.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonDetonation.asset
new file mode 100644
index 000000000..abd38eba7
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonDetonation.asset
@@ -0,0 +1,43 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escIonDetonation
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.IonDetonation, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: baseDuration
+ fieldValue:
+ stringValue: 1
+ objectValue: {fileID: 0}
+ - fieldName: novaDamageCoefficient
+ fieldValue:
+ stringValue: 8
+ objectValue: {fileID: 0}
+ - fieldName: novaRadius
+ fieldValue:
+ stringValue: 12
+ objectValue: {fileID: 0}
+ - fieldName: novaForce
+ fieldValue:
+ stringValue: 2000
+ objectValue: {fileID: 0}
+ - fieldName: novaProcCoefficient
+ fieldValue:
+ stringValue: 1
+ objectValue: {fileID: 0}
+ - fieldName: novaSoundString
+ fieldValue:
+ stringValue:
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonFistPunch.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonFistPunch.asset
new file mode 100644
index 000000000..b15a4cb70
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escIonFistPunch.asset
@@ -0,0 +1,39 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escIonFistPunch
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.IonFistPunch, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: baseDuration
+ fieldValue:
+ stringValue: 0.4
+ objectValue: {fileID: 0}
+ - fieldName: damageCoefficient
+ fieldValue:
+ stringValue: 1.5
+ objectValue: {fileID: 0}
+ - fieldName: force
+ fieldValue:
+ stringValue: 20
+ objectValue: {fileID: 0}
+ - fieldName: procCoefficient
+ fieldValue:
+ stringValue: 1
+ objectValue: {fileID: 0}
+ - fieldName: attackSoundString
+ fieldValue:
+ stringValue:
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escRecallSpear.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escRecallSpear.asset
new file mode 100644
index 000000000..a3c690686
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escRecallSpear.asset
@@ -0,0 +1,27 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escRecallSpear
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.RecallSpear, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: baseDuration
+ fieldValue:
+ stringValue: 0.3
+ objectValue: {fileID: 0}
+ - fieldName: recallSoundString
+ fieldValue:
+ stringValue:
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escSpearThrust.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escSpearThrust.asset
new file mode 100644
index 000000000..c63e0d3aa
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escSpearThrust.asset
@@ -0,0 +1,84 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escSpearThrust
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.SpearThrust, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: hitboxGroupName
+ fieldValue:
+ stringValue: SpearHitbox
+ objectValue: {fileID: 0}
+ - fieldName: damageCoefficient
+ fieldValue:
+ stringValue: 3.5
+ objectValue: {fileID: 0}
+ - fieldName: procCoefficient
+ fieldValue:
+ stringValue: 1
+ objectValue: {fileID: 0}
+ - fieldName: baseDuration
+ fieldValue:
+ stringValue: 0.9
+ objectValue: {fileID: 0}
+ - fieldName: attackStartTimeFraction
+ fieldValue:
+ stringValue: 0.25
+ objectValue: {fileID: 0}
+ - fieldName: attackEndTimeFraction
+ fieldValue:
+ stringValue: 0.45
+ objectValue: {fileID: 0}
+ - fieldName: earlyExitTimeFraction
+ fieldValue:
+ stringValue: 0.6
+ objectValue: {fileID: 0}
+ - fieldName: pushForce
+ fieldValue:
+ stringValue: 300
+ objectValue: {fileID: 0}
+ - fieldName: hitStopDuration
+ fieldValue:
+ stringValue: 0.08
+ objectValue: {fileID: 0}
+ - fieldName: hitHopVelocity
+ fieldValue:
+ stringValue: 4
+ objectValue: {fileID: 0}
+ - fieldName: attackRecoil
+ fieldValue:
+ stringValue: 0.75
+ objectValue: {fileID: 0}
+ - fieldName: muzzleString
+ fieldValue:
+ stringValue: SpearMuzzle
+ objectValue: {fileID: 0}
+ - fieldName: playbackRateParam
+ fieldValue:
+ stringValue: Primary.playbackRate
+ objectValue: {fileID: 0}
+ - fieldName: damageType
+ fieldValue:
+ stringValue: '{"assemblyQualifiedName":"RoR2.DamageType, RoR2, Version=0.0.0.0,
+ Culture=neutral, PublicKeyToken=null","values":"Generic"}'
+ objectValue: {fileID: 0}
+ - fieldName: tipperDamageCoefficient
+ fieldValue:
+ stringValue: 2
+ objectValue: {fileID: 0}
+ - fieldName: thrustAnimationState
+ fieldValue:
+ stringValue: SpearThrust
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escThrowSpear.asset b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escThrowSpear.asset
new file mode 100644
index 000000000..15832ab51
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/AssetStorm/InDev/Characters/Lancer/Skills/escThrowSpear.asset
@@ -0,0 +1,43 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 0
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 89795172, guid: 951ce57ad999ac1f040a4dceb5f8b763, type: 3}
+ m_Name: escThrowSpear
+ m_EditorClassIdentifier:
+ targetType:
+ assemblyQualifiedName: EntityStates.Lancer.ThrowSpear, Starstorm2, Version=1.0.0.0,
+ Culture=neutral, PublicKeyToken=null
+ serializedFieldsCollection:
+ serializedFields:
+ - fieldName: baseDuration
+ fieldValue:
+ stringValue: 0.5
+ objectValue: {fileID: 0}
+ - fieldName: damageCoefficient
+ fieldValue:
+ stringValue: 4
+ objectValue: {fileID: 0}
+ - fieldName: force
+ fieldValue:
+ stringValue: 40
+ objectValue: {fileID: 0}
+ - fieldName: procCoefficient
+ fieldValue:
+ stringValue: 1
+ objectValue: {fileID: 0}
+ - fieldName: throwSoundString
+ fieldValue:
+ stringValue:
+ objectValue: {fileID: 0}
+ - fieldName: muzzleString
+ fieldValue:
+ stringValue: SpearMuzzle
+ objectValue: {fileID: 0}
diff --git a/SS2-Project/Assets/Starstorm2/Components/Duke/DukeController.cs b/SS2-Project/Assets/Starstorm2/Components/Duke/DukeController.cs
new file mode 100644
index 000000000..f0446fbaa
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Components/Duke/DukeController.cs
@@ -0,0 +1,18 @@
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SS2.Components
+{
+ [RequireComponent(typeof(CharacterBody))]
+ public class DukeController : NetworkBehaviour
+ {
+ public bool useAltCamera;
+
+ private void Awake()
+ {
+ // TODO Make it configurable
+ useAltCamera = false;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Components/GupBall/GupBallKickReceiver.cs b/SS2-Project/Assets/Starstorm2/Components/GupBall/GupBallKickReceiver.cs
new file mode 100644
index 000000000..b9999336d
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Components/GupBall/GupBallKickReceiver.cs
@@ -0,0 +1,96 @@
+using UnityEngine;
+using RoR2;
+
+namespace SS2.Components
+{
+ ///
+ /// Receives incoming damage on a GupBall body and converts it into a constant-velocity kick
+ /// in the direction away from the attacker. Damage is always rejected so the ball never dies.
+ ///
+ /// Implements IOnIncomingDamageServerReceiver (not IOnTakeDamageServerReceiver) because:
+ /// - It fires before damage is applied (HealthComponent.cs:1318)
+ /// - We can set damageInfo.rejected = true to prevent all damage
+ /// - godMode would block ALL hooks, so we use rejection instead
+ ///
+ /// Networking: This hook runs server-only inside HealthComponent.TakeDamage [Server].
+ /// The ball body is server-authoritative (no player owner), so ApplyForceImpulse
+ /// applies directly. CharacterNetworkTransform syncs position to clients.
+ ///
+ public class GupBallKickReceiver : MonoBehaviour, IOnIncomingDamageServerReceiver
+ {
+ [Header("Kick Settings")]
+ public float kickSpeed = 30f;
+ public float maxSpeed = 60f;
+ [Tooltip("Multiplier for vertical kick component. Lower = flatter kicks.")]
+ public float verticalKickMultiplier = 0.6f;
+ [Tooltip("Upward bias added to every kick so ground-level hits arc slightly.")]
+ public float upwardBias = 0.1f;
+
+ private CharacterBody body;
+ private CharacterMotor motor;
+
+ private void Awake()
+ {
+ if (!TryGetComponent(out body))
+ {
+ Debug.LogError("GupBallKickReceiver: Missing CharacterBody on " + gameObject.name);
+ }
+ if (!TryGetComponent(out motor))
+ {
+ Debug.LogError("GupBallKickReceiver: Missing CharacterMotor on " + gameObject.name);
+ }
+ }
+
+ public void OnIncomingDamageServer(DamageInfo damageInfo)
+ {
+ damageInfo.rejected = true;
+
+ if (motor == null || body == null)
+ return;
+
+ if (damageInfo.attacker == null)
+ return;
+
+ CharacterBody attackerBody;
+ if (!damageInfo.attacker.TryGetComponent(out attackerBody))
+ return;
+
+ Vector3 ballPosition = body.corePosition;
+ Vector3 attackerPosition = attackerBody.corePosition;
+ Vector3 kickDir = ballPosition - attackerPosition;
+
+ // Epsilon check: attacker overlapping the ball
+ if (kickDir.sqrMagnitude < 0.001f)
+ {
+ kickDir = Vector3.up;
+ }
+ else
+ {
+ kickDir.Normalize();
+ }
+
+ // Dampen vertical component
+ kickDir.y *= verticalKickMultiplier;
+
+ // Add upward bias so ground-level hits arc
+ kickDir.y += upwardBias;
+
+ kickDir.Normalize();
+
+ Vector3 kickForce = kickDir * kickSpeed;
+
+ PhysForceInfo forceInfo = PhysForceInfo.Create();
+ forceInfo.force = kickForce;
+ forceInfo.massIsOne = true;
+ forceInfo.ignoreGroundStick = true;
+ forceInfo.resetVelocity = true;
+ motor.ApplyForceImpulse(in forceInfo);
+
+ // Safety clamp
+ if (motor.velocity.sqrMagnitude > maxSpeed * maxSpeed)
+ {
+ motor.velocity = motor.velocity.normalized * maxSpeed;
+ }
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Components/Lancer/IonFieldBehavior.cs b/SS2-Project/Assets/Starstorm2/Components/Lancer/IonFieldBehavior.cs
new file mode 100644
index 000000000..8b44b13f3
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Components/Lancer/IonFieldBehavior.cs
@@ -0,0 +1,69 @@
+using MSU;
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SS2.Components
+{
+ public sealed class IonFieldBehavior : BaseBuffBehaviour
+ {
+ [BuffDefAssociation(useOnServer = true, useOnClient = false)]
+ private static BuffDef GetBuffDef() => SS2.Survivors.Lancer.bdIonField;
+
+ public static float tickInterval = 0.5f;
+ public static float baseDamagePerTick = 1f;
+ public static float baseRadius = 5f;
+ public static float radiusPerStack = 0.5f;
+ public static GameObject tickEffectPrefab;
+
+ private float tickTimer;
+
+ private void OnEnable()
+ {
+ tickTimer = 0f;
+ }
+
+ private void FixedUpdate()
+ {
+ if (!NetworkServer.active || !hasAnyStacks || !characterBody)
+ return;
+
+ tickTimer += Time.fixedDeltaTime;
+ if (tickTimer < tickInterval)
+ return;
+
+ tickTimer -= tickInterval;
+
+ int stacks = characterBody.GetBuffCount(buffDef);
+ float damage = baseDamagePerTick * (1f + 0.2f * (characterBody.level - 1f)) * stacks;
+ float radius = baseRadius + radiusPerStack * (stacks - 1);
+
+ if (tickEffectPrefab)
+ {
+ EffectManager.SpawnEffect(tickEffectPrefab, new EffectData
+ {
+ origin = characterBody.corePosition,
+ scale = radius
+ }, true);
+ }
+
+ BlastAttack blastAttack = new BlastAttack();
+ blastAttack.position = characterBody.corePosition;
+ blastAttack.baseDamage = damage;
+ blastAttack.baseForce = 0f;
+ blastAttack.radius = radius;
+ blastAttack.attacker = gameObject;
+ blastAttack.inflictor = null;
+ // Ion field is a debuff on enemies. We want the AoE to damage enemies (from the player perspective).
+ // Since the debuffed body is an enemy, using its teamIndex would PROTECT enemies from the blast.
+ // Use TeamIndex.Player so the blast damages everyone NOT on the player team
+ blastAttack.teamIndex = TeamIndex.Player;
+ blastAttack.crit = false;
+ blastAttack.procCoefficient = 0f;
+ blastAttack.damageColorIndex = DamageColorIndex.Item;
+ blastAttack.falloffModel = BlastAttack.FalloffModel.None;
+ blastAttack.damageType = DamageType.Generic;
+ blastAttack.Fire();
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerController.cs b/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerController.cs
new file mode 100644
index 000000000..7583e0a82
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerController.cs
@@ -0,0 +1,108 @@
+using RoR2;
+using RoR2.Skills;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SS2.Components
+{
+ public class LancerController : NetworkBehaviour
+ {
+ public enum SpearState : byte
+ {
+ Held = 0,
+ Thrown = 1,
+ Returning = 2
+ }
+
+ [SerializeField]
+ public SkillDef unarmedPrimarySkillDef;
+
+ [SerializeField]
+ public SkillDef recallSpearSkillDef;
+
+ [SyncVar(hook = nameof(OnSpearStateChanged))]
+ private SpearState _spearState;
+
+ private GameObject _spearProjectileInstance;
+ private GenericSkill primarySkill;
+ private GenericSkill secondarySkill;
+
+ public SpearState spearState => _spearState;
+
+ private void Awake()
+ {
+ if (TryGetComponent(out SkillLocator skillLocator))
+ {
+ primarySkill = skillLocator.primary;
+ secondarySkill = skillLocator.secondary;
+ }
+ else
+ {
+ Debug.LogError("LancerController: Failed to get SkillLocator component.");
+ }
+ }
+
+ [Server]
+ public void SetSpearState(SpearState newState)
+ {
+ OnSpearStateChanged(newState);
+ _spearState = newState;
+ }
+
+ private void OnSpearStateChanged(SpearState newState)
+ {
+ _spearState = newState;
+
+ switch (newState)
+ {
+ case SpearState.Thrown:
+ ApplyUnarmedOverrides();
+ break;
+ case SpearState.Held:
+ RemoveUnarmedOverrides();
+ break;
+ }
+ }
+
+ public void SetSpearProjectile(GameObject go)
+ {
+ _spearProjectileInstance = go;
+ }
+
+ public GameObject GetSpearProjectile()
+ {
+ return _spearProjectileInstance;
+ }
+
+ public Vector3 GetSpearPosition()
+ {
+ if (_spearProjectileInstance)
+ return _spearProjectileInstance.transform.position;
+
+ return gameObject.transform.position;
+ }
+
+ private bool _overridesApplied;
+
+ public void ApplyUnarmedOverrides()
+ {
+ if (_overridesApplied) return;
+ _overridesApplied = true;
+ primarySkill?.SetSkillOverride(this, unarmedPrimarySkillDef, GenericSkill.SkillOverridePriority.Contextual);
+ secondarySkill?.SetSkillOverride(this, recallSpearSkillDef, GenericSkill.SkillOverridePriority.Contextual);
+ }
+
+ public void RemoveUnarmedOverrides()
+ {
+ if (!_overridesApplied) return;
+ _overridesApplied = false;
+ primarySkill?.UnsetSkillOverride(this, unarmedPrimarySkillDef, GenericSkill.SkillOverridePriority.Contextual);
+ secondarySkill?.UnsetSkillOverride(this, recallSpearSkillDef, GenericSkill.SkillOverridePriority.Contextual);
+ }
+
+ private void OnDestroy()
+ {
+ RemoveUnarmedOverrides();
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerSpearProjectile.cs b/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerSpearProjectile.cs
new file mode 100644
index 000000000..a9d835f1a
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Components/Lancer/LancerSpearProjectile.cs
@@ -0,0 +1,277 @@
+using System.Collections.Generic;
+using RoR2;
+using RoR2.Projectile;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SS2.Components
+{
+ [RequireComponent(typeof(ProjectileController))]
+ public class LancerSpearProjectile : NetworkBehaviour, IProjectileImpactBehavior
+ {
+ public enum SpearProjectileState : byte
+ {
+ Flying = 0,
+ Stuck = 1,
+ Returning = 2
+ }
+
+ public float travelSpeed = 80f;
+ public float returnSpeed = 60f;
+ public float returnAcceleration = 5f;
+ public string stickSoundString = "";
+ public float catchDistance = 3f;
+
+ public static float ionFieldPassThroughCoefficient = 1f;
+
+ private ProjectileController projectileController;
+ private Rigidbody rigidbody;
+ private ProjectileDamage projectileDamage;
+ private bool hasRegisteredWithOwner;
+
+ // Stick data
+ private Transform stuckTransform;
+ private Vector3 localStuckPosition;
+ private Quaternion localStuckRotation;
+ private CharacterBody stuckBody;
+
+ // Return pass-through tracking
+ private HashSet returnHitTargets = new HashSet();
+
+ [SyncVar]
+ private SpearProjectileState _state;
+
+ private void Awake()
+ {
+ if (!TryGetComponent(out projectileController))
+ Debug.LogError("LancerSpearProjectile: Failed to get ProjectileController.");
+ if (!TryGetComponent(out rigidbody))
+ Debug.LogError("LancerSpearProjectile: Failed to get Rigidbody.");
+ if (!TryGetComponent(out projectileDamage))
+ Debug.LogError("LancerSpearProjectile: Failed to get ProjectileDamage.");
+ }
+
+ private void Start()
+ {
+ if (!projectileController.owner)
+ {
+ if (NetworkServer.active)
+ Object.Destroy(gameObject);
+ return;
+ }
+
+ if (projectileController.owner.TryGetComponent(out LancerController lancerController))
+ {
+ lancerController.SetSpearProjectile(gameObject);
+ hasRegisteredWithOwner = true;
+ }
+ else
+ {
+ Debug.LogError("LancerSpearProjectile: Owner missing LancerController.");
+ }
+ }
+
+ public void OnProjectileImpact(ProjectileImpactInfo impactInfo)
+ {
+ if (_state != SpearProjectileState.Flying)
+ return;
+
+ if (impactInfo.collider)
+ {
+ HurtBox hurtBox = impactInfo.collider.GetComponent();
+ if (hurtBox && hurtBox.healthComponent && hurtBox.healthComponent.gameObject == projectileController.owner)
+ return;
+ }
+
+ if (NetworkServer.active)
+ {
+ HurtBox hurtBox = impactInfo.collider?.GetComponent();
+
+ // Deal damage on hit (replaces removed ProjectileSingleTargetImpact)
+ if (hurtBox && hurtBox.healthComponent)
+ {
+ DamageInfo damageInfo = new DamageInfo();
+ if (projectileDamage)
+ {
+ damageInfo.damage = projectileDamage.damage;
+ damageInfo.crit = projectileDamage.crit;
+ damageInfo.attacker = projectileController.owner;
+ damageInfo.inflictor = gameObject;
+ damageInfo.position = impactInfo.estimatedPointOfImpact;
+ damageInfo.force = projectileDamage.force * transform.forward;
+ damageInfo.procChainMask = projectileController.procChainMask;
+ damageInfo.procCoefficient = projectileController.procCoefficient;
+ damageInfo.damageColorIndex = projectileDamage.damageColorIndex;
+ damageInfo.damageType = projectileDamage.damageType;
+ }
+ hurtBox.healthComponent.TakeDamage(damageInfo);
+ GlobalEventManager.instance.OnHitEnemy(damageInfo, hurtBox.healthComponent.gameObject);
+ GlobalEventManager.instance.OnHitAll(damageInfo, hurtBox.healthComponent.gameObject);
+
+ // Apply ion field to enemy hit
+ if (hurtBox.healthComponent.body)
+ {
+ hurtBox.healthComponent.body.AddTimedBuff(Survivors.Lancer.bdIonField, Survivors.Lancer.ionFieldDuration);
+ }
+ }
+
+ Stick(impactInfo.collider, impactInfo.estimatedImpactNormal);
+ }
+ }
+
+ private void Stick(Collider collider, Vector3 normal)
+ {
+ Transform hitTransform = collider.transform;
+ HurtBox hurtBox = collider.GetComponent();
+ if (hurtBox && hurtBox.healthComponent)
+ {
+ stuckBody = hurtBox.healthComponent.body;
+ hitTransform = collider.transform;
+ }
+
+ stuckTransform = hitTransform;
+ localStuckPosition = hitTransform.InverseTransformPoint(transform.position);
+ localStuckRotation = Quaternion.Inverse(hitTransform.rotation) * transform.rotation;
+
+ rigidbody.velocity = Vector3.zero;
+ rigidbody.isKinematic = true;
+ rigidbody.detectCollisions = false;
+
+ _state = SpearProjectileState.Stuck;
+
+ if (!string.IsNullOrEmpty(stickSoundString))
+ Util.PlaySound(stickSoundString, gameObject);
+ }
+
+ public void BeginReturn()
+ {
+ if (!NetworkServer.active)
+ return;
+
+ rigidbody.isKinematic = false;
+ rigidbody.detectCollisions = false;
+ stuckTransform = null;
+ stuckBody = null;
+ returnHitTargets.Clear();
+
+ _state = SpearProjectileState.Returning;
+ }
+
+ private void FixedUpdate()
+ {
+ switch (_state)
+ {
+ case SpearProjectileState.Flying:
+ break;
+
+ case SpearProjectileState.Stuck:
+ UpdateStuck();
+ break;
+
+ case SpearProjectileState.Returning:
+ UpdateReturn();
+ break;
+ }
+ }
+
+ private void UpdateStuck()
+ {
+ if (stuckTransform)
+ {
+ transform.SetPositionAndRotation(
+ stuckTransform.TransformPoint(localStuckPosition),
+ stuckTransform.rotation * localStuckRotation
+ );
+ }
+ else if (_state == SpearProjectileState.Stuck)
+ {
+ // Stuck target was destroyed — begin returning to owner
+ if (NetworkServer.active)
+ {
+ BeginReturn();
+ }
+ }
+ }
+
+ private void UpdateReturn()
+ {
+ if (!projectileController.owner)
+ {
+ if (NetworkServer.active)
+ Object.Destroy(gameObject);
+ return;
+ }
+
+ Vector3 direction = (projectileController.owner.transform.position - transform.position).normalized;
+ rigidbody.velocity = direction * returnSpeed;
+
+ // Apply ion field to enemies along return path
+ if (NetworkServer.active)
+ {
+ Collider[] colliders = Physics.OverlapSphere(transform.position, 2f, LayerIndex.entityPrecise.mask);
+ foreach (Collider col in colliders)
+ {
+ HurtBox hurtBox = col.GetComponent();
+ if (hurtBox && hurtBox.healthComponent && hurtBox.healthComponent.body)
+ {
+ if (returnHitTargets.Contains(hurtBox.healthComponent))
+ continue;
+
+ TeamComponent victimTeam = hurtBox.healthComponent.body.teamComponent;
+ TeamIndex ownerTeam = projectileController.teamFilter ? projectileController.teamFilter.teamIndex : TeamIndex.None;
+ if (victimTeam && victimTeam.teamIndex != ownerTeam)
+ {
+ hurtBox.healthComponent.body.AddTimedBuff(Survivors.Lancer.bdIonField, Survivors.Lancer.ionFieldDuration);
+ returnHitTargets.Add(hurtBox.healthComponent);
+ }
+ }
+ }
+ }
+
+ float distToOwner = Vector3.Distance(transform.position, projectileController.owner.transform.position);
+ if (distToOwner <= catchDistance)
+ {
+ OnCaughtByOwner();
+ }
+ }
+
+ private void OnCaughtByOwner()
+ {
+ if (!projectileController.owner)
+ return;
+
+ if (projectileController.owner.TryGetComponent(out LancerController lancerController))
+ {
+ if (NetworkServer.active)
+ {
+ lancerController.SetSpearState(LancerController.SpearState.Held);
+ }
+ lancerController.SetSpearProjectile(null);
+ }
+ else
+ {
+ Debug.LogError("LancerSpearProjectile: Owner missing LancerController on catch.");
+ }
+
+ if (NetworkServer.active)
+ {
+ Object.Destroy(gameObject);
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (hasRegisteredWithOwner && projectileController && projectileController.owner)
+ {
+ if (projectileController.owner.TryGetComponent(out LancerController lancerController))
+ {
+ if (lancerController.GetSpearProjectile() == gameObject)
+ {
+ lancerController.SetSpearProjectile(null);
+ }
+ }
+ }
+ }
+
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairController.cs b/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairController.cs
index 477fdc3bf..4bc5279d6 100644
--- a/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairController.cs
+++ b/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairController.cs
@@ -2,7 +2,6 @@
using RoR2.HudOverlay;
using RoR2.UI;
using System.Collections.Generic;
-using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Networking;
@@ -10,17 +9,17 @@ namespace SS2.Components
{
public class SelfRepairController : NetworkBehaviour
{
- public CharacterBody characterBody;
public float repairMax = 10f;
public float repairGainPerUpdate = 0.10f;
- public GameObject repairOverlayPrefab = SS2.Survivors.Toolbot.RepairOverlayPrefab;
-
+ public GameObject repairOverlayPrefab;
public string repairOverlayChildLocatorEntry = "CrosshairExtras";
+
+ private CharacterBody characterBody;
private OverlayController repairOverlayController;
private List fillUiList = new List();
- [SyncVar(hook = "OnRepairModified")]
+ [SyncVar(hook = nameof(OnRepairModified))]
private float _repair;
public float repair
{
@@ -38,38 +37,26 @@ public bool isMaxRepair
}
}
- public float Network_charge
+ public void OnEnable()
{
- get
+ if (!TryGetComponent(out characterBody))
{
- return _repair;
+ Debug.LogError("SelfRepairController: Missing CharacterBody on " + gameObject.name);
}
- [param: In]
- set
+
+ if (repairOverlayPrefab != null)
{
- if (NetworkServer.localClientActive && syncVarHookGuard)
+ OverlayCreationParams repairOverlayCreationParams = new OverlayCreationParams
{
- syncVarHookGuard = true;
- OnRepairModified(value);
- syncVarHookGuard = false;
- }
- SetSyncVar(value, ref _repair, 1U);
+ prefab = repairOverlayPrefab,
+ childLocatorEntry = repairOverlayChildLocatorEntry
+ };
+ repairOverlayController = HudOverlayManager.AddOverlay(gameObject, repairOverlayCreationParams);
+ repairOverlayController.onInstanceAdded += OnRepairOverlayInstanceAdded;
+ repairOverlayController.onInstanceRemove += OnRepairOverlayInstanceRemoved;
}
}
- public void OnEnable()
- {
- OverlayCreationParams repairOverlayCreationParams = new OverlayCreationParams
- {
- prefab = repairOverlayPrefab,
- childLocatorEntry = repairOverlayChildLocatorEntry
- };
- repairOverlayController = HudOverlayManager.AddOverlay(gameObject, repairOverlayCreationParams);
- repairOverlayController.onInstanceAdded += OnRepairOverlayInstanceAdded;
- repairOverlayController.onInstanceRemove += OnRepairOverlayInstanceRemoved;
- characterBody = GetComponent();
- }
-
private void OnDisable()
{
if (repairOverlayController != null)
@@ -85,13 +72,13 @@ private void FixedUpdate()
{
if (NetworkServer.active)
{
- UpdateUI();
-
if (!isMaxRepair)
{
AddRepair(repairGainPerUpdate);
}
}
+
+ UpdateUI();
}
private void UpdateUI()
@@ -104,70 +91,41 @@ private void UpdateUI()
private void OnRepairOverlayInstanceAdded(OverlayController controller, GameObject instance)
{
- fillUiList.Add(instance.GetComponent());
+ if (instance.TryGetComponent(out ImageFillController ifc))
+ {
+ fillUiList.Add(ifc);
+ }
+ else
+ {
+ Debug.LogError("SelfRepairController: Overlay instance missing ImageFillController");
+ }
}
private void OnRepairOverlayInstanceRemoved(OverlayController controller, GameObject instance)
{
- fillUiList.Remove(instance.transform.GetComponent());
+ if (instance.TryGetComponent(out ImageFillController ifc))
+ {
+ fillUiList.Remove(ifc);
+ }
}
public void AddRepair(float amount)
{
if (!NetworkServer.active)
{
- SS2Log.Error("self repair controller add repair on client");
+ SS2Log.Error("SelfRepairController: AddRepair called on client");
return;
}
if (isMaxRepair && amount > 0)
amount = 0f;
- Network_charge = Mathf.Clamp(repair + amount, 0f, repairMax);
+ _repair = Mathf.Clamp(repair + amount, 0f, repairMax);
}
private void OnRepairModified(float newRepair)
{
- Network_charge = newRepair;
- }
-
- //let him cook
- public override bool OnSerialize(NetworkWriter writer, bool forceAll)
- {
- if (forceAll)
- {
- writer.Write(_repair);
- return true;
- }
- bool flag = false;
- if ((syncVarDirtyBits & 1U) != 0U)
- {
- if (!flag)
- {
- writer.WritePackedUInt32(syncVarDirtyBits);
- flag = true;
- }
- writer.Write(_repair);
- }
- if (!flag)
- {
- writer.WritePackedUInt32(syncVarDirtyBits);
- }
- return flag;
- }
-
- public override void OnDeserialize(NetworkReader reader, bool initialState)
- {
- if (initialState)
- {
- _repair = reader.ReadSingle();
- return;
- }
- int num = (int)reader.ReadPackedUInt32();
- if ((num & 1) != 0)
- {
- OnRepairModified(reader.ReadSingle());
- }
+ _repair = newRepair;
}
}
}
\ No newline at end of file
diff --git a/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairSkillDef.cs b/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairSkillDef.cs
index bba169f29..c3c2b58cb 100644
--- a/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairSkillDef.cs
+++ b/SS2-Project/Assets/Starstorm2/Components/Toolbot/SelfRepairSkillDef.cs
@@ -13,14 +13,16 @@ public class SelfRepairSkillDef : SkillDef
{
public override BaseSkillInstanceData OnAssigned([NotNull] GenericSkill skillSlot)
{
- skillSlot.characterBody.gameObject.AddComponent();
+ SelfRepairController selfRepairController = skillSlot.characterBody.gameObject.EnsureComponent();
+
+ if (SS2.Survivors.Toolbot.RepairOverlayPrefab != null)
+ {
+ selfRepairController.repairOverlayPrefab = SS2.Survivors.Toolbot.RepairOverlayPrefab;
+ }
- SelfRepairController selfRepairMeter = skillSlot.characterBody.gameObject.EnsureComponent();
- //selfRepairMeter.repairOverlayPrefab = SS2.Survivors.Toolbot.RepairOverlayPrefab;
-
return new SelfRepairSkillDef.InstanceData
{
- selfRepairMeter = selfRepairMeter
+ selfRepairMeter = selfRepairController
};
}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/Characters/Monsters/GupBall.cs b/SS2-Project/Assets/Starstorm2/Modules/Characters/Monsters/GupBall.cs
new file mode 100644
index 000000000..9ab0c5590
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/Characters/Monsters/GupBall.cs
@@ -0,0 +1,110 @@
+using RoR2;
+using UnityEngine;
+using MSU;
+using MSU.Config;
+using RoR2.ContentManagement;
+using RiskOfOptions.OptionConfigs;
+using SS2.Components;
+
+namespace SS2.Monsters
+{
+ public sealed class GupBall : SS2Monster
+ {
+ public override SS2AssetRequest AssetRequest =>
+ SS2Assets.LoadAssetAsync("acGupBall", SS2Bundle.Indev);
+
+ // Config values
+ private static ConfiguredFloat cfgKickSpeed;
+ private static ConfiguredFloat cfgMaxSpeed;
+ private static ConfiguredFloat cfgVerticalKickMultiplier;
+ private static ConfiguredFloat cfgUpwardBias;
+ private static ConfiguredFloat cfgBaseMoveSpeed;
+
+ public override bool IsAvailable(ContentPack contentPack)
+ {
+ return SS2Config.enableBeta && base.IsAvailable(contentPack);
+ }
+
+ public override void Initialize()
+ {
+ cfgKickSpeed = SS2Config.ConfigFactory.MakeConfiguredFloat(30f, b =>
+ {
+ b.section = "GupBall";
+ b.key = "Kick Speed";
+ b.description = "Velocity applied to the ball per hit.";
+ b.configFile = SS2Config.ConfigMonster;
+ b.sliderConfig = new SliderConfig { min = 5f, max = 100f };
+ }).DoConfigure();
+
+ cfgMaxSpeed = SS2Config.ConfigFactory.MakeConfiguredFloat(60f, b =>
+ {
+ b.section = "GupBall";
+ b.key = "Max Speed";
+ b.description = "Maximum velocity the ball can reach.";
+ b.configFile = SS2Config.ConfigMonster;
+ b.sliderConfig = new SliderConfig { min = 10f, max = 200f };
+ }).DoConfigure();
+
+ cfgVerticalKickMultiplier = SS2Config.ConfigFactory.MakeConfiguredFloat(0.6f, b =>
+ {
+ b.section = "GupBall";
+ b.key = "Vertical Kick Multiplier";
+ b.description = "Multiplier for the vertical component of kicks. Lower values = flatter kicks.";
+ b.configFile = SS2Config.ConfigMonster;
+ b.sliderConfig = new SliderConfig { min = 0f, max = 1f };
+ }).DoConfigure();
+
+ cfgUpwardBias = SS2Config.ConfigFactory.MakeConfiguredFloat(0.1f, b =>
+ {
+ b.section = "GupBall";
+ b.key = "Upward Bias";
+ b.description = "Upward nudge added to each kick so ground-level hits arc slightly.";
+ b.configFile = SS2Config.ConfigMonster;
+ b.sliderConfig = new SliderConfig { min = 0f, max = 0.5f };
+ }).DoConfigure();
+
+ cfgBaseMoveSpeed = SS2Config.ConfigFactory.MakeConfiguredFloat(7f, b =>
+ {
+ b.section = "GupBall";
+ b.key = "Friction (Base Move Speed)";
+ b.description = "Controls how quickly the ball decelerates. Higher = more friction.";
+ b.configFile = SS2Config.ConfigMonster;
+ b.sliderConfig = new SliderConfig { min = 0f, max = 20f };
+ }).DoConfigure();
+
+ if (CharacterPrefab == null)
+ {
+ SS2Log.Warning("GupBall: CharacterPrefab is null, skipping Initialize.");
+ return;
+ }
+
+ // Set body flags
+ if (CharacterPrefab.TryGetComponent(out var characterBody))
+ {
+ characterBody.baseMoveSpeed = cfgBaseMoveSpeed;
+ characterBody.bodyFlags |= CharacterBody.BodyFlags.IgnoreFallDamage;
+ }
+ else
+ {
+ Debug.LogError("GupBall: Missing CharacterBody on " + CharacterPrefab.name);
+ }
+
+ // Hide health bar
+ if (CharacterPrefab.TryGetComponent(out var healthComponent))
+ {
+ healthComponent.dontShowHealthbar = true;
+ }
+ else
+ {
+ Debug.LogError("GupBall: Missing HealthComponent on " + CharacterPrefab.name);
+ }
+
+ // Add kick receiver
+ var kickReceiver = CharacterPrefab.AddComponent();
+ kickReceiver.kickSpeed = cfgKickSpeed;
+ kickReceiver.maxSpeed = cfgMaxSpeed;
+ kickReceiver.verticalKickMultiplier = cfgVerticalKickMultiplier;
+ kickReceiver.upwardBias = cfgUpwardBias;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Duke.cs b/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Duke.cs
new file mode 100644
index 000000000..a6d80184a
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Duke.cs
@@ -0,0 +1,183 @@
+using MSU;
+using R2API;
+using RoR2;
+using RoR2.ContentManagement;
+using RoR2.UI;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.Networking;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SS2.Survivors
+{
+ public sealed class Duke : SS2Survivor, IContentPackModifier
+ {
+ public override SS2AssetRequest AssetRequest => SS2Assets.LoadAssetAsync("acDuke", SS2Bundle.Indev);
+
+ public static ModdedProcType RicochetProc { get; private set; }
+
+ public static BuffDef bdDukeStun;
+
+ // Ricochet tuning
+ private static float ricochetDamageCoefficient = 1f;
+ private static float ricochetRange = 30f;
+ private static float ricochetProcCoefficient = 0.5f;
+
+ // Ricochet VFX
+ public static GameObject ricochetTracerPrefab;
+ public static GameObject ricochetHitPrefab;
+
+ public override bool IsAvailable(ContentPack contentPack)
+ {
+ return SS2Config.enableBeta && base.IsAvailable(contentPack);
+ }
+
+ public override void Initialize()
+ {
+ RicochetProc = ProcTypeAPI.ReserveProcType();
+
+ bdDukeStun = AssetCollection.FindAsset("bdDukeStun");
+ if (!bdDukeStun)
+ Debug.LogError("[Duke] Failed to find bdDukeStun BuffDef in asset collection. Stun and ricochet-on-stun will not work.");
+
+ GlobalEventManager.onServerDamageDealt += OnServerDamageDealt;
+
+ AssignEffects();
+ ModifyPrefab();
+ }
+
+ public void ModifyContentPack(ContentPack contentPack)
+ {
+ }
+
+ private void ModifyPrefab()
+ {
+ if (CharacterPrefab.TryGetComponent(out CharacterBody cb))
+ {
+ cb.preferredPodPrefab = Addressables.LoadAssetAsync("RoR2/Base/SurvivorPod/SurvivorPod.prefab").WaitForCompletion();
+ cb._defaultCrosshairPrefab = Addressables.LoadAssetAsync("RoR2/Base/Bandit2/Bandit2Crosshair.prefab").WaitForCompletion();
+ }
+ else
+ {
+ Debug.LogError("[Duke] ModifyPrefab: CharacterPrefab missing CharacterBody component.");
+ }
+ }
+
+ private void AssignEffects()
+ {
+ // M1 Deagle VFX
+ EntityStates.Duke.FireDeagle.muzzleEffectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/MuzzleflashFMJ.prefab").WaitForCompletion();
+ EntityStates.Duke.FireDeagle.tracerPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/TracerCommandoDefault.prefab").WaitForCompletion();
+ EntityStates.Duke.FireDeagle.hitPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/HitsparkCommando.prefab").WaitForCompletion();
+
+ // ADS Shot VFX
+ EntityStates.Duke.FireADSShot.muzzleEffectPrefab = Addressables.LoadAssetAsync("RoR2/DLC1/Railgunner/MuzzleflashRailgun.prefab").WaitForCompletion();
+ EntityStates.Duke.FireADSShot.tracerPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/TracerCommandoDefault.prefab").WaitForCompletion();
+ EntityStates.Duke.FireADSShot.hitPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/HitsparkCommando.prefab").WaitForCompletion();
+
+ // ADS weakpoint crosshair — clone StandardCrosshair and add weakpoint visualization (Pilot pattern)
+ var baseCrosshair = Addressables.LoadAssetAsync("RoR2/Base/UI/StandardCrosshair.prefab").WaitForCompletion();
+ var dukeCrosshair = UnityEngine.Object.Instantiate(baseCrosshair);
+ dukeCrosshair.name = "DukeADSCrosshair";
+ var visualizerPrefab = Addressables.LoadAssetAsync("RoR2/DLC1/Railgunner/RailgunnerSniperTargetVisualizerLight.prefab").WaitForCompletion();
+ dukeCrosshair.AddComponent(); // required by SniperTargetViewer
+ var targetViewer = dukeCrosshair.AddComponent();
+ targetViewer.visualizerPrefab = visualizerPrefab;
+ EntityStates.Duke.AimDownSights.crosshairOverridePrefab = dukeCrosshair;
+
+ // Stun cone VFX
+ EntityStates.Duke.StunCone.effectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Common/VFX/OmniExplosionVFXQuick.prefab").WaitForCompletion();
+
+ // Ricochet VFX
+ ricochetTracerPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/TracerCommandoDefault.prefab").WaitForCompletion();
+ ricochetHitPrefab = Addressables.LoadAssetAsync("RoR2/Base/Commando/HitsparkCommando.prefab").WaitForCompletion();
+ }
+
+ private void OnServerDamageDealt(DamageReport damageReport)
+ {
+ if (!NetworkServer.active) return;
+
+ DamageInfo damageInfo = damageReport.damageInfo;
+ CharacterBody attackerBody = damageReport.attackerBody;
+ CharacterBody victimBody = damageReport.victimBody;
+
+ if (!attackerBody || !victimBody) return;
+
+ // Only Duke bodies trigger ricochet
+ if (!attackerBody.TryGetComponent(out Components.DukeController _)) return;
+
+ // Prevent ricochet chain (n=1 only)
+ if (damageInfo.procChainMask.HasModdedProc(RicochetProc)) return;
+
+ // Trigger conditions: crit OR victim has Duke stun debuff
+ bool shouldRicochet = damageInfo.crit || (bdDukeStun && victimBody.HasBuff(bdDukeStun));
+ if (!shouldRicochet) return;
+
+ // Find nearest enemy to the victim
+ HurtBox targetHurtBox = FindRicochetTarget(attackerBody, victimBody);
+ if (!targetHurtBox) return;
+
+ FireRicochet(damageInfo, attackerBody, victimBody, targetHurtBox);
+ }
+
+ private HurtBox FindRicochetTarget(CharacterBody attackerBody, CharacterBody victimBody)
+ {
+ var search = new BullseyeSearch();
+ search.searchOrigin = victimBody.corePosition;
+ search.searchDirection = Vector3.zero;
+ search.teamMaskFilter = TeamMask.GetEnemyTeams(attackerBody.teamComponent.teamIndex);
+ search.filterByLoS = true;
+ search.sortMode = BullseyeSearch.SortMode.Distance;
+ search.maxDistanceFilter = ricochetRange;
+ search.filterByDistinctEntity = true;
+ search.RefreshCandidates();
+
+ // Exclude the victim itself
+ return search.GetResults()
+ .Where(h => h && h.healthComponent && h.healthComponent.alive && h.healthComponent != victimBody.healthComponent)
+ .FirstOrDefault();
+ }
+
+ private void FireRicochet(DamageInfo originalDamage, CharacterBody attackerBody, CharacterBody victimBody, HurtBox target)
+ {
+ Vector3 origin = victimBody.corePosition;
+ Vector3 direction = (target.transform.position - origin).normalized;
+
+ // Offset origin slightly toward the target to avoid re-hitting the victim's own collider
+ origin += direction * (victimBody.radius + 0.5f);
+
+ ProcChainMask procMask = originalDamage.procChainMask;
+ procMask.AddModdedProc(RicochetProc);
+
+ DamageTypeCombo damageType = DamageType.Generic;
+
+ var bulletAttack = new BulletAttack
+ {
+ aimVector = direction,
+ origin = origin,
+ damage = originalDamage.damage * ricochetDamageCoefficient,
+ damageType = damageType,
+ damageColorIndex = DamageColorIndex.Default,
+ minSpread = 0f,
+ maxSpread = 0f,
+ falloffModel = BulletAttack.FalloffModel.None,
+ maxDistance = ricochetRange,
+ force = 100f,
+ isCrit = originalDamage.crit,
+ owner = attackerBody.gameObject,
+ muzzleName = "",
+ smartCollision = true,
+ procChainMask = procMask,
+ procCoefficient = ricochetProcCoefficient,
+ radius = 0.5f,
+ weapon = attackerBody.gameObject,
+ tracerEffectPrefab = ricochetTracerPrefab,
+ hitEffectPrefab = ricochetHitPrefab
+ };
+ bulletAttack.Fire();
+
+ Debug.Log("[Duke] Ricochet fired from " + victimBody.name + " toward " + target.healthComponent.body.name);
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Lancer.cs b/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Lancer.cs
new file mode 100644
index 000000000..e7eea329c
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/Characters/Survivors/Lancer.cs
@@ -0,0 +1,176 @@
+using MSU;
+using R2API;
+using RoR2;
+using RoR2.ContentManagement;
+using RoR2.Projectile;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.Networking;
+
+namespace SS2.Survivors
+{
+ public sealed class Lancer : SS2Survivor
+ {
+ public override SS2AssetRequest AssetRequest => SS2Assets.LoadAssetAsync("acLancer", SS2Bundle.Indev);
+
+ public static BuffDef bdIonField;
+
+ // Ion field tuning
+ public static float ionFieldDuration = 5f;
+ public static int ionFieldMaxStacks = 5;
+
+ public static DamageAPI.ModdedDamageType TipperIonFieldDamageType { get; set; }
+
+ // Code-created projectile prefabs
+ private static GameObject lancerSpearProjectile;
+ private static GameObject lancerIonFistProjectile;
+
+ public override bool IsAvailable(ContentPack contentPack)
+ {
+ return SS2Config.enableBeta && base.IsAvailable(contentPack);
+ }
+
+ public override void Initialize()
+ {
+ bdIonField = AssetCollection.FindAsset("bdIonField");
+
+ if (bdIonField)
+ {
+ bdIonField.canStack = true;
+ bdIonField.maxStacks = ionFieldMaxStacks;
+ }
+
+ TipperIonFieldDamageType = DamageAPI.ReserveDamageType();
+ GlobalEventManager.onServerDamageDealt += OnServerDamageDealt;
+
+ CreateProjectiles();
+ AssignEffects();
+ ModifyPrefab();
+ }
+
+ public override void ModifyContentPack(ContentPack contentPack)
+ {
+ base.ModifyContentPack(contentPack);
+ contentPack.projectilePrefabs.Add(new GameObject[] { lancerSpearProjectile, lancerIonFistProjectile });
+ }
+
+ private void CreateProjectiles()
+ {
+ CreateSpearProjectile();
+ CreateIonFistProjectile();
+ }
+
+ private void CreateSpearProjectile()
+ {
+ var original = Addressables.LoadAssetAsync("RoR2/Base/Bandit2/Bandit2ShivProjectile.prefab").WaitForCompletion();
+ lancerSpearProjectile = PrefabAPI.InstantiateClone(original, "LancerSpearProjectile", true);
+
+ // Remove vanilla impact/stick behavior — LancerSpearProjectile handles everything
+ if (lancerSpearProjectile.TryGetComponent(out ProjectileStickOnImpact stick))
+ Object.DestroyImmediate(stick);
+ else
+ Debug.LogError("[Lancer] Bandit2ShivProjectile clone missing ProjectileStickOnImpact");
+
+ if (lancerSpearProjectile.TryGetComponent(out ProjectileSingleTargetImpact singleTarget))
+ Object.DestroyImmediate(singleTarget);
+ else
+ Debug.LogError("[Lancer] Bandit2ShivProjectile clone missing ProjectileSingleTargetImpact");
+
+ if (lancerSpearProjectile.TryGetComponent(out RoR2.EntityLogic.DelayedEvent delayedEvent))
+ Object.DestroyImmediate(delayedEvent);
+
+ if (lancerSpearProjectile.TryGetComponent(out RoR2.EventFunctions eventFunctions))
+ Object.DestroyImmediate(eventFunctions);
+
+ // Configure ProjectileSimple
+ if (lancerSpearProjectile.TryGetComponent(out ProjectileSimple ps))
+ {
+ ps.desiredForwardSpeed = 80f;
+ ps.lifetime = 30f;
+ ps.updateAfterFiring = false;
+ }
+ else
+ {
+ Debug.LogError("[Lancer] Spear projectile missing ProjectileSimple");
+ }
+
+ // Disable gravity for linear travel
+ if (lancerSpearProjectile.TryGetComponent(out Rigidbody rb))
+ {
+ rb.useGravity = false;
+ }
+
+ // Add our custom spear behavior
+ lancerSpearProjectile.AddComponent();
+
+ EnsureNetworkIdentity(lancerSpearProjectile);
+
+ Debug.Log("[Lancer] Created LancerSpearProjectile prefab");
+ }
+
+ private void CreateIonFistProjectile()
+ {
+ var original = Addressables.LoadAssetAsync("RoR2/DLC2/Seeker/SpiritPunchProjectile.prefab").WaitForCompletion();
+ lancerIonFistProjectile = PrefabAPI.InstantiateClone(original, "LancerIonFistProjectile", true);
+
+ // Keep all Seeker VFX (ghost, dissipate, impact) — reusing vanilla assets
+ // Keep ProjectileOverlapAttack, trigger collider, HitBox child, fireFrequency=60
+ // Speed and lifetime already good (70 speed, 0.75s lifetime)
+
+ EnsureNetworkIdentity(lancerIonFistProjectile);
+
+ Debug.Log("[Lancer] Created LancerIonFistProjectile prefab (SpiritPunch clone)");
+ }
+
+ private void AssignEffects()
+ {
+ // Cache shared loads
+ var muzzleflashFMJ = Addressables.LoadAssetAsync("RoR2/Base/Commando/MuzzleflashFMJ.prefab").WaitForCompletion();
+ var omniImpactVFXSlash = Addressables.LoadAssetAsync("RoR2/Base/Common/VFX/OmniImpactVFXSlash.prefab").WaitForCompletion();
+
+ // ThrowSpear
+ EntityStates.Lancer.ThrowSpear.projectilePrefab = lancerSpearProjectile;
+ EntityStates.Lancer.ThrowSpear.muzzleflashEffectPrefab = muzzleflashFMJ;
+
+ // IonFistPunch
+ EntityStates.Lancer.IonFistPunch.projectilePrefab = lancerIonFistProjectile;
+ EntityStates.Lancer.IonFistPunch.muzzleflashEffectPrefab = Addressables.LoadAssetAsync("RoR2/DLC2/Seeker/SpiritPunchMuzzleFlashVFX.prefab").WaitForCompletion();
+
+ // IonDetonation
+ EntityStates.Lancer.IonDetonation.novaEffectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Vagrant/VagrantNovaExplosion.prefab").WaitForCompletion();
+ EntityStates.Lancer.IonDetonation.novaImpactEffectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Loader/OmniImpactVFXLoader.prefab").WaitForCompletion();
+
+ // IonFieldBehavior tick effect
+ Components.IonFieldBehavior.tickEffectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Common/VFX/OmniExplosionVFXQuick.prefab").WaitForCompletion();
+
+ // SpearThrust (bridging static → instance fields in OnEnter)
+ EntityStates.Lancer.SpearThrust.spearSwingEffectPrefab = Addressables.LoadAssetAsync("RoR2/Base/Merc/MercSwordSlash.prefab").WaitForCompletion();
+ EntityStates.Lancer.SpearThrust.spearHitEffectPrefab = omniImpactVFXSlash;
+
+ Debug.Log("[Lancer] All VFX/projectile references assigned");
+ }
+
+ private static void EnsureNetworkIdentity(GameObject prefab)
+ {
+ if (!prefab.GetComponent())
+ prefab.AddComponent();
+ }
+
+ private static void OnServerDamageDealt(DamageReport damageReport)
+ {
+ if (DamageAPI.HasModdedDamageType(damageReport.damageInfo, TipperIonFieldDamageType))
+ {
+ if (damageReport.victimBody)
+ {
+ damageReport.victimBody.AddTimedBuff(bdIonField, ionFieldDuration);
+ }
+ }
+ }
+
+ private void ModifyPrefab()
+ {
+ var cb = CharacterPrefab.GetComponent();
+ cb.preferredPodPrefab = Addressables.LoadAssetAsync("RoR2/Base/SurvivorPod/SurvivorPod.prefab").WaitForCompletion();
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/Characters/Variants/Toolbot.cs b/SS2-Project/Assets/Starstorm2/Modules/Characters/Variants/Toolbot.cs
index 7a17b1eb4..933db2216 100644
--- a/SS2-Project/Assets/Starstorm2/Modules/Characters/Variants/Toolbot.cs
+++ b/SS2-Project/Assets/Starstorm2/Modules/Characters/Variants/Toolbot.cs
@@ -17,20 +17,17 @@ public class Toolbot : SS2VanillaSurvivor
public override void Initialize()
{
- //GameObject toolbotBodyPrefab = Addressables.LoadAssetAsync("RoR2/Base/Toolbot/ToolbotBody.prefab").WaitForCompletion();
+ GameObject toolbotBodyPrefab = Addressables.LoadAssetAsync("RoR2/Base/Toolbot/ToolbotBody.prefab").WaitForCompletion();
- //SkillLocator skillLocator = toolbotBodyPrefab.GetComponent();
- //SkillFamily primarySkillFamily = skillLocator.primary.skillFamily;
- //SkillFamily secondarySkillFamily = skillLocator.secondary.skillFamily;
- //SkillFamily specialSkillFamily = skillLocator.special.skillFamily;
+ SkillLocator skillLocator = toolbotBodyPrefab.GetComponent();
+ SkillFamily secondarySkillFamily = skillLocator.secondary.skillFamily;
- //RepairOverlayPrefab = assetCollection.FindAsset("ToolbotRepairUI");
+ RepairOverlayPrefab = assetCollection.FindAsset("ToolbotRepairUI");
- //SelfRepairSkillDef sdSelfRepair = assetCollection.FindAsset("sdSelfRepair");
-
- //AddSkill(secondarySkillFamily, sdSelfRepair);
- //AddEntityStateMachine(toolbotBodyPrefab, "Toolbox"); // Used by skills like self-repair that dont want to interrupt weapon
+ SelfRepairSkillDef sdSelfRepair = assetCollection.FindAsset("sdSelfRepair");
+ AddSkill(secondarySkillFamily, sdSelfRepair);
+ AddEntityStateMachine(toolbotBodyPrefab, "Toolbox");
}
public override void ModifyContentPack(ContentPack contentPack)
@@ -44,4 +41,3 @@ public override bool IsAvailable(ContentPack contentPack)
}
}
}
-
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/AimDownSights.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/AimDownSights.cs
new file mode 100644
index 000000000..144ea193e
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/AimDownSights.cs
@@ -0,0 +1,160 @@
+using RoR2;
+using RoR2.Skills;
+using RoR2.UI;
+using UnityEngine;
+
+namespace EntityStates.Duke
+{
+ public class AimDownSights : BaseSkillState
+ {
+ private static float camEntryDuration = 0.2f;
+ private static float camExitDuration = 0.4f;
+ private static float overrideDelay = 0.175f;
+
+ private static string enterSoundString = "Play_bandit2_m2_charge";
+ private static string exitSoundString = "Play_bandit2_m2_uncharge";
+
+ public static GameObject crosshairOverridePrefab;
+
+ [SerializeField]
+ public SkillDef adsFireSkillDef;
+
+ private float overrideStopwatch;
+ private bool overrideSet;
+ private CrosshairUtils.OverrideRequest crosshairOverrideRequest;
+ private CameraTargetParams.CameraParamsOverrideHandle camOverrideHandle;
+ private SS2.Components.DukeController dukeController;
+
+ private CharacterCameraParamsData adsCameraParams = new CharacterCameraParamsData
+ {
+ pivotVerticalOffset = 1.37f,
+ idealLocalCameraPos = new Vector3(1.2f, -0.75f, -6.1f),
+ };
+
+ private CharacterCameraParamsData altCameraParams = new CharacterCameraParamsData
+ {
+ pivotVerticalOffset = 1.37f,
+ idealLocalCameraPos = new Vector3(-1.2f, -0.75f, -6.1f),
+ };
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+
+ Util.PlaySound(enterSoundString, gameObject);
+ PlayCrossfade("Gesture, Override", "AimDownSights", "Secondary.playbackRate", 0.5f, 0.3f);
+
+ characterBody.SetAimTimer(2f);
+
+ if (gameObject.TryGetComponent(out SS2.Components.DukeController controller))
+ {
+ dukeController = controller;
+ }
+ else
+ {
+ Debug.LogError("[Duke] AimDownSights: Failed to get DukeController component.");
+ }
+
+ ApplyCameraOverride();
+
+ if (skillLocator.secondary.stock > 0)
+ {
+ TrySetSkillOverride();
+ }
+
+ if (crosshairOverridePrefab)
+ {
+ crosshairOverrideRequest = CrosshairUtils.RequestOverrideForBody(characterBody, crosshairOverridePrefab, CrosshairUtils.OverridePriority.Skill);
+ }
+ }
+
+ private void ApplyCameraOverride()
+ {
+ bool useAlt = dukeController && dukeController.useAltCamera;
+
+ cameraTargetParams.RemoveParamsOverride(camOverrideHandle, camExitDuration);
+
+ var request = new CameraTargetParams.CameraParamsOverrideRequest
+ {
+ cameraParamsData = useAlt ? altCameraParams : adsCameraParams,
+ priority = 0f
+ };
+ camOverrideHandle = cameraTargetParams.AddParamsOverride(request, camEntryDuration);
+ }
+
+ private void TrySetSkillOverride()
+ {
+ overrideStopwatch += Time.fixedDeltaTime;
+ if (!overrideSet && overrideStopwatch >= overrideDelay)
+ {
+ overrideSet = true;
+ skillLocator.primary.SetSkillOverride(this, adsFireSkillDef, GenericSkill.SkillOverridePriority.Replacement);
+ }
+ }
+
+ private void UnsetSkillOverride()
+ {
+ overrideStopwatch = 0f;
+ if (overrideSet)
+ {
+ overrideSet = false;
+ skillLocator.primary.UnsetSkillOverride(this, adsFireSkillDef, GenericSkill.SkillOverridePriority.Replacement);
+ }
+ }
+
+ public override void Update()
+ {
+ base.Update();
+ if (Input.GetKeyDown(KeyCode.V) && isAuthority && dukeController)
+ {
+ dukeController.useAltCamera = !dukeController.useAltCamera;
+ ApplyCameraOverride();
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+
+ characterBody.isSprinting = false;
+ characterBody.SetAimTimer(2f);
+
+ if (skillLocator.secondary.stock > 0)
+ {
+ TrySetSkillOverride();
+ }
+ else
+ {
+ UnsetSkillOverride();
+ }
+
+ if (isAuthority && !inputBank.skill2.down)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override void OnExit()
+ {
+ base.OnExit();
+ UnsetSkillOverride();
+ PlayCrossfade("Gesture, Override", "BufferEmpty", "Secondary.playbackRate", 0.3f, 0.3f);
+ Util.PlaySound(exitSoundString, gameObject);
+
+ if (cameraTargetParams)
+ {
+ cameraTargetParams.RemoveParamsOverride(camOverrideHandle, camExitDuration);
+ }
+
+ if (crosshairOverrideRequest != null)
+ {
+ crosshairOverrideRequest.Dispose();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.PrioritySkill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/DiveRoll.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/DiveRoll.cs
new file mode 100644
index 000000000..cb0543867
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/DiveRoll.cs
@@ -0,0 +1,197 @@
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Duke
+{
+ public class DiveRoll : BaseSkillState
+ {
+ // Roll phase
+ private static float rollDuration = 0.6f;
+ private static float rollInitialSpeedCoefficient = 6f;
+ private static float rollFinalSpeedCoefficient = 1.5f;
+
+ // Slide phase
+ private static float maxSlideDuration = 1.2f;
+ private static float slideInitialSpeedCoefficient = 4f;
+ private static float slideFinalSpeedCoefficient = 1f;
+
+ private static string rollSoundString = "";
+
+ private float rollSpeed;
+ private Vector3 forwardDirection;
+ private Vector3 previousPosition;
+ private Animator animator;
+
+ private bool inSlidePhase;
+ private float slideAge;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+
+ animator = GetModelAnimator();
+
+ Util.PlaySound(Commando.DodgeState.dodgeSoundString, gameObject);
+ if (rollSoundString.Length > 0)
+ Util.PlaySound(rollSoundString, gameObject);
+
+ PlayCrossfade("Body", "Utility", "Utility.rate", rollDuration * 1.25f, 0.05f);
+ PlayCrossfade("Gesture, Override", "BufferEmpty", 0.05f);
+ PlayCrossfade("FullBody, Override", "BufferEmpty", 0.05f);
+
+ if (NetworkServer.active)
+ characterBody.AddTimedBuff(RoR2Content.Buffs.ArmorBoost, rollDuration);
+
+ if (isAuthority && inputBank && characterDirection)
+ {
+ forwardDirection = ((inputBank.moveVector == Vector3.zero) ? characterDirection.forward : inputBank.moveVector).normalized;
+ }
+
+ inSlidePhase = false;
+ slideAge = 0f;
+ RecalculateSpeed();
+
+ if (characterMotor && characterDirection)
+ {
+ characterMotor.velocity = forwardDirection * rollSpeed;
+ characterMotor.velocity.y = 0f;
+ }
+
+ Vector3 velocity = characterMotor ? characterMotor.velocity : Vector3.zero;
+ previousPosition = transform.position - velocity;
+ }
+
+ private void RecalculateSpeed()
+ {
+ if (inSlidePhase)
+ {
+ float t = Mathf.Clamp01(slideAge / maxSlideDuration);
+ rollSpeed = moveSpeedStat * Mathf.Lerp(slideInitialSpeedCoefficient, slideFinalSpeedCoefficient, t);
+ }
+ else
+ {
+ rollSpeed = moveSpeedStat * Mathf.Lerp(rollInitialSpeedCoefficient, rollFinalSpeedCoefficient, fixedAge / rollDuration);
+ }
+ }
+
+ private void TransitionToSlide()
+ {
+ inSlidePhase = true;
+ slideAge = 0f;
+
+ PlayCrossfade("Body", "Slide", 0.1f);
+ Util.PlaySound("Play_commando_shift", gameObject);
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ RecalculateSpeed();
+
+ characterBody.isSprinting = true;
+
+ if (isAuthority)
+ {
+ if (inSlidePhase)
+ {
+ slideAge += Time.fixedDeltaTime;
+
+ // Steer during slide
+ if (inputBank && characterDirection)
+ {
+ Vector3 moveInput = inputBank.moveVector;
+ if (moveInput != Vector3.zero)
+ {
+ forwardDirection = Vector3.Lerp(forwardDirection, moveInput.normalized, 3f * Time.fixedDeltaTime).normalized;
+ }
+ }
+ }
+
+ Vector3 normalized = (transform.position - previousPosition).normalized;
+ if (characterMotor && characterDirection && normalized != Vector3.zero)
+ {
+ Vector3 vector = normalized * rollSpeed;
+ float y = vector.y;
+ vector.y = 0f;
+ float d = Mathf.Max(Vector3.Dot(vector, forwardDirection), 0f);
+ vector = forwardDirection * d;
+ vector.y += Mathf.Max(y, 0f);
+ characterMotor.velocity = vector;
+ }
+
+ if (animator)
+ {
+ Vector3 rhs = inputBank ? characterDirection.forward : forwardDirection;
+ Vector3 rhs2 = Vector3.Cross(Vector3.up, rhs);
+ float num = Vector3.Dot(forwardDirection, rhs);
+ float num2 = Vector3.Dot(forwardDirection, rhs2);
+ animator.SetFloat("forwardSpeed", num);
+ animator.SetFloat("rightSpeed", num2);
+ }
+
+ previousPosition = transform.position;
+
+ // Phase transition and exit logic
+ if (!inSlidePhase && fixedAge >= rollDuration)
+ {
+ if (inputBank.skill3.down)
+ {
+ TransitionToSlide();
+ }
+ else
+ {
+ outer.SetNextStateToMain();
+ return;
+ }
+ }
+
+ if (inSlidePhase)
+ {
+ if (!inputBank.skill3.down || slideAge >= maxSlideDuration)
+ {
+ outer.SetNextStateToMain();
+ return;
+ }
+ }
+ }
+ }
+
+ public override void OnExit()
+ {
+ // Reload M2 stocks
+ if (skillLocator && skillLocator.secondary)
+ {
+ skillLocator.secondary.Reset();
+ Debug.Log("[Duke] DiveRoll: Restocked secondary (" + skillLocator.secondary.maxStock + " rounds).");
+ }
+
+ if (animator)
+ {
+ animator.SetBool("isRolling", false);
+ }
+
+ if (cameraTargetParams)
+ cameraTargetParams.fovOverride = -1f;
+
+ base.OnExit();
+ }
+
+ public override void OnSerialize(NetworkWriter writer)
+ {
+ base.OnSerialize(writer);
+ writer.Write(forwardDirection);
+ }
+
+ public override void OnDeserialize(NetworkReader reader)
+ {
+ base.OnDeserialize(reader);
+ forwardDirection = reader.ReadVector3();
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.Frozen;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireADSShot.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireADSShot.cs
new file mode 100644
index 000000000..638f6daff
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireADSShot.cs
@@ -0,0 +1,102 @@
+using RoR2;
+using UnityEngine;
+
+namespace EntityStates.Duke
+{
+ public class FireADSShot : BaseSkillState
+ {
+ private static float damageCoefficient = 4f;
+ private static float procCoefficient = 1f;
+ private static float baseDuration = 0.4f;
+ private static float recoil = 4f;
+ private static float spreadBloom = 0.3f;
+ private static float force = 300f;
+ private static float bulletRadius = 0.4f;
+ private static float range = 256f;
+ private static string muzzleString = "Muzzle";
+
+ public static GameObject muzzleEffectPrefab;
+ public static GameObject tracerPrefab;
+ public static GameObject hitPrefab;
+
+ private float duration;
+ private bool hasFired;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ characterBody.SetAimTimer(2f);
+ hasFired = false;
+ PlayAnimation("Gesture, Override", "FireADS");
+ Fire();
+ }
+
+ private void Fire()
+ {
+ if (hasFired) return;
+ hasFired = true;
+
+ if (skillLocator.secondary.stock <= 0) return;
+ skillLocator.secondary.DeductStock(1);
+ characterBody.OnSkillActivated(skillLocator.secondary);
+
+ bool isCrit = RollCrit();
+ Util.PlayAttackSpeedSound("Play_railgunner_m1_fire", gameObject, attackSpeedStat);
+ AddRecoil(-0.6f * recoil, -1f * recoil, -0.3f * recoil, 0.3f * recoil);
+
+ if (muzzleEffectPrefab)
+ EffectManager.SimpleMuzzleFlash(muzzleEffectPrefab, gameObject, muzzleString, false);
+
+ if (isAuthority)
+ {
+ Ray aimRay = GetAimRay();
+
+ DamageTypeCombo damageType = DamageType.Generic;
+ damageType.damageSource = DamageSource.Secondary;
+
+ var bulletAttack = new BulletAttack
+ {
+ aimVector = aimRay.direction,
+ origin = aimRay.origin,
+ damage = damageCoefficient * damageStat,
+ damageType = damageType,
+ damageColorIndex = DamageColorIndex.Default,
+ minSpread = 0f,
+ maxSpread = 0f,
+ falloffModel = BulletAttack.FalloffModel.None,
+ maxDistance = range,
+ force = force,
+ isCrit = isCrit,
+ owner = gameObject,
+ muzzleName = muzzleString,
+ smartCollision = true,
+ procChainMask = default,
+ procCoefficient = procCoefficient,
+ radius = bulletRadius,
+ weapon = gameObject,
+ tracerEffectPrefab = tracerPrefab,
+ hitEffectPrefab = hitPrefab,
+ sniper = true // BulletAttack auto-crits on sniper target hits (sets isCrit + DamageColorIndex.Sniper)
+ };
+
+ bulletAttack.Fire();
+ }
+ characterBody.AddSpreadBloom(spreadBloom);
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.Skill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireDeagle.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireDeagle.cs
new file mode 100644
index 000000000..12a0e4e2b
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/FireDeagle.cs
@@ -0,0 +1,96 @@
+using RoR2;
+using UnityEngine;
+
+namespace EntityStates.Duke
+{
+ public class FireDeagle : BaseSkillState
+ {
+ private static float damageCoefficient = 1.8f;
+ private static float procCoefficient = 1f;
+ private static float baseDuration = 0.5f;
+ private static float recoil = 3f;
+ private static float spreadBloom = 0.5f;
+ private static float force = 200f;
+ private static float bulletRadius = 1f;
+ private static float range = 256f;
+ private static string muzzleString = "Muzzle";
+
+ public static GameObject muzzleEffectPrefab;
+ public static GameObject tracerPrefab;
+ public static GameObject hitPrefab;
+
+ private float duration;
+ private bool hasFired;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ characterBody.SetAimTimer(2f);
+ hasFired = false;
+ PlayAnimation("Gesture, Override", "Primary");
+ Fire();
+ }
+
+ private void Fire()
+ {
+ if (hasFired) return;
+ hasFired = true;
+
+ bool isCrit = RollCrit();
+ Util.PlayAttackSpeedSound("Play_bandit2_m1_rifle", gameObject, attackSpeedStat);
+ AddRecoil(-0.4f * recoil, -0.8f * recoil, -0.3f * recoil, 0.3f * recoil);
+
+ if (muzzleEffectPrefab)
+ EffectManager.SimpleMuzzleFlash(muzzleEffectPrefab, gameObject, muzzleString, false);
+
+ if (isAuthority)
+ {
+ Ray aimRay = GetAimRay();
+
+ DamageTypeCombo damageType = DamageType.Generic;
+ damageType.damageSource = DamageSource.Primary;
+
+ var bulletAttack = new BulletAttack
+ {
+ aimVector = aimRay.direction,
+ origin = aimRay.origin,
+ damage = damageCoefficient * damageStat,
+ damageType = damageType,
+ damageColorIndex = DamageColorIndex.Default,
+ minSpread = 0f,
+ maxSpread = characterBody.spreadBloomAngle,
+ falloffModel = BulletAttack.FalloffModel.DefaultBullet,
+ maxDistance = range,
+ force = force,
+ isCrit = isCrit,
+ owner = gameObject,
+ muzzleName = muzzleString,
+ smartCollision = true,
+ procChainMask = default,
+ procCoefficient = procCoefficient,
+ radius = bulletRadius,
+ weapon = gameObject,
+ tracerEffectPrefab = tracerPrefab,
+ hitEffectPrefab = hitPrefab
+ };
+ bulletAttack.Fire();
+ }
+ characterBody.AddSpreadBloom(spreadBloom);
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.Skill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/StunCone.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/StunCone.cs
new file mode 100644
index 000000000..4c5598951
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Duke/StunCone.cs
@@ -0,0 +1,97 @@
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Duke
+{
+ public class StunCone : BaseSkillState
+ {
+ private static float baseDuration = 0.8f;
+ private static float stunDuration = 2f;
+ private static float coneAngle = 60f;
+ private static float coneRange = 20f;
+ private static string soundString = "Play_loader_m1_swing";
+
+ public static GameObject effectPrefab;
+
+ private float duration;
+ private bool hasFired;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ hasFired = false;
+
+ Util.PlayAttackSpeedSound(soundString, gameObject, attackSpeedStat);
+ PlayAnimation("Gesture, Override", "StunCone", "StunCone.playbackRate", duration);
+ characterBody.SetAimTimer(2f);
+
+ FireCone();
+ }
+
+ private void FireCone()
+ {
+ if (hasFired) return;
+ hasFired = true;
+
+ if (effectPrefab)
+ {
+ EffectManager.SimpleMuzzleFlash(effectPrefab, gameObject, "Muzzle", false);
+ }
+
+ if (!NetworkServer.active) return;
+
+ Ray aimRay = GetAimRay();
+
+ var search = new BullseyeSearch();
+ search.teamMaskFilter = TeamMask.GetEnemyTeams(GetTeam());
+ search.filterByLoS = true;
+ search.maxDistanceFilter = coneRange;
+ search.minAngleFilter = 0f;
+ search.maxAngleFilter = coneAngle;
+ search.sortMode = BullseyeSearch.SortMode.Distance;
+ search.filterByDistinctEntity = true;
+ search.searchOrigin = aimRay.origin;
+ search.searchDirection = aimRay.direction;
+ search.RefreshCandidates();
+
+ foreach (HurtBox hurtBox in search.GetResults())
+ {
+ if (!hurtBox || !hurtBox.healthComponent || !hurtBox.healthComponent.alive) continue;
+
+ CharacterBody victimBody = hurtBox.healthComponent.body;
+ if (!victimBody) continue;
+
+ // Apply Duke stun buff (marker for ricochet)
+ if (SS2.Survivors.Duke.bdDukeStun)
+ victimBody.AddTimedBuff(SS2.Survivors.Duke.bdDukeStun, stunDuration);
+
+ // Apply actual stun state via SetStateOnHurt
+ if (victimBody.TryGetComponent(out SetStateOnHurt setStateOnHurt))
+ {
+ if (setStateOnHurt.canBeStunned)
+ {
+ setStateOnHurt.SetStun(stunDuration);
+ }
+ }
+
+ Debug.Log("[Duke] StunCone: Stunned " + victimBody.name + " for " + stunDuration + "s.");
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.PrioritySkill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/Dodge.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/Dodge.cs
new file mode 100644
index 000000000..447e0a982
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/Dodge.cs
@@ -0,0 +1,122 @@
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Lancer
+{
+ public class Dodge : BaseSkillState
+ {
+ private static float duration = 0.5f;
+ private static float initialSpeedCoefficient = 6.5f;
+ private static float finalSpeedCoefficient = 1.5f;
+ public static float dodgeFOV = -1f;
+
+ private float rollSpeed;
+ private Vector3 forwardDirection;
+ private Vector3 previousPosition;
+ private Animator animator;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+
+ animator = GetModelAnimator();
+
+ Util.PlaySound(Commando.DodgeState.dodgeSoundString, gameObject);
+
+ animator.SetBool("isRolling", true);
+
+ PlayCrossfade("Body", "Utility", "Utility.rate", duration * 1.25f, 0.05f);
+ PlayCrossfade("Gesture, Override", "BufferEmpty", 0.05f);
+ PlayCrossfade("FullBody, Override", "BufferEmpty", 0.05f);
+ PlayCrossfade("Gesture, Override, LeftArm", "BufferEmpty", 0.05f);
+ PlayCrossfade("Gesture, Additive, LeftArm", "BufferEmpty", 0.05f);
+
+ if (NetworkServer.active) characterBody.AddTimedBuff(RoR2Content.Buffs.ArmorBoost, 1.5f * duration);
+
+ if (isAuthority && inputBank && characterDirection)
+ {
+ forwardDirection = ((inputBank.moveVector == Vector3.zero) ? characterDirection.forward : inputBank.moveVector).normalized;
+ }
+
+ RecalculateRollSpeed();
+ if (characterMotor && characterDirection)
+ {
+ characterMotor.velocity = forwardDirection * rollSpeed;
+ characterMotor.velocity.y = 0f;
+ }
+
+ Vector3 velocity = characterMotor ? characterMotor.velocity : Vector3.zero;
+ previousPosition = transform.position - velocity;
+ }
+
+ private void RecalculateRollSpeed()
+ {
+ rollSpeed = moveSpeedStat * Mathf.Lerp(initialSpeedCoefficient, finalSpeedCoefficient, fixedAge / duration);
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ RecalculateRollSpeed();
+
+ base.characterBody.isSprinting = true;
+
+ if (isAuthority)
+ {
+ Vector3 normalized = (transform.position - previousPosition).normalized;
+ if (characterMotor && characterDirection && normalized != Vector3.zero)
+ {
+ Vector3 vector = normalized * rollSpeed;
+ float y = vector.y;
+ vector.y = 0f;
+ float d = Mathf.Max(Vector3.Dot(vector, forwardDirection), 0f);
+ vector = forwardDirection * d;
+ vector.y += Mathf.Max(y, 0f);
+ characterMotor.velocity = vector;
+
+ Vector3 rhs = inputBank ? characterDirection.forward : forwardDirection;
+ Vector3 rhs2 = Vector3.Cross(Vector3.up, rhs);
+ float num = Vector3.Dot(forwardDirection, rhs);
+ float num2 = Vector3.Dot(forwardDirection, rhs2);
+ animator.SetFloat("forwardSpeed", num);
+ animator.SetFloat("rightSpeed", num2);
+ }
+
+ previousPosition = transform.position;
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ return;
+ }
+ }
+ }
+
+ public override void OnExit()
+ {
+ animator.SetBool("isRolling", false);
+ animator.SetBool("shouldExit", true);
+
+ if (cameraTargetParams)
+ cameraTargetParams.fovOverride = -1f;
+ base.OnExit();
+ }
+
+ public override void OnSerialize(NetworkWriter writer)
+ {
+ base.OnSerialize(writer);
+ writer.Write(forwardDirection);
+ }
+
+ public override void OnDeserialize(NetworkReader reader)
+ {
+ base.OnDeserialize(reader);
+ forwardDirection = reader.ReadVector3();
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.Frozen;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonDetonation.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonDetonation.cs
new file mode 100644
index 000000000..79530d7e3
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonDetonation.cs
@@ -0,0 +1,109 @@
+using RoR2;
+using SS2.Survivors;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Lancer
+{
+ public class IonDetonation : BaseSkillState
+ {
+ public static float baseDuration = 1f;
+ public static float novaDamageCoefficient = 8f;
+ public static float novaRadius = 12f;
+ public static float novaForce = 2000f;
+ public static float novaProcCoefficient = 1f;
+ public static GameObject novaEffectPrefab;
+ public static GameObject novaImpactEffectPrefab;
+ public static string novaSoundString = "";
+
+ private float duration;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ PlayAnimation("Gesture, Override", "IonDetonation", "IonDetonation.playbackRate", duration);
+ Detonate();
+ }
+
+ private void Detonate()
+ {
+ Vector3 detonationPosition;
+ if (gameObject.TryGetComponent(out SS2.Components.LancerController lancerController))
+ {
+ detonationPosition = lancerController.GetSpearPosition();
+ }
+ else
+ {
+ Debug.LogError("IonDetonation: Failed to get LancerController component.");
+ detonationPosition = transform.position;
+ }
+
+ Util.PlaySound(novaSoundString, gameObject);
+
+ if (novaEffectPrefab)
+ {
+ EffectManager.SpawnEffect(novaEffectPrefab, new EffectData
+ {
+ origin = detonationPosition,
+ scale = novaRadius
+ }, false);
+ }
+
+ if (NetworkServer.active)
+ {
+ BlastAttack blastAttack = new BlastAttack();
+ blastAttack.attacker = gameObject;
+ blastAttack.baseDamage = damageStat * novaDamageCoefficient;
+ blastAttack.baseForce = novaForce;
+ blastAttack.bonusForce = Vector3.zero;
+ blastAttack.attackerFiltering = AttackerFiltering.NeverHitSelf;
+ blastAttack.crit = characterBody.RollCrit();
+ blastAttack.damageColorIndex = DamageColorIndex.Default;
+ blastAttack.damageType = DamageType.Generic;
+ blastAttack.damageType.damageSource = DamageSource.Special;
+ blastAttack.falloffModel = BlastAttack.FalloffModel.None;
+ blastAttack.inflictor = gameObject;
+ blastAttack.position = detonationPosition;
+ blastAttack.procCoefficient = novaProcCoefficient;
+ blastAttack.radius = novaRadius;
+ blastAttack.teamIndex = teamComponent.teamIndex;
+ if (novaImpactEffectPrefab)
+ {
+ blastAttack.impactEffect = EffectCatalog.FindEffectIndexFromPrefab(novaImpactEffectPrefab);
+ }
+ blastAttack.Fire();
+
+ SphereSearch sphereSearch = new SphereSearch();
+ sphereSearch.origin = detonationPosition;
+ sphereSearch.radius = novaRadius;
+ sphereSearch.mask = LayerIndex.entityPrecise.mask;
+ sphereSearch.RefreshCandidates();
+ sphereSearch.FilterCandidatesByHurtBoxTeam(TeamMask.GetEnemyTeams(teamComponent.teamIndex));
+ sphereSearch.FilterCandidatesByDistinctHurtBoxEntities();
+ HurtBox[] hurtBoxes = sphereSearch.GetHurtBoxes();
+ foreach (HurtBox hurtBox in hurtBoxes)
+ {
+ if (hurtBox.healthComponent && hurtBox.healthComponent.body)
+ {
+ hurtBox.healthComponent.body.AddTimedBuff(Lancer.bdIonField, Lancer.ionFieldDuration);
+ }
+ }
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.PrioritySkill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonFistPunch.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonFistPunch.cs
new file mode 100644
index 000000000..15e529543
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/IonFistPunch.cs
@@ -0,0 +1,103 @@
+using RoR2;
+using RoR2.Projectile;
+using RoR2.Skills;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Lancer
+{
+ public class IonFistPunch : BaseState, SteppedSkillDef.IStepSetter
+ {
+ public enum Gauntlet
+ {
+ Left,
+ Right
+ }
+
+ public static GameObject projectilePrefab;
+ public static GameObject muzzleflashEffectPrefab;
+ public static float baseDuration = 0.4f;
+ public static float damageCoefficient = 1.5f;
+ public static float force = 20f;
+ public static float procCoefficient = 1f;
+ public static string attackSoundString = "";
+
+ private float duration;
+ private string muzzleString;
+ private Gauntlet gauntlet;
+
+ public void SetStep(int i)
+ {
+ gauntlet = (Gauntlet)i;
+ }
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ StartAimMode(2f);
+
+ Util.PlaySound(attackSoundString, gameObject);
+
+ switch (gauntlet)
+ {
+ case Gauntlet.Left:
+ muzzleString = "FistLeft";
+ PlayCrossfade("Gesture, Override", "PunchLeft", "Punch.playbackRate", duration, 0.1f);
+ break;
+ case Gauntlet.Right:
+ muzzleString = "FistRight";
+ PlayCrossfade("Gesture, Override", "PunchRight", "Punch.playbackRate", duration, 0.1f);
+ break;
+ }
+
+ if (isAuthority && projectilePrefab)
+ {
+ Ray aimRay = GetAimRay();
+ FireProjectileInfo fireProjectileInfo = new FireProjectileInfo
+ {
+ projectilePrefab = projectilePrefab,
+ position = FindModelChild(muzzleString)?.position ?? aimRay.origin,
+ rotation = Util.QuaternionSafeLookRotation(aimRay.direction),
+ owner = gameObject,
+ damage = damageStat * damageCoefficient,
+ force = force,
+ crit = RollCrit(),
+ damageTypeOverride = new DamageTypeCombo(DamageType.Generic, DamageTypeExtended.Generic, DamageSource.Primary)
+ };
+ ProjectileManager.instance.FireProjectile(fireProjectileInfo);
+ }
+
+ if (muzzleflashEffectPrefab)
+ {
+ EffectManager.SimpleMuzzleFlash(muzzleflashEffectPrefab, gameObject, muzzleString, transmit: false);
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.Skill;
+ }
+
+ public override void OnSerialize(NetworkWriter writer)
+ {
+ base.OnSerialize(writer);
+ writer.Write((byte)gauntlet);
+ }
+
+ public override void OnDeserialize(NetworkReader reader)
+ {
+ base.OnDeserialize(reader);
+ gauntlet = (Gauntlet)reader.ReadByte();
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/RecallSpear.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/RecallSpear.cs
new file mode 100644
index 000000000..971d0de29
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/RecallSpear.cs
@@ -0,0 +1,62 @@
+using RoR2;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Lancer
+{
+ public class RecallSpear : BaseSkillState
+ {
+ public static float baseDuration = 0.3f;
+ public static string recallSoundString = "";
+
+ private float duration;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+
+ PlayCrossfade("Gesture, Override", "RecallSpear", "RecallSpear.playbackRate", duration, 0.1f);
+ Util.PlaySound(recallSoundString, gameObject);
+
+ if (gameObject.TryGetComponent(out SS2.Components.LancerController lancerController))
+ {
+ if (NetworkServer.active)
+ {
+ GameObject spearProjectile = lancerController.GetSpearProjectile();
+ if (spearProjectile)
+ {
+ if (spearProjectile.TryGetComponent(out SS2.Components.LancerSpearProjectile spearComponent))
+ {
+ spearComponent.BeginReturn();
+ }
+ else
+ {
+ Debug.LogError("RecallSpear: Spear projectile missing LancerSpearProjectile component.");
+ }
+ }
+
+ lancerController.SetSpearState(SS2.Components.LancerController.SpearState.Returning);
+ }
+ }
+ else
+ {
+ Debug.LogError("RecallSpear: Failed to get LancerController component.");
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.PrioritySkill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/SpearThrust.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/SpearThrust.cs
new file mode 100644
index 000000000..19bb405f4
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/SpearThrust.cs
@@ -0,0 +1,79 @@
+using EntityStates.Knight;
+using R2API;
+using RoR2;
+using UnityEngine;
+
+namespace EntityStates.Lancer
+{
+ public class SpearThrust : BaseKnightMeleeAttack
+ {
+ public static float tipperDamageCoefficient = 2f;
+ public static string thrustAnimationState = "SpearThrust";
+
+ // Static VFX fields assigned from Lancer.cs Initialize()
+ // BaseKnightMeleeAttack's swingEffectPrefab/hitEffectPrefab are [SerializeField] instance fields,
+ // so we bridge them via static fields set in OnEnter before base.OnEnter().
+ public static GameObject spearSwingEffectPrefab;
+ public static GameObject spearHitEffectPrefab;
+
+ private OverlapAttack tipperAttack;
+
+ public override void OnEnter()
+ {
+ hitboxGroupName = "SpearHitbox";
+ muzzleString = "SpearMuzzle";
+ swingEffectPrefab = spearSwingEffectPrefab;
+ hitEffectPrefab = spearHitEffectPrefab;
+
+ base.OnEnter();
+
+ this.attack.damageType.damageSource = DamageSource.Primary;
+
+ HitBoxGroup tipperHitboxGroup = FindHitBoxGroup("TipperHitbox");
+ if (tipperHitboxGroup)
+ {
+ tipperAttack = new OverlapAttack();
+ tipperAttack.damageType = attack.damageType;
+ tipperAttack.attacker = gameObject;
+ tipperAttack.inflictor = gameObject;
+ tipperAttack.teamIndex = GetTeam();
+ tipperAttack.damage = tipperDamageCoefficient * damageStat;
+ tipperAttack.procCoefficient = procCoefficient;
+ tipperAttack.hitEffectPrefab = hitEffectPrefab;
+ tipperAttack.forceVector = bonusForce;
+ tipperAttack.pushAwayForce = pushForce;
+ tipperAttack.hitBoxGroup = tipperHitboxGroup;
+ tipperAttack.isCrit = attack.isCrit;
+ tipperAttack.maximumOverlapTargets = 1000;
+ tipperAttack.AddModdedDamageType(SS2.Survivors.Lancer.TipperIonFieldDamageType);
+ if (impactSound != null)
+ {
+ tipperAttack.impactSound = impactSound.index;
+ }
+ }
+ else
+ {
+ Debug.LogError("SpearThrust: TipperHitbox HitBoxGroup not found on model.");
+ }
+ }
+
+ public override void PlayAttackAnimation()
+ {
+ if (isGrounded && !animator.GetBool("isMoving"))
+ {
+ PlayCrossfade("FullBody, Override", thrustAnimationState, playbackRateParam, duration, 0.08f);
+ }
+ PlayCrossfade("Gesture, Override", thrustAnimationState, playbackRateParam, duration, 0.08f);
+ }
+
+ protected override void FireAttack()
+ {
+ base.FireAttack();
+
+ if (isAuthority && tipperAttack != null)
+ {
+ tipperAttack.Fire();
+ }
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/ThrowSpear.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/ThrowSpear.cs
new file mode 100644
index 000000000..f3f9521b2
--- /dev/null
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Lancer/ThrowSpear.cs
@@ -0,0 +1,86 @@
+using RoR2;
+using RoR2.Projectile;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace EntityStates.Lancer
+{
+ public class ThrowSpear : BaseSkillState
+ {
+ public static float baseDuration = 0.5f;
+ public static float damageCoefficient = 4f;
+ public static float force = 40f;
+ public static float procCoefficient = 1f;
+ public static GameObject projectilePrefab;
+ public static GameObject muzzleflashEffectPrefab;
+ public static string throwSoundString = "";
+ public static string muzzleString = "SpearMuzzle";
+
+ private float duration;
+ private bool hasFired;
+
+ public override void OnEnter()
+ {
+ base.OnEnter();
+ duration = baseDuration / attackSpeedStat;
+ StartAimMode(2f);
+ PlayCrossfade("Gesture, Override", "ThrowSpear", "ThrowSpear.playbackRate", duration, 0.1f);
+ Util.PlayAttackSpeedSound(throwSoundString, gameObject, attackSpeedStat);
+
+ if (muzzleflashEffectPrefab)
+ EffectManager.SimpleMuzzleFlash(muzzleflashEffectPrefab, gameObject, muzzleString, false);
+
+ FireSpear();
+ }
+
+ private void FireSpear()
+ {
+ if (hasFired)
+ return;
+ hasFired = true;
+
+ if (isAuthority && projectilePrefab)
+ {
+ Ray aimRay = GetAimRay();
+ FireProjectileInfo fireProjectileInfo = new FireProjectileInfo
+ {
+ projectilePrefab = projectilePrefab,
+ position = FindModelChild(muzzleString)?.position ?? aimRay.origin,
+ rotation = Util.QuaternionSafeLookRotation(aimRay.direction),
+ owner = gameObject,
+ damage = damageStat * damageCoefficient,
+ force = force,
+ crit = RollCrit(),
+ damageTypeOverride = new DamageTypeCombo(DamageType.SlowOnHit, DamageTypeExtended.Generic, DamageSource.Secondary)
+ };
+ ProjectileManager.instance.FireProjectile(fireProjectileInfo);
+ }
+
+ if (gameObject.TryGetComponent(out SS2.Components.LancerController lancerController))
+ {
+ if (NetworkServer.active)
+ {
+ lancerController.SetSpearState(SS2.Components.LancerController.SpearState.Thrown);
+ }
+ }
+ else
+ {
+ Debug.LogError("ThrowSpear: Failed to get LancerController component.");
+ }
+ }
+
+ public override void FixedUpdate()
+ {
+ base.FixedUpdate();
+ if (fixedAge >= duration && isAuthority)
+ {
+ outer.SetNextStateToMain();
+ }
+ }
+
+ public override InterruptPriority GetMinimumInterruptPriority()
+ {
+ return InterruptPriority.PrioritySkill;
+ }
+ }
+}
diff --git a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Toolbot/SelfRepair.cs b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Toolbot/SelfRepair.cs
index 9fce33925..7394afeeb 100644
--- a/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Toolbot/SelfRepair.cs
+++ b/SS2-Project/Assets/Starstorm2/Modules/EntityStates/Toolbot/SelfRepair.cs
@@ -1,35 +1,49 @@
using SS2.Components;
+using UnityEngine;
+using UnityEngine.Networking;
namespace EntityStates.Toolbot
{
public class SelfRepair : BaseSkillState
{
- public float baseDuration = 1f;
- private float duration = 3f;
- private float healthGainPerRepair = 0.05f;
- private float repairLossPerTick = 1f;
+ public static float baseDuration = 3f;
+ public static float healthFractionPerTick = 0.05f;
+ public static float repairCostPerTick = 1f;
+ public static float tickInterval = 0.5f;
+ private float duration;
+ private float tickTimer;
private SelfRepairController selfRepairController;
public override void OnEnter()
{
base.OnEnter();
- selfRepairController = GetComponent();
+ duration = baseDuration / attackSpeedStat;
+
+ if (!gameObject.TryGetComponent(out selfRepairController))
+ {
+ Debug.LogError("SelfRepair: Missing SelfRepairController on " + gameObject.name);
+ }
}
public override void FixedUpdate()
{
base.FixedUpdate();
- if (base.fixedAge >= duration && selfRepairController.repair <= repairLossPerTick)
+ if (NetworkServer.active && selfRepairController != null)
{
- outer.SetNextStateToMain();
+ tickTimer -= Time.fixedDeltaTime;
+ if (tickTimer <= 0f && selfRepairController.repair >= repairCostPerTick)
+ {
+ tickTimer = tickInterval;
+ characterBody.healthComponent.HealFraction(healthFractionPerTick, default);
+ selfRepairController.AddRepair(-repairCostPerTick);
+ }
}
- if (selfRepairController && base.isAuthority && selfRepairController.repair >= repairLossPerTick)
+ if (isAuthority && (fixedAge >= duration || selfRepairController == null || selfRepairController.repair < repairCostPerTick))
{
- this.characterBody.healthComponent.HealFraction(healthGainPerRepair, default);
- selfRepairController.AddRepair(-repairLossPerTick);
+ outer.SetNextStateToMain();
}
}