Player/TopDownPlayer.cs
using System;
using System.Linq;
namespace Prototype;
public sealed class FpsPlayer : Component
{
[Property] public bool ThirdPersonView { get; set; }
public Vector3 VisualMuzzlePosition => Components.Get<DasherBodyVisual>() is { Ready: true } body ? body.MuzzlePosition : _viewWeapon.IsValid() && !ThirdPersonView
? _viewWeapon.WorldPosition + _viewWeapon.WorldRotation.Right * (Weapon?.Spec.Id == "scatter" ? 49f : 61f)
: EyePosition + EyeRotation.Forward * 38f + EyeRotation.Down * 13f;
[Sync] public string BodyAnimation { get; private set; } = "idle";
[Sync] public string BodyWeaponId { get; private set; } = "rifle";
[Sync] public float WeaponSwitchProgress { get; private set; }
public bool SwitchingWeapon => _switchLeft > 0f;
private float _switchLeft;
private bool _switchCommitted;
[Sync] public float BodyReloadProgress { get; private set; }
private float _bodyAttackUntil;
[Sync(SyncFlags.FromHost)] public Guid ParticipantId { get; set; }
[Sync] public string PlayerName { get; set; } = "Runner";
public RunBuffs Buffs => Components.Get<RunBuffs>();
public float MoveSpeed { get; set; } = 280f;
public float DashSpeed { get; set; } = 1180f;
public float DashDuration { get; set; } = .24f;
public float DashCooldown { get; set; } = 1.35f;
[Sync] public float DamageMultiplier { get; private set; } = 1f;
[Sync] public float FireRateMultiplier { get; private set; } = 1f;
public float SpeedMultiplier { get; private set; } = 1f;
public HealthComponent Vitality { get; private set; }
public WeaponController Weapon { get; private set; }
public HeroSpec Hero { get; private set; }
public float UltimateCooldown => MathF.Max( 0f, _ultimateCooldown );
public float DashCooldownRemaining => MathF.Max( 0f, _dashLeft );
public float DashReadyFraction => 1f - Math.Clamp( DashCooldownRemaining / MathF.Max( .01f, DashCooldown ), 0f, 1f );
public float LastHitDamage { get; private set; }
public float LastHitAt { get; private set; } = -100f;
public bool LastHitHeadshot { get; private set; }
public float LastHurtAt { get; private set; } = -100f;
public float HurtStrength { get; private set; }
private float _hitSoundAt = -1f;
private bool _hitSoundHeadshot;
private float _hurtSoundAt = -100f;
private Vector3 _knockback;
private Vector3 _velocity;
private Angles _viewAngles;
private bool _grounded;
private float _hopBonus;
private float _hopGroundTime;
private bool _hopChain;
private const float HopGain = .12f;
private const float HopMaximum = .6f;
private const float HopLandingWindow = .15f;
private Vector3 _lastSafePosition;
private float _dashLeft;
private float _dashTime;
private Vector3 _dashDirection;
private float _invulnerable;
private CapsuleCollider _hitbox;
private GameObject _viewWeapon;
private float _weaponKick;
private float _weaponSwayYaw;
private float _weaponSwayPitch;
private float _moveIntensity;
private float _swapAnimation;
private float _ultimateCooldown;
private float _ultimateTime;
private bool _ultimateActive;
private float _baseDamageReduction;
[Sync] public float PermanentReloadFactor { get; private set; } = 1f;
public float TotalDefense => _baseDamageReduction * 100f + RunBuffs.ArmorPerRank * (Buffs?.Rank("armor") ?? 0);
public Vector3 EyePosition => WorldPosition + Vector3.Up * 65f;
[Sync] public Rotation NetworkViewRotation { get; private set; } = Rotation.Identity;
public Rotation EyeRotation => IsProxy ? NetworkViewRotation : _viewAngles.ToRotation();
public FpsPlayer SpectatorTarget { get; private set; }
public bool IsSpectating => PrototypeGame.Instance?.LocalPlayer == this && Vitality?.IsDead == true && PrototypeGame.Instance?.Phase is RunPhase.Combat or RunPhase.RoomClear or RunPhase.Reward or RunPhase.Generating;
public Rotation ViewRigRotation => EyeRotation * Rotation.FromPitch( _weaponKick * 5f + _weaponSwayPitch * .35f ) * Rotation.FromYaw( _weaponSwayYaw * .35f );
public Vector3 ViewRigOffset => EyeRotation.Forward * (-_weaponKick * 3f)
+ EyeRotation.Right * ((_grounded ? MathF.Sin( Time.Now * 10.5f ) * .8f * _moveIntensity : 0f) + _weaponSwayYaw * .12f)
- EyeRotation.Up * ((_grounded ? MathF.Abs( MathF.Cos( Time.Now * 10.5f ) ) * .6f * _moveIntensity : 0f) + _weaponSwayPitch * .1f);
private Vector3? _pendingSpawn;
private int _spawnSeed;
[Rpc.Broadcast]
public void TeleportForLevel( Vector3 position, int seed, bool freshRun )
{
if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
if ( IsProxy ) return;
_pendingSpawn = position;
_spawnSeed = seed;
// Player health is owner-synced: a host-only reset cannot revive a guest.
if (freshRun && Vitality.IsValid())
{
_ultimateActive = false; _ultimateTime = _ultimateCooldown = 0f;
_dashLeft = _dashTime = _switchLeft = 0f;
Weapon?.ResetForRun();
LastHitAt = LastHurtAt = -100f;
_hitSoundAt = -1f; _hitSoundHeadshot = false;
DamagePopups.Clear();
Buffs?.ResetOwner();
DamageMultiplier = FireRateMultiplier = SpeedMultiplier = 1f;
DashCooldown = 1.35f; _baseDamageReduction = 0f;
ApplyHeroLoadout();
}
// Surviving runners keep their health; only a new run or revival heals fully.
if ( Vitality.IsValid() && (freshRun || Vitality.IsDead) ) Vitality.ResetTo( Vitality.MaxHealth );
}
protected override void OnStart()
{
GameProfile.Load();
Hero = HeroCatalog.Get( GameProfile.Progress.SelectedHero );
Vitality = Components.GetOrCreate<HealthComponent>();
_lastSafePosition = WorldPosition;
if ( !IsProxy ) Vitality.ResetTo( 100f );
if ( !IsProxy ) ApplyHeroLoadout();
Vitality.Died -= OnPlayerDied;
Vitality.Died += OnPlayerDied;
Weapon = Components.GetOrCreate<WeaponController>();
var hitbox = Components.GetOrCreate<CapsuleCollider>();
hitbox.Start = Vector3.Up * 18f;
hitbox.End = Vector3.Up * 54f;
hitbox.Radius = 18f;
hitbox.IsTrigger = false;
_hitbox = hitbox;
// Production rig owns the player visual; do not create the old second body.
// DasherBodyVisual is created by SpawnPlayer before NetworkSpawn.
_viewWeapon = new GameObject( true, "View Weapon" ); _viewWeapon.WorldPosition = EyePosition;
_viewWeapon.NetworkMode = NetworkMode.Never;
RuinVisualFactory.SetWeaponModel( _viewWeapon, PrototypeCatalog.Rifle );
_viewWeapon.Enabled = !IsProxy;
}
protected override void OnFixedUpdate()
{
var phase = PrototypeGame.Instance?.LocalPhase;
if ( IsProxy || Vitality?.IsDead == true || phase is not (RunPhase.Combat or RunPhase.Reward) ) return;
var game = PrototypeGame.Instance;
if ( game?.WorldReady != true ) { _velocity = Vector3.Zero; ResetBunnyHop(); return; }
if ( _pendingSpawn.HasValue )
{
if ( game.Seed != _spawnSeed ) return;
WorldPosition = _pendingSpawn.Value;
_lastSafePosition = WorldPosition;
_velocity = _knockback = Vector3.Zero;
_dashTime = 0f;
_pendingSpawn = null;
_grounded = false;
ResetBunnyHop();
}
_dashLeft = MathF.Max( 0f, _dashLeft - Time.Delta );
_dashTime = MathF.Max( 0f, _dashTime - Time.Delta );
_invulnerable = MathF.Max( 0f, _invulnerable - Time.Delta );
_ultimateCooldown = MathF.Max( 0f, _ultimateCooldown - Time.Delta );
_ultimateTime = MathF.Max( 0f, _ultimateTime - Time.Delta );
if ( _ultimateActive && _ultimateTime <= 0f ) EndUltimate();
if ( WorldPosition.z < -120f ) RecoverFromFall();
if (!game.IsRunMenuOpen) UpdateLook();
var moveInput = game.IsRunMenuOpen ? Vector3.Zero : Input.AnalogMove;
_moveIntensity = Math.Clamp( moveInput.Length, 0f, 1f );
var yaw = Rotation.FromYaw( _viewAngles.yaw );
var wish = yaw * new Vector3( moveInput.x, moveInput.y, 0f );
if ( !wish.IsNearZeroLength ) wish = wish.Normal;
if ( !game.IsRunMenuOpen && Input.Pressed( "Dash" ) && _dashLeft <= 0f )
{
_dashDirection = wish.IsNearZeroLength ? yaw.Forward : wish;
_dashTime = DashDuration;
_dashLeft = MathF.Max(1.35f*.5f, DashCooldown*(1f-.1f*(Buffs?.Rank("dash") ?? 0)));
Buffs?.DashStarted();
_invulnerable = DashDuration;
PrototypeAudio.Play( "dash", WorldPosition );
}
// Dash preserves the chain and gives a fresh landing window afterward.
_hopGroundTime = _grounded && _dashTime <= 0f ? _hopGroundTime + Time.Delta : 0f;
if (game.IsRunMenuOpen || (_dashTime <= 0f && (wish.IsNearZeroLength || _hopGroundTime > HopLandingWindow)))
ResetBunnyHop();
// Holding jump queues the next takeoff as soon as ground contact is detected.
if (_grounded && !game.IsRunMenuOpen && Input.Down("Jump"))
{
if (!wish.IsNearZeroLength && _dashTime <= 0f)
{
_hopBonus = _hopChain ? MathF.Min(HopMaximum, _hopBonus + HopGain) : 0f;
_hopChain = true;
}
_hopGroundTime = 0f;
_velocity.z = 300f;
_grounded = false;
}
var horizontalVelocity = wish * MoveSpeed * SpeedMultiplier * (Buffs?.SpeedFactor ?? 1f) * (1f + _hopBonus) + _knockback;
if (_dashTime > 0f) horizontalVelocity = _dashDirection * DashSpeed * SpeedMultiplier + _knockback;
_velocity.z -= 780f * Time.Delta;
var beforeMove = WorldPosition;
MoveHorizontal( horizontalVelocity * Time.Delta );
// Pushing into a wall cannot bank a faster chain for later.
if (_hopChain && _dashTime <= 0f && (WorldPosition - beforeMove).WithZ(0f).Length < horizontalVelocity.Length * Time.Delta * .25f)
ResetBunnyHop();
MoveVertical( _velocity.z * Time.Delta );
ProbeGround();
_knockback = Vector3.Lerp( _knockback, Vector3.Zero, Time.Delta * 9f );
if ( phase == RunPhase.Combat && !game.IsRunMenuOpen && Input.Down( "Attack1" ) && Weapon.TryFire( EyePosition + EyeRotation.Forward * 22f, EyeRotation.Forward, GameObject ) )
{
_weaponKick = MathF.Min( 1.6f, _weaponKick + (Weapon.Spec.Pellets > 1 ? 1.35f : .72f) );
_bodyAttackUntil = Time.Now + .25f;
_viewAngles.pitch -= Weapon.Spec.Pellets > 1 ? .9f : .22f;
}
if ( !game.IsRunMenuOpen && Input.Pressed( "Reload" ) ) Weapon.StartReload();
if ( !game.IsRunMenuOpen && Input.Pressed( "Menu" ) ) EquipAlternate();
if ( !game.IsRunMenuOpen && !game.HasNearbyRunInteraction && Input.Pressed( "Use" ) ) ActivateUltimate();
}
protected override void OnUpdate()
{
if ( _hitbox.IsValid() ) _hitbox.Enabled = Vitality?.IsDead != true;
if ( IsProxy ) return;
if (_hitSoundAt >= 0f && Time.Now >= _hitSoundAt)
{
PrototypeAudio.Play(_hitSoundHeadshot ? "hit_head" : "hit_body", EyePosition);
_hitSoundAt = -1f;
_hitSoundHeadshot = false;
}
// Cumulative host state repairs a missed reward RPC without double credit.
var economy = PrototypeGame.Instance;
if (economy?.LocalInRun == true) GameProfile.BankCoins(economy.CoinRunId, economy.LocalRunCoins);
NetworkViewRotation = _viewAngles.ToRotation();
UpdateSpectator();
if ( _switchLeft > 0f )
{
_switchLeft = MathF.Max( 0f, _switchLeft - Time.Delta );
WeaponSwitchProgress = 1f - _switchLeft / .55f;
if ( !_switchCommitted && WeaponSwitchProgress >= .5f )
{
_switchCommitted = true;
Weapon.Equip( Weapon.Spec.Id == "rifle" ? PrototypeCatalog.Scatter : PrototypeCatalog.Rifle );
RuinVisualFactory.SetWeaponModel( _viewWeapon, Weapon.Spec );
}
}
else WeaponSwitchProgress = 0f;
BodyWeaponId = Weapon?.Spec.Id ?? "rifle";
BodyReloadProgress = Weapon?.ReloadProgress ?? 0f;
var dash = WorldRotation.Inverse * _dashDirection;
BodyAnimation = Vitality?.IsDead == true ? "death"
: _dashTime > 0f ? (MathF.Abs( dash.x ) >= MathF.Abs( dash.y )
? (dash.x >= 0 ? "dashforward" : "dashback")
: (dash.y >= 0 ? "dashleft" : "dashright"))
: Weapon?.Reloading == true ? "reload"
: Time.Now < _bodyAttackUntil ? "attack"
: _moveIntensity > .1f ? "run" : "idle";
}
private void UpdateSpectator()
{
if ( !IsSpectating ) { SpectatorTarget = null; return; }
var living = PrototypeGame.Instance.RunPlayers.Where( p => p != this && p.Vitality?.IsDead == false )
.OrderBy( p => p.GameObject.Network.OwnerId ).ToArray();
if ( living.Length == 0 ) { SpectatorTarget = null; return; }
var index = Array.IndexOf( living, SpectatorTarget );
if ( index < 0 ) index = 0;
else if ( PrototypeGame.Instance?.IsRunMenuOpen != true && Input.Pressed( "Attack1" ) ) index = (index + 1) % living.Length;
else if ( PrototypeGame.Instance?.IsRunMenuOpen != true && Input.Pressed( "Attack2" ) ) index = (index + living.Length - 1) % living.Length;
SpectatorTarget = living[index];
}
private void UpdateLook()
{
var lookInput = Input.AnalogLook;
if (GameProfile.Settings.InvertMouse) lookInput.pitch = -lookInput.pitch;
_viewAngles += lookInput * GameProfile.Settings.MouseSensitivity;
_weaponSwayYaw = MathX.Lerp( _weaponSwayYaw, -lookInput.yaw * (GameProfile.Settings.ReduceWeaponMotion ? 0f : 1.8f), Time.Delta * 14f );
_weaponSwayPitch = MathX.Lerp( _weaponSwayPitch, lookInput.pitch * (GameProfile.Settings.ReduceWeaponMotion ? 0f : 1.45f), Time.Delta * 14f );
_viewAngles.pitch = Math.Clamp( _viewAngles.pitch, -86f, 86f );
_viewAngles.roll = 0f;
WorldRotation = Rotation.FromYaw( _viewAngles.yaw );
}
private void MoveHorizontal( Vector3 delta )
{
if ( delta.IsNearZeroLength ) return;
var center = WorldPosition + Vector3.Up * 36f;
var trace = Scene.Trace.Sphere( 18f, center, center + delta ).WithoutTags( "player", "enemy", "projectile" ).Run();
// At a box edge the sweep may begin touching/inside the collider and return
// zero movement forever. Try a conventional stair step before sliding; this
// also lets the controller recover from shallow overlap on props.
if ( trace.Hit && trace.Fraction <= .03f && TryStepMove( delta ) ) return;
WorldPosition += delta * MathF.Max( 0f, trace.Fraction - .02f );
if ( !trace.Hit ) return;
var remaining = delta * (1f - trace.Fraction);
var normal = trace.Normal.WithZ( 0f ).Normal;
var slide = remaining - normal * Vector3.Dot( remaining, normal );
center = WorldPosition + Vector3.Up * 36f;
var slideTrace = Scene.Trace.Sphere( 18f, center, center + slide ).WithoutTags( "player", "enemy", "projectile" ).Run();
WorldPosition += slide * MathF.Max( 0f, slideTrace.Fraction - .02f );
}
private bool TryStepMove( Vector3 delta )
{
const float stepHeight = 24f;
var raisedCenter = WorldPosition + Vector3.Up * (36f + stepHeight);
var raisedTrace = Scene.Trace.Sphere( 18f, raisedCenter, raisedCenter + delta )
.WithoutTags( "player", "enemy", "projectile" ).Run();
if ( raisedTrace.Hit && raisedTrace.Fraction < .98f ) return false;
var destination = WorldPosition + Vector3.Up * stepHeight + delta;
var ground = Scene.Trace.Ray( destination + Vector3.Up * 6f, destination + Vector3.Down * (stepHeight + 10f) )
.WithoutTags( "player", "enemy", "projectile" ).Run();
if ( !ground.Hit ) return false;
WorldPosition = destination.WithZ( ground.HitPosition.z );
_velocity.z = 0f;
_grounded = true;
return true;
}
private void MoveVertical( float amount )
{
if ( MathF.Abs( amount ) < .001f ) return;
var center = WorldPosition + Vector3.Up * 36f;
var delta = Vector3.Up * amount;
var trace = Scene.Trace.Sphere( 18f, center, center + delta ).WithoutTags( "player", "enemy", "projectile" ).Run();
WorldPosition += delta * MathF.Max( 0f, trace.Fraction - .02f );
if ( trace.Hit ) _velocity.z = 0f;
}
private void ProbeGround()
{
var trace = Scene.Trace.Ray( WorldPosition + Vector3.Up * 6f, WorldPosition + Vector3.Down * 12f ).WithoutTags( "player", "enemy", "projectile" ).Run();
_grounded = trace.Hit && _velocity.z <= 0f;
if ( _grounded )
{
WorldPosition = WorldPosition.WithZ( trace.HitPosition.z );
_velocity.z = 0f;
_lastSafePosition = WorldPosition;
}
}
private void ResetBunnyHop()
{
_hopBonus = 0f;
_hopChain = false;
_hopGroundTime = 0f;
}
private void RecoverFromFall()
{
WorldPosition = _lastSafePosition + Vector3.Up * 8f;
_grounded = false;
ResetBunnyHop();
_velocity = Vector3.Zero;
_knockback = Vector3.Zero;
}
public void UpdateLegacyVisualVisibility(bool firstPerson, bool hasRig)
{
// Never put the camera inside the fallback head, even while the rig loads.
if (_viewWeapon.IsValid()) _viewWeapon.Enabled = !IsProxy && !IsSpectating && Vitality?.IsDead != true && firstPerson && !hasRig;
}
protected override void OnPreRender()
{
if ( PrototypeGame.Instance?.LocalPlayer != this || Scene.Camera is null ) return;
UpdateSpectator();
var viewTarget = IsSpectating ? SpectatorTarget : this;
if (viewTarget.IsValid())
{
viewTarget.UpdateLegacyVisualVisibility(true, viewTarget.Components.Get<DasherBodyVisual>()?.Ready == true);
viewTarget.Components.Get<DasherBodyVisual>()?.PrepareView();
}
if ( IsSpectating && SpectatorTarget.IsValid() && SpectatorTarget.Vitality?.IsDead == false )
{
Scene.Camera.WorldPosition = SpectatorTarget.EyePosition;
Scene.Camera.WorldRotation = SpectatorTarget.EyeRotation;
Scene.Camera.FieldOfView = GameProfile.Settings.FieldOfView;
if ( _viewWeapon.IsValid() ) _viewWeapon.Enabled = false;
return;
}
var cameraTrace = Scene.Trace.Ray( EyePosition, EyePosition - EyeRotation.Forward * 140f + Vector3.Up * 18f ).WithoutTags( "player", "projectile" ).Run();
Scene.Camera.WorldPosition = ThirdPersonView ? cameraTrace.EndPosition : EyePosition;
if ( _viewWeapon.IsValid() ) _viewWeapon.Enabled = Vitality?.IsDead != true && !ThirdPersonView && Components.Get<DasherBodyVisual>()?.Ready != true;
Scene.Camera.WorldRotation = EyeRotation;
Scene.Camera.FieldOfView = GameProfile.Settings.FieldOfView;
var colorAdjustments = Scene.Camera.Components.GetOrCreate<ColorAdjustments>();
colorAdjustments.Brightness = GameProfile.Settings.Brightness;
_weaponKick = MathX.Lerp( _weaponKick, 0f, Time.Delta * 12f );
_swapAnimation = MathF.Max( 0f, _swapAnimation - Time.Delta * 3.8f );
if ( _viewWeapon.IsValid() )
{
var reload = Weapon?.ReloadProgress ?? 0f;
var reloadArc = MathF.Sin( reload * MathF.PI );
var walkBob = _grounded ? MathF.Sin( Time.Now * 10.5f ) * 1.45f * _moveIntensity : 0f;
var walkLift = _grounded ? MathF.Abs( MathF.Cos( Time.Now * 10.5f ) ) * 1.1f * _moveIntensity : 0f;
var lowered = reloadArc * 13f + MathF.Sin( _swapAnimation * MathF.PI ) * 18f;
_viewWeapon.WorldPosition = EyePosition
+ EyeRotation.Forward * (38f - _weaponKick * 7f)
+ EyeRotation.Right * (11f + walkBob + _weaponSwayYaw * .18f)
+ EyeRotation.Down * (16f + lowered + walkLift + _weaponSwayPitch * .14f);
// Both FBX weapons import with their barrel along local -Y, Z up.
var productionOrientation = Rotation.FromYaw( 90f );
_viewWeapon.WorldRotation = EyeRotation
* productionOrientation
* Rotation.FromPitch( reloadArc * 48f + _weaponKick * 9f + _weaponSwayPitch )
* Rotation.FromYaw( _weaponSwayYaw )
* Rotation.FromRoll( reloadArc * -32f + _swapAnimation * 75f + walkBob * .65f - _weaponKick * 2.5f );
_viewWeapon.Components.Get<ModelRenderer>()?.Tint = Color.White;
}
}
[Rpc.Broadcast]
public void ReceiveCoins(string runId, int total)
{
if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
if (Networking.IsActive ? Connection.Local?.Id != ParticipantId : IsProxy) return;
GameProfile.BankCoins(runId, total);
}
public sealed class DamagePopup
{
public float Damage { get; set; }
public float Started { get; init; }
public int Slot { get; init; }
public bool Headshot { get; init; }
}
public System.Collections.Generic.List<DamagePopup> DamagePopups { get; } = new();
private readonly System.Random _popupRandom = new();
public void ReportHit(float damage, bool headshot = false)
{
if (IsProxy || !float.IsFinite(damage) || damage <= 0f) return;
// Coalesce pellets into one sound; a head pellet takes priority.
if (_hitSoundAt < 0f) _hitSoundAt = Time.Now + .025f;
_hitSoundHeadshot |= headshot;
LastHitHeadshot = headshot || (LastHitHeadshot && Time.Now - LastHitAt < .08f);
LastHitDamage = damage; LastHitAt = Time.Now;
DamagePopups.RemoveAll(x => Time.Now - x.Started >= .7f);
if (!GameProfile.Settings.ShowDamageNumbers) return;
// Combine near-simultaneous pellet hits instead of stacking their labels.
var last = DamagePopups.LastOrDefault(x => x.Headshot == headshot);
if (last is not null && Time.Now - last.Started < .025f) { last.Damage += damage; return; }
if (DamagePopups.Count >= 6) DamagePopups.RemoveAt(0);
var free = Enumerable.Range(0,6).Where(slot => !DamagePopups.Any(x => x.Headshot == headshot && x.Slot == slot)).ToArray();
DamagePopups.Add(new DamagePopup { Damage=damage, Headshot=headshot, Started=Time.Now, Slot=headshot ? free[0] : free[_popupRandom.Next(free.Length)] });
}
[Rpc.Broadcast]
public void ReceiveReward( string buff )
{
if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
if ( !IsProxy ) ApplyBuff( buff );
}
public void ApplyBuff( string buff )
{
if ( Vitality?.IsDead != false ) return;
switch ( buff )
{
case "power": DamageMultiplier *= 1.25f; break;
case "haste": FireRateMultiplier *= 1.2f; SpeedMultiplier *= 1.08f; break;
case "vitality": Vitality.MaxHealth += 30f; Vitality.Health = Vitality.MaxHealth; break;
}
}
public void EquipAlternate()
{
if ( SwitchingWeapon ) return;
Weapon?.BeginSwitch();
_switchLeft = .55f;
_switchCommitted = false;
_swapAnimation = 1f;
PrototypeAudio.Play( "weapon_swap", WorldPosition );
}
public void ReceiveDamage( float amount, GameObject source )
{
if ( PrototypeGame.Instance?.IsHost != true ) return;
ReceiveHostDamage( amount, source );
}
[Rpc.Broadcast]
private void ReceiveHostDamage( float amount, GameObject source )
{
if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
if ( IsProxy || !Vitality.IsValid() || Vitality.IsDead || !float.IsFinite( amount ) || amount <= 0f || _invulnerable > 0f ) return;
var reduced = BuffRules.ReducedDamage(amount, TotalDefense);
if (Buffs?.AbsorbLethal(reduced, Vitality) == true) return;
Vitality.ApplyDamage( reduced, source );
if (reduced > 0f)
{
LastHurtAt = Time.Now;
HurtStrength = Math.Clamp(reduced / MathF.Max(1f, Vitality.MaxHealth) * 2f, .35f, 1f);
if (Time.Now - _hurtSoundAt >= .12f)
{
PrototypeAudio.Play("player_hurt", EyePosition);
_hurtSoundAt = Time.Now;
}
}
if ( source.IsValid() )
{
var away = (WorldPosition - source.WorldPosition).WithZ( 0f ).Normal;
_knockback = away * 520f;
}
}
private void ApplyHeroLoadout()
{
Func<string, int> rank = id => GameProfile.SkillRank( id );
switch ( Hero.Id )
{
case "warden": Vitality.MaxHealth = 125f + rank("warden_vigor") * 8f; Vitality.Health = Vitality.MaxHealth; _baseDamageReduction = .10f + rank("warden_guard") * .04f; break;
case "seer": FireRateMultiplier *= 1.10f + rank("seer_flux") * .04f; SpeedMultiplier *= 1f + rank("seer_stride") * .03f; break;
case "relic": DamageMultiplier *= 1.15f + rank("relic_focus") * .05f; break;
case "nomad":
Vitality.MaxHealth = 100f + 10f*rank("tree_heart") + 5f*rank("tree_vitality") + 10f*rank("tree_bastion");
Vitality.Health = Vitality.MaxHealth;
DamageMultiplier = 1f + .05f*rank("tree_power") + .03f*rank("tree_precision");
FireRateMultiplier = 1f + .03f*rank("tree_haste") + .02f*rank("tree_precision");
_baseDamageReduction = .05f*rank("tree_armor") + .02f*rank("tree_vitality") + .03f*rank("tree_bastion");
SpeedMultiplier *= 1.12f + .02f*rank("tree_stride");
DashCooldown /= 1f + .05f*rank("tree_dash");
PermanentReloadFactor = 1f/(1f + .05f*rank("tree_reload"));
break;
}
}
private void ActivateUltimate()
{
if ( _ultimateCooldown > 0f || PrototypeGame.Instance?.Phase != RunPhase.Combat ) return;
var cooldownReduction = GameProfile.SkillRank( Hero.Id is "warden" ? "warden_aegis" : Hero.Id is "relic" ? "relic_lance" : "" ) * .07f;
_ultimateCooldown = MathF.Max(14f,28f / (1f + .05f*GameProfile.SkillRank("tree_focus")) * (1f - cooldownReduction)*(1f-.1f*(Buffs?.Rank("focus") ?? 0)));
var durationBonus = .4f * GameProfile.SkillRank("tree_dust");
_ultimateTime = 5f + durationBonus;
_ultimateActive = true;
switch ( Hero.Id )
{
case "warden": _invulnerable = 4f + durationBonus; foreach ( var enemy in Scene.GetAll<EnemyBrain>().Where(e => e.WorldPosition.Distance(WorldPosition) < 420f) ) enemy.Vitality.ApplyDamage( 40f, GameObject ); break;
case "seer": FireRateMultiplier *= 1.65f; SpeedMultiplier *= 1.35f; break;
case "relic": DamageMultiplier *= 2.2f; break;
case "nomad": _dashLeft = 0f; SpeedMultiplier *= 1.45f; break;
}
}
private void EndUltimate()
{
_ultimateActive = false;
switch ( Hero.Id )
{
case "seer": FireRateMultiplier /= 1.65f; SpeedMultiplier /= 1.35f; break;
case "relic": DamageMultiplier /= 2.2f; break;
case "nomad": SpeedMultiplier /= 1.45f; break;
}
}
private void OnPlayerDied( GameObject killer )
{
ResetBunnyHop();
PrototypeGame.Instance?.NotifyPlayerDeath( this );
}
protected override void OnDestroy()
{
if ( Vitality.IsValid() ) Vitality.Died -= OnPlayerDied;
if ( _viewWeapon.IsValid() ) _viewWeapon.Destroy();
}
}