Search the source of every open source package.
2807 results
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Twitch Poop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "garry" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "garry.twitchpoop" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "6/6/2026 7:39:31 PM" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "25" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.128.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.128.0")]using System;
using Sandbox;
namespace Goo.Animation;
public record struct SmoothVector2
{
public Vector2 Current;
public Vector2 Target;
public Vector2 Velocity;
public float SmoothTime;
public SmoothVector2(Vector2 initial, float smoothTime)
{
Current = initial;
Target = initial;
Velocity = default;
SmoothTime = smoothTime;
}
public void Update(float dt)
{
float vx = Velocity.x, vy = Velocity.y;
Current = new Vector2(
MathX.SmoothDamp(Current.x, Target.x, ref vx, SmoothTime, dt),
MathX.SmoothDamp(Current.y, Target.y, ref vy, SmoothTime, dt));
Velocity = new Vector2(vx, vy);
}
public bool IsSettled =>
MathF.Abs(Target.x - Current.x) < 0.0001f &&
MathF.Abs(Target.y - Current.y) < 0.0001f &&
MathF.Abs(Velocity.x) < 0.0001f &&
MathF.Abs(Velocity.y) < 0.0001f;
/// <summary>Advances by dt and returns true while still moving; chain calls with | (not ||) so every damper advances each frame.</summary>
public bool Tick(float dt) { Update(dt); return !IsSettled; }
}
using Sandbox;
using Sandbox.Rendering;
using Sandbox.UI;
namespace Goo;
// Style helpers hoisted so generated Blob facades share them. Keep the early-return form: an engine-type ternary that resolves bare null via an implicit string operator silently produces magenta (Color.Parse fallback) instead of the intended absent-property. See engine-fact memories.
internal static class StyleAccumulator
{
static StyleList Rent(StyleList current)
=> ReferenceEquals(current, StyleList.Empty)
? BuildContext.Current.RentStyleList()
: current;
public static StyleList Add(StyleList current, StyleField field, Length? value)
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromLength(value.Value));
return list;
}
public static StyleList Add<TEnum>(StyleList current, StyleField field, TEnum? value, System.Func<TEnum, StyleValue> wrap) where TEnum : struct
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, wrap(value.Value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, Color? value, System.Func<Color, StyleValue> wrap)
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, wrap(value.Value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, string? value)
{
if (value is null) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromString(value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, float? value)
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromSingle(value.Value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, bool? value)
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromBoolean(value.Value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, int? value)
{
if (!value.HasValue) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromInt32(value.Value));
return list;
}
public static StyleList Add(StyleList current, StyleField field, Texture? value)
{
if (value is null) return current;
var list = Rent(current);
list.Add(field, StyleValue.FromTexture(value));
return list;
}
}
using System;
using Sandbox;
namespace Goo.FpsUI;
// ============================================================================
// ALL demo / self-preview behaviour for the FPS UI pack lives in this ONE file.
//
// DELETE THIS FILE to remove demo functionality.
// deleting this file makes every Demo inspector toggle disappear
// ============================================================================
static class FpsDemo
{
static readonly string[] Names = { "Vex", "Korr", "Juno", "Rhys", "Mara", "Dane", "Iko", "Sol" };
static readonly (int Amount, string Action)[] Grants =
{
(100, "Kill"), (125, "Headshot"), (50, "Assist"), (200, "Double Kill"), (25, "Hitmarker"),
};
public static void Health( HealthModel m, ref float t, float dt ) // bleed in chunks, big heal when low
{
t += dt;
if ( t < 2.4f ) return;
t = 0f;
if ( m.Health <= m.MaxHealth * 0.3f ) m.Heal( m.MaxHealth * 0.65f );
else m.Damage( m.MaxHealth * 0.22f );
}
public static void Stamina( StaminaModel m, ref float t, float dt ) // sprint on/off cycle
{
t += dt;
if ( t >= 3.1f ) { t = 0f; m.SetSprinting( !m.Sprinting ); }
if ( m.Stamina <= 0f ) m.SetSprinting( false );
}
public static void Ammo( AmmoModel m, ref float t, float dt ) // steady fire, auto-reload on empty
{
t += dt;
if ( m.Reloading ) return;
if ( m.Mag <= 0 ) { m.Reload(); return; }
if ( t >= 0.18f ) { t = 0f; m.Fire(); }
}
public static void Crosshair( CrosshairModel m, ref float t, float dt ) // steady fire cadence
{
t += dt;
if ( t >= 0.18f ) { t = 0f; m.Fire(); }
}
public static void Hitmarker( HitmarkerModel m, ref float t, float dt ) // periodic pop
{
t += dt;
if ( t >= 0.5f ) { t = 0f; m.Pop( false ); }
}
public static void Killfeed( KillfeedModel m, ref float t, ref int pick, float dt ) // periodic fake kills
{
t += dt;
if ( t < 2.2f ) return;
t = 0f;
string a = Names[pick % Names.Length];
string v = Names[(pick + 3) % Names.Length];
// Alternate teams, occasionally make the local player the killer so the preview shows both.
bool youKill = pick % 3 == 0;
KillTeam at = youKill || pick % 2 == 0 ? KillTeam.Friendly : KillTeam.Enemy;
KillTeam vt = at == KillTeam.Friendly ? KillTeam.Enemy : KillTeam.Friendly;
pick++;
m.Add( a, at, v, vt, attackerLocal: youKill );
}
public static void Xp( XpModel m, ref float t, ref int pick, float dt ) // periodic grants
{
t += dt;
if ( t < 1.6f ) return;
t = 0f;
var g = Grants[pick % Grants.Length];
pick++;
m.Add( g.Amount, g.Action );
}
public static void Scoreboard( ScoreboardModel m, ref float t, float dt ) // loop the clock, nudge scores, cycle variant
{
if ( m.TimeRemaining <= 0f ) m.TimeRemaining = 600f;
t += dt;
if ( t < 4f ) return;
t = 0f;
m.Mode = m.Mode switch
{
ScoreboardMode.Tdm => ScoreboardMode.Domination,
ScoreboardMode.Domination => ScoreboardMode.Ffa,
_ => ScoreboardMode.Tdm,
};
m.FriendlyScore = (m.FriendlyScore + 7) % (m.ScoreLimit + 1);
m.EnemyScore = (m.EnemyScore + 5) % (m.ScoreLimit + 1);
m.PlayerScore += 150;
m.LeaderScore = Math.Max( m.LeaderScore, m.PlayerScore ) + 50;
for ( int i = 0; i < m.Points.Length; i++ ) // rotate cap ownership
m.Points[i] = (CapOwner)(((int)m.Points[i] + 1) % 3);
}
}
// ---- per-widget demo hooks (the [Property] Demo toggle + timers + StepDemo body) ----
public sealed partial class HealthWidget
{
[Property] public bool Demo { get; set; } = true; // self-animate in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Health( _m, ref _demoT, dt );
}
}
public sealed partial class StaminaWidget
{
[Property] public bool Demo { get; set; } = true; // self-animate in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Stamina( _m, ref _demoT, dt );
}
}
public sealed partial class AmmoWidget
{
[Property] public bool Demo { get; set; } = true; // self-fire in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Ammo( _m, ref _demoT, dt );
}
}
public sealed partial class CrosshairWidget
{
[Property] public bool Demo { get; set; } = true; // self-fire in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Crosshair( _m, ref _demoT, dt );
}
}
public sealed partial class HitmarkerWidget
{
[Property] public bool Demo { get; set; } = true; // periodic pop in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Hitmarker( _m, ref _demoT, dt );
}
}
public sealed partial class KillfeedWidget
{
[Property] public bool Demo { get; set; } = true; // fake kills in editor
float _demoT;
int _demoPick;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Killfeed( _m, ref _demoT, ref _demoPick, dt );
}
}
public sealed partial class XpWidget
{
[Property] public bool Demo { get; set; } = true; // fake grants in editor
float _demoT;
int _demoPick;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Xp( _m, ref _demoT, ref _demoPick, dt );
}
}
public sealed partial class ScoreboardWidget
{
[Property] public bool Demo { get; set; } = true; // self-animate in editor
float _demoT;
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
FpsDemo.Scoreboard( _m, ref _demoT, dt );
}
}
// ---- the assembled HUD: one coordinated synthetic firefight ----
public sealed partial class FpsHud
{
[Property, Group( "General" )] public bool Demo { get; set; } = true; // self-run a synthetic firefight in editor
readonly Random _demoRng = new();
float _hpT, _stT, _kfT, _xpT, _sbT; // per-subsystem demo timers
int _kfPick, _xpPick; // demo name / grant cursors
float _triggerT, _modeT; // synthetic trigger square-wave + fire-mode cycle
partial void StepDemo( float dt, ref bool active )
{
if ( !Demo ) return;
active = true;
// Cycle the fire mode every few seconds, then drive a synthetic trigger square-wave (held ~1s,
// released ~0.4s) through the real fire control so each mode visibly fires differently.
_modeT -= dt;
if ( _modeT <= 0f ) { _modeT = 3.0f; CycleFireMode(); }
_fire.Mode = FireMode; _fire.BurstCount = BurstCount; _fire.Rpm = RoundsPerMinute;
_triggerT += dt;
bool trigger = _triggerT % 1.4f < 1.0f;
if ( !_ammo.Reloading )
{
if ( _ammo.Mag <= 0 ) _ammo.Reload();
else if ( _fire.Tick( trigger, dt ) )
{
_ammo.Fire(); _crosshair.Fire();
if ( _demoRng.NextSingle() < 0.5f ) _hitmarker.Pop( _demoRng.NextSingle() < 0.15f );
}
}
FpsDemo.Health( _health, ref _hpT, dt );
FpsDemo.Stamina( _stamina, ref _stT, dt );
FpsDemo.Killfeed( _killfeed, ref _kfT, ref _kfPick, dt );
FpsDemo.Xp( _xp, ref _xpT, ref _xpPick, dt );
if ( ShowScoreboard ) FpsDemo.Scoreboard( _scoreboard, ref _sbT, dt );
}
}
using System;
using Sandbox;
namespace Goo.FpsUI;
// A drop-in shootable target for the FPS pack. Attach to any GameObject that has a Collider; the HUD's
// hitscan damages it through the engine's Component.IDamageable interface (the same path TriggerHurt uses).
// Exposes Health / IsDead / Name so FpsHud can drive the hitmarker, killfeed, and XP on a hit or kill.
// Engine-side component, not a Goo panel.
[Title( "FPS Target" ), Category( "FPS UI" ), Icon( "my_location" )]
public sealed class FpsTarget : Component, Component.IDamageable
{
[Property] public string Name { get; set; } = "Target"; // shown as the victim in the killfeed
[Property, Range( 1f, 1000f )] public float MaxHealth { get; set; } = 100f; // health restored on enable / respawn
[Property] public bool RespawnOnDeath { get; set; } = true; // refill health a moment after dying
[Property, Range( 0.5f, 10f )] public float RespawnDelay { get; set; } = 3f; // seconds dead before respawning
public float Health { get; private set; }
public bool IsDead => Health <= 0f;
float _respawnAt;
protected override void OnEnabled() => Health = MaxHealth;
// Engine damage entry point: TriggerHurt volumes and the HUD's hitscan both call this.
public void OnDamage( in DamageInfo damage )
{
if ( IsDead ) return; // already down, ignore extra hits
Health = Math.Max( 0f, Health - damage.Damage );
if ( IsDead && RespawnOnDeath ) _respawnAt = Time.Now + RespawnDelay;
}
protected override void OnUpdate()
{
if ( IsDead && RespawnOnDeath && Time.Now >= _respawnAt ) Health = MaxHealth;
}
}
using System;
namespace Goo.FpsUI;
// Compass logic: a 0..360 heading plus the shortest signed angular delta the view uses to
// place cardinal ticks. Engine-free.
public sealed class CompassModel
{
public float Heading { get; private set; } // current yaw, normalized 0..360
public void SetHeading( float yawDeg ) // set heading from game code (any range)
=> Heading = ((yawDeg % 360f) + 360f) % 360f;
// Signed delta from `from` to `to`, in (-180, 180]. Used to position ticks around center.
public static float ShortestDelta( float from, float to ) => ((to - from + 540f) % 360f) - 180f;
public bool Tick( float dt ) => false; // heading changes drive rebuilds, not the damper
}
using System;
using Goo;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform; // rule 18: ambiguous with Sandbox.UI otherwise
namespace Goo.FpsUI;
// Shared, stateless view helpers. Each returns a fresh Container (never cache a Container).
static class Parts
{
public enum Corner { TopLeft, TopCenter, TopRight, BottomLeft, BottomRight, Center }
// Absolute fill spanning `frac` (0..1) of its Relative parent's width, full height.
public static Container FillRect( string key, float frac, Color color, float radius ) => new()
{
Key = key, Position = PositionMode.Absolute, Top = 0, Left = 0,
Height = Length.Percent( 100 ),
Width = Length.Percent( Math.Clamp( frac, 0f, 1f ) * 100f ),
BorderRadius = radius, BackgroundColor = color,
};
// Absolute full-cover tint at a given opacity (flashes, warnings).
public static Container Overlay( string key, Color color, float opacity, float radius ) => new()
{
Key = key, Position = PositionMode.Absolute, Top = 0, Left = 0,
Width = Length.Percent( 100 ), Height = Length.Percent( 100 ),
BorderRadius = radius, BackgroundColor = color, Opacity = opacity,
};
// Pin `child` to a screen corner/center inside a Relative, full-size root. `m` is the edge inset.
// Container properties are init-only (record struct), so we use `with` expressions per corner.
public static Container Anchor( string key, Corner c, float m, Container child )
{
var box = new Container
{
Key = key, Position = PositionMode.Absolute, PointerEvents = PointerEvents.None,
Children = { child },
};
return c switch
{
Corner.TopLeft => box with { Top = m, Left = m },
Corner.TopRight => box with { Top = m, Right = m },
Corner.BottomLeft => box with { Bottom = m, Left = m },
Corner.BottomRight => box with { Bottom = m, Right = m },
Corner.TopCenter => box with
{
Top = m, Left = Length.Percent( 50 ),
Transform = PanelTransform.Translate( Length.Percent( -50 ) ?? default, Length.Percent( 0 ) ?? default ),
},
_ => box with // Center
{
Top = Length.Percent( 50 ), Left = Length.Percent( 50 ),
Transform = PanelTransform.Translate( Length.Percent( -50 ) ?? default, Length.Percent( -50 ) ?? default ),
},
};
}
// Pin `child` horizontally centered, offset `dy` px from the screen center (positive = down, negative = up).
public static Container CenterOffset( string key, float dy, Container child ) => new()
{
Key = key, Position = PositionMode.Absolute, PointerEvents = PointerEvents.None,
Top = Length.Percent( 50 ), Left = Length.Percent( 50 ),
Transform = PanelTransform.Translate( Length.Percent( -50 ) ?? default, Px.Of( dy ) ),
Children = { child },
};
// Pin `child`'s top-left corner at a pixel offset from the screen center (positive dx = right, positive dy = down).
public static Container Offset( string key, float dx, float dy, Container child ) => new()
{
Key = key, Position = PositionMode.Absolute, PointerEvents = PointerEvents.None,
Top = Length.Percent( 50 ), Left = Length.Percent( 50 ),
Transform = PanelTransform.Translate( Px.Of( dx ), Px.Of( dy ) ),
Children = { child },
};
// Semi-opaque rounded backing behind a HUD readout, so text/bars stay legible over the world.
// Wraps one child and sizes to it, all card styling comes from the theme.
public static Container Panel( string key, FpsTheme t, Container child ) => new()
{
Key = key, Position = PositionMode.Relative,
Padding = t.PanelPad, BorderRadius = t.PanelRadius,
BackgroundColor = t.BackingBg,
Children = { child },
};
// A full-size, pointer-through, Relative root used by every standalone widget and the FpsHud.
public static Container Root( string key ) => new()
{
Key = key, Width = Length.Percent( 100 ), Height = Length.Percent( 100 ),
Position = PositionMode.Relative, PointerEvents = PointerEvents.None,
};
}
using System;
using Goo;
using Sandbox;
using Sandbox.UI;
namespace Goo.FpsUI;
// Stateless presenter: the secondary (stamina/armor) bar.
static class StaminaView
{
public const float TrackW = 320f, TrackH = 10f;
public static Container Build( StaminaModel m, FpsTheme t )
{
float frac = m.ShownFraction;
Color fill = Color.Lerp( t.Warn, t.Good, frac );
var track = new Container
{
Key = "stTrack", Position = PositionMode.Relative,
Width = TrackW, Height = TrackH, BackgroundColor = t.TrackBg,
BorderRadius = t.Radius, Overflow = OverflowMode.Hidden,
Children = { Parts.FillRect( "fill", frac, fill, t.Radius ) },
};
if ( m.Flash > 0.001f ) track.Children.Add( Parts.Overlay( "flash", Color.White, m.Flash * 0.8f, t.Radius ) );
return track;
}
}
// Standalone stamina bar. Call Sprinting(bool) from your movement code each frame.
public sealed partial class StaminaWidget : GooPanel<Container>
{
[Property, Range( 1f, 1000f )] public float MaxStamina { get; set; } = 100f; // full-bar value
[Property, Range( 0.05f, 2f )] public float DrainRate { get; set; } = 0.55f; // fraction of max drained per second while sprinting
[Property] public PlayerController? Player { get; set; } // player to gate sprint on when stamina is empty (null = skip gating)
readonly StaminaModel _m = new();
readonly FpsTheme _t = new();
bool _booted;
bool _sprintBlocked;
float _cachedRunSpeed;
void Boot() { _m.MaxStamina = MaxStamina; _m.Reset(); _booted = true; }
public void Sprinting( bool on ) => _m.SetSprinting( on ); // toggle sprint drain
// Demo-only seam: implemented in FpsDemo.cs, compiles out when that file is deleted.
partial void StepDemo( float dt, ref bool active );
protected override bool Tick( float dt )
{
if ( !_booted ) Boot();
_m.DrainRate = DrainRate; // live-tunable
bool demo = false;
StepDemo( dt, ref demo );
if ( !demo )
{
Sprinting( Sandbox.Input.Down( "run" ) );
FpsInput.ApplySprintGate( Player, _m.Stamina, ref _sprintBlocked, ref _cachedRunSpeed );
}
bool moving = _m.Tick( dt );
return demo || moving;
}
protected override Container Build()
{
if ( !_booted ) Boot();
var root = Parts.Root( "fpsStamina" );
// Sits just above where the health bar would be (health height + gap).
root.Children.Add( Parts.Anchor( "a", Parts.Corner.BottomLeft, _t.Margin + HealthView.TrackH + 10f, Parts.Panel( "bg", _t, StaminaView.Build( _m, _t ) ) ) );
return root;
}
}
using System;
using Sandbox;
using Sandbox.UI;
namespace Goo.Internal;
internal sealed class StatefulShapePanel : Panel, IStatefulEventHost
{
internal Action<MousePanelEvent>? _onClick;
internal Action<MousePanelEvent>? _onRightClick;
internal Action<MousePanelEvent>? _onMiddleClick;
internal Action<MousePanelEvent>? _onMouseEnter;
internal Action<MousePanelEvent>? _onMouseLeave;
internal Action<MousePanelEvent>? _onMouseDown;
internal Action<MousePanelEvent>? _onMouseUp;
internal Action<MousePanelEvent>? _onMouseMove;
internal bool _userSetPointerEvents;
internal Action? _requestRebuild;
public Action? RequestRebuild { set => _requestRebuild = value; }
public void ApplyEvents(in BlobEvents events)
{
_onClick = events.OnClick;
_onRightClick = events.OnRightClick;
_onMiddleClick = events.OnMiddleClick;
_onMouseEnter = events.OnMouseEnter;
_onMouseLeave = events.OnMouseLeave;
_onMouseDown = events.OnMouseDown;
_onMouseUp = events.OnMouseUp;
_onMouseMove = events.OnMouseMove;
}
public bool HasEventHandlers =>
_onClick != null || _onRightClick != null || _onMiddleClick != null || _onMouseEnter != null || _onMouseLeave != null ||
_onMouseDown != null || _onMouseUp != null || _onMouseMove != null;
public bool UserSetPointerEvents
{
get => _userSetPointerEvents;
set => _userSetPointerEvents = value;
}
// Apply a baked alpha texture as BackgroundImage. The shape color comes from
// BackgroundColor flowing through the engine's BgTint multiplier; this method
// is only responsible for the alpha mask.
public void ApplyShape(BlobKind kind, in ShapeParams shape)
{
Style.BackgroundImage = ShapeTextureCache.GetOrBake(kind, in shape);
Style.BackgroundSizeX = Length.Percent(100);
Style.BackgroundSizeY = Length.Percent(100);
}
public void ApplyPolygon(Vector2[] points)
{
Style.BackgroundImage = ShapeTextureCache.GetOrBakePolygon(points);
Style.BackgroundSizeX = Length.Percent(100);
Style.BackgroundSizeY = Length.Percent(100);
}
protected override void OnClick(MousePanelEvent e) { base.OnClick(e); EventDispatch.Fire(_onClick, e, _requestRebuild); }
protected override void OnRightClick(MousePanelEvent e) { base.OnRightClick(e); EventDispatch.Fire(_onRightClick, e, _requestRebuild); }
protected override void OnMiddleClick(MousePanelEvent e) { base.OnMiddleClick(e); EventDispatch.Fire(_onMiddleClick, e, _requestRebuild); }
protected override void OnMouseOver(MousePanelEvent e) { base.OnMouseOver(e); EventDispatch.Fire(_onMouseEnter, e, _requestRebuild); }
protected override void OnMouseOut(MousePanelEvent e) { base.OnMouseOut(e); EventDispatch.Fire(_onMouseLeave, e, _requestRebuild); }
protected override void OnMouseDown(MousePanelEvent e) { base.OnMouseDown(e); EventDispatch.Fire(_onMouseDown, e, _requestRebuild); }
protected override void OnMouseUp(MousePanelEvent e) { base.OnMouseUp(e); EventDispatch.Fire(_onMouseUp, e, _requestRebuild); }
protected override void OnMouseMove(MousePanelEvent e) { base.OnMouseMove(e); EventDispatch.Fire(_onMouseMove, e, _requestRebuild); }
}
using System;
using Sandbox.UI;
namespace Goo.Internal;
internal sealed class StatefulSvgPanel : Sandbox.UI.SvgPanel, IStatefulEventHost
{
internal Action<MousePanelEvent>? _onClick;
internal Action<MousePanelEvent>? _onRightClick;
internal Action<MousePanelEvent>? _onMiddleClick;
internal Action<MousePanelEvent>? _onMouseEnter;
internal Action<MousePanelEvent>? _onMouseLeave;
internal Action<MousePanelEvent>? _onMouseDown;
internal Action<MousePanelEvent>? _onMouseUp;
internal Action<MousePanelEvent>? _onMouseMove;
internal bool _userSetPointerEvents;
internal Action? _requestRebuild;
public Action? RequestRebuild { set => _requestRebuild = value; }
public void ApplyEvents(in BlobEvents events)
{
_onClick = events.OnClick;
_onRightClick = events.OnRightClick;
_onMiddleClick = events.OnMiddleClick;
_onMouseEnter = events.OnMouseEnter;
_onMouseLeave = events.OnMouseLeave;
_onMouseDown = events.OnMouseDown;
_onMouseUp = events.OnMouseUp;
_onMouseMove = events.OnMouseMove;
}
public bool HasEventHandlers =>
_onClick != null || _onRightClick != null || _onMiddleClick != null || _onMouseEnter != null || _onMouseLeave != null ||
_onMouseDown != null || _onMouseUp != null || _onMouseMove != null;
public bool UserSetPointerEvents
{
get => _userSetPointerEvents;
set => _userSetPointerEvents = value;
}
protected override void OnClick(MousePanelEvent e) { base.OnClick(e); EventDispatch.Fire(_onClick, e, _requestRebuild); }
protected override void OnRightClick(MousePanelEvent e) { base.OnRightClick(e); EventDispatch.Fire(_onRightClick, e, _requestRebuild); }
protected override void OnMiddleClick(MousePanelEvent e) { base.OnMiddleClick(e); EventDispatch.Fire(_onMiddleClick, e, _requestRebuild); }
protected override void OnMouseOver(MousePanelEvent e) { base.OnMouseOver(e); EventDispatch.Fire(_onMouseEnter, e, _requestRebuild); }
protected override void OnMouseOut(MousePanelEvent e) { base.OnMouseOut(e); EventDispatch.Fire(_onMouseLeave, e, _requestRebuild); }
protected override void OnMouseDown(MousePanelEvent e) { base.OnMouseDown(e); EventDispatch.Fire(_onMouseDown, e, _requestRebuild); }
protected override void OnMouseUp(MousePanelEvent e) { base.OnMouseUp(e); EventDispatch.Fire(_onMouseUp, e, _requestRebuild); }
protected override void OnMouseMove(MousePanelEvent e) { base.OnMouseMove(e); EventDispatch.Fire(_onMouseMove, e, _requestRebuild); }
}
namespace Skateboard.Entities;
public sealed class GrindPathNodeEntity : Component
{
}
#nullable enable
using Sandbox;
public abstract class Item
{
public Scene? Scene {get;set;} = null;
public GameObject? GameObject {get;set;} = null;
public string Name {get;set;} = "empty";
public abstract Item Init();
public abstract void Act();
public abstract void Drop();
}using Sandbox;
public class SCP_173_SFX : Component
{
[Property] public CharacterController Character_Controller {get;set;}
[Property] public SoundPointComponent Rattle {get;set;}
protected override void OnUpdate()
{ if (IsProxy) return;
if (Character_Controller.IsOnGround && Character_Controller.Velocity.LengthSquared > 0
&& !GameObject.Tags.Has("seen")) {Rattle.StartSound(); return;} Rattle.StopSound();
}
}public class CharmRerollConsecutive : Charm
{
public const string ItemId = "charm_reroll_consecutive";
public static string Description() => $"See +1 perk choice for\neach consecutive reroll";
private int _consecutiveRerolls = 0;
public override void OnRerollBefore()
{
base.OnRerollBefore();
_consecutiveRerolls++;
Player.Modify( this, PlayerStat.NumPerkChoices, _consecutiveRerolls, ModifierType.Add );
}
public override void OnChoosePerk( TypeDescription type )
{
base.OnChoosePerk( type );
_consecutiveRerolls = 0;
Player.StopModifying( this, PlayerStat.NumPerkChoices );
}
public override void OnRunStart()
{
base.OnRunStart();
_consecutiveRerolls = 0;
}
}
using System;
using Sandbox;
public class FallingObject : Component
{
public Player Shooter { get; set; }
public TimeSince TimeSinceSpawn { get; set; }
public float Lifetime { get; set; }
public float FallProgress { get; set; }
protected float _startingHeight = 1024f;
public bool HasHitGround { get; set; }
protected override void OnStart()
{
base.OnStart();
TimeSinceSpawn = 0f;
if ( IsProxy )
return;
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( IsProxy )
return;
if ( TimeSinceSpawn > Lifetime )
{
if( !HasHitGround )
HitGround();
}
else
{
FallProgress = Utils.Map( TimeSinceSpawn, 0f, Lifetime, 0f, 1f );
WorldPosition = new Vector3( WorldPosition.x, WorldPosition.y, Utils.Map( FallProgress, 0f, 1f, _startingHeight, 0f ) );
}
}
public virtual void HitGround()
{
GameObject.Destroy();
}
}
using System;
using Sandbox;
public class Globals
{
}
public class GunActiveReload : Gun
{
public const string ItemId = "gun_active_reload";
public static string Description() => $"Start with {Perk.GetRichTextNameToken( typeof( PerkActiveReload ) )} {Perk.GetRichTextToken( nameof(PerkActiveReload) )}";
public override void OnRunStart()
{
base.OnRunStart();
Manager.Instance.Chat.AddLocalChatMessage( $"Got {Perk.GetRichTextNameToken( typeof( PerkActiveReload ) )} {Perk.GetRichTextToken( nameof( PerkActiveReload ) )}", from: "" );
Player.AddPerk( TypeLibrary.GetType( typeof( PerkActiveReload ) ) );
}
}
/// <summary>
/// Marker interface for anything that can register stat modifiers on a Player via Player.Modify.
/// Implemented by Perk, Gun, Charm, and Gem.
/// </summary>
public interface IStatModifier { }
using System;
using Sandbox;
public class LightningParticleEffect : Component
{
[Property] public ParticleEffect ParticleEffect { get; set; }
[Property] public ParticleSpriteRenderer ParticleRenderer { get; set; }
[Property] public ParticleRingEmitter RingEmitter { get; set; }
private const float CHARGE_TIME = 3f;
private const float DELAY = 0.5f;
private TimeSince _timeSinceReset;
protected override void OnStart()
{
base.OnStart();
_timeSinceReset = 0f;
}
[Rpc.Broadcast]
public void ResetEffect()
{
if ( !IsProxy ) // only for client effect
return;
if ( !ParticleRenderer.Enabled )
return;
_timeSinceReset = 0f;
RingEmitter.Rate = 500;
RingEmitter.Radius = 30f;
ParticleEffect.Alpha = 0f;
}
protected override void OnUpdate()
{
if ( !IsProxy ) // only for client effect
return;
if ( _timeSinceReset < DELAY )
return;
float progress = Utils.Map( _timeSinceReset, 0f, DELAY + CHARGE_TIME, 0f, 1f );
RingEmitter.Rate = 500;
RingEmitter.Radius = Utils.Map( progress, 0f, 1f, 30f, 0f, EasingType.SineIn );
ParticleEffect.Alpha = Utils.Map( progress, 0f, 1f, 0f, 1f, EasingType.ExpoIn );
}
[Rpc.Broadcast]
public void SetVisible( bool visible )
{
ParticleRenderer.Enabled = visible;
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, curse: true, alwaysOfferDebug: false )]
public class CurseObscureScreen : Perk
{
private const float TOTAL_TIME = 5f;
private const float FADE_IN_TIME = 0.2f;
private const float FADE_OUT_START = 4.5f;
private const float MAX_INTENSITY = 2f;
private TimeSince _timeSinceHurt;
private bool _isActive;
private float _fadeInStartIntensity;
static CurseObscureScreen()
{
Register<CurseObscureScreen>(
name: "Blindness",
imagePath: "textures/icons/vector/curse_obscure_screen.png",
description: level => $"Reduce vision for {TOTAL_TIME}s when hit"
);
}
public override void Start()
{
base.Start();
}
public override void Update( float dt )
{
base.Update( dt );
if ( !_isActive )
return;
var obscure = Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure];
var vignette = obscure.GetComponent<Vignette>();
if ( _timeSinceHurt > TOTAL_TIME )
{
obscure.Enabled = false;
_isActive = false;
ShouldUpdate = false;
DisplayText = " ";
DisplayCooldown = 0f;
}
else
{
float intensity;
if ( _timeSinceHurt < FADE_IN_TIME )
intensity = Utils.Map( _timeSinceHurt, 0f, FADE_IN_TIME, _fadeInStartIntensity, MAX_INTENSITY, EasingType.SineOut );
else if ( _timeSinceHurt < FADE_OUT_START )
intensity = MAX_INTENSITY;
else
intensity = Utils.Map( _timeSinceHurt, FADE_OUT_START, TOTAL_TIME, MAX_INTENSITY, 0f, EasingType.SineIn );
vignette.Intensity = intensity;
DisplayText = $"{MathX.CeilToInt( TOTAL_TIME - _timeSinceHurt )}";
DisplayCooldown = Utils.Map( _timeSinceHurt, 0f, TOTAL_TIME, 1f, 0f );
}
}
public override void OnHit( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, float force, Enemy enemySource, EnemyType enemyType, float previousHealth )
{
base.OnHit( amount, damageType, isSelfInflicted, dir, force, enemySource, enemyType, previousHealth );
if ( damageType == DamageType.Self )
return;
var obscure = Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure];
var vignette = obscure.GetComponent<Vignette>();
_fadeInStartIntensity = _isActive ? vignette.Intensity : 0f;
obscure.Enabled = true;
_timeSinceHurt = 0f;
_isActive = true;
ShouldUpdate = true;
DisplayCooldown = 1f;
HighlightColor = new Color( 1f, 0f, 0.5f );
HighlightDuration = 0.4f;
HighlightOpacity = 2.5f;
Highlight();
IconScale = Game.Random.Float( 1.1f, 1.2f );
IconAngleOffset = Game.Random.Float( 8f, 12f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
}
void DisableEffect()
{
Manager.Instance.OverlayEffects[OverlayEffectsType.Obscure].Enabled = false;
_isActive = false;
ShouldUpdate = false;
}
public override void OnDie()
{
base.OnDie();
DisableEffect();
}
public override void Remove( bool restart = false )
{
base.Remove( restart );
DisableEffect();
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, curse: true, alwaysOfferDebug: false )]
public class CurseXpCoinHurt : Perk
{
private enum Mod { DmgAmount };
static CurseXpCoinHurt()
{
Register<CurseXpCoinHurt>(
name: "Sharp Coins",
imagePath: "textures/icons/vector/curse_xp_coin_hurt.png",
description: level => $"-{GetValue( level, Mod.DmgAmount ).ToString("0.##")} hp when you get xp coins"
);
}
public override void Start()
{
base.Start();
HighlightColor = new Color( 1f, 0.4f, 0.4f );
HighlightDuration = 0.4f;
HighlightOpacity = 0.7f;
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.CoinFlatDmgAmount, -GetValue( Level, Mod.DmgAmount ), ModifierType.Add );
}
public override void OnGainXpCoin( float xp )
{
base.OnGainXpCoin( xp );
Player.Damage( GetValue( Level, Mod.DmgAmount ), DamageType.Self, Player.Position2D, Utils.GetRandomVector(), upwardAmount: 0f, force: 0f, ragdollForce: 1f, enemySource: null, enemyType: EnemyType.None );
Highlight();
IconScale = Game.Random.Float( 1.1f, 1.15f );
IconAngleOffset = Game.Random.Float( 5f, 10f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.DmgAmount:
default:
return 3f;
//switch(level)
//{
// case 1: default: return 1;
// case 2: return 3;
// case 3: return 6;
// case 4: return 10;
//}
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Uncommon, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Armor })]
public class PerkArmorBuffer : Perk
{
private enum Mod { MaxArmor };
private float _timer;
private const float DELAY = 3f;
static PerkArmorBuffer()
{
Register<PerkArmorBuffer>(
name: "Armor Buffer",
imagePath: "textures/icons/vector/armor_buffer.png",
description: level => $"+1 armor-item every {DELAY}s while\nbelow {(int)GetValue( level, Mod.MaxArmor )} armor-item",
upgradeDescription: level => $"+1 armor-item every {DELAY}s while\nbelow {(int)GetValue( level - 1, Mod.MaxArmor )}→{(int)GetValue( level, Mod.MaxArmor )} armor-item"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
HighlightColor = new Color( 0.9f, 0.9f, 1f );
HighlightDuration = 0.2f;
HighlightOpacity = 0.5f;
}
public override void Refresh()
{
base.Refresh();
}
public override void Update( float dt )
{
base.Update( dt );
var maxArmor = (int)GetValue( Level, Mod.MaxArmor );
if ( Player.Armor < maxArmor )
{
_timer += dt;
if ( _timer >= DELAY )
{
Player.GainArmor( 1 );
_timer = 0f;
Highlight();
IconScale = Game.Random.Float( 1.1f, 1.15f );
IconAngleOffset = Game.Random.Float( 5f, 8f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
}
}
else
{
_timer = 0f;
}
DisplayCooldown = Player.Armor < maxArmor ? Utils.Map( _timer, 0f, 1f, 0f, 1f ) : 0f;
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.MaxArmor:
default:
return level * 2;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, alwaysOfferDebug: false )]
public class PerkAutoAim : Perk
{
private enum Mod { Time, Radius };
private TimeSince _timeSinceCheck;
static PerkAutoAim()
{
Register<PerkAutoAim>(
name: "Aimbot",
imagePath: "textures/icons/vector/auto_aim.png",
description: level => $"Auto-aim at the nearest enemy\n+{GetValue( level, Mod.Radius, true ).ToString("0.##")}m bullet homing range",
upgradeDescription: level => $"Auto-aim at the nearest enemy\n+{GetValue( level, Mod.Radius, true ).ToString( "0.##" )}m bullet homing range"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.AutoAim, 1f, ModifierType.Add );
Player.Modify( this, PlayerStat.BulletHomingRadius, GetValue( Level, Mod.Radius ), ModifierType.Add );
Player.Modify( this, PlayerStat.BulletHomingRadiusDisplay, GetValue( Level, Mod.Radius, true ), ModifierType.Add );
}
public override void Update( float dt )
{
base.Update( dt );
if ( Player.Stats[PlayerStat.IsBerserk] > 0f )
return;
if ( _timeSinceCheck > 0.2f )
{
var closestEnemy = Manager.Instance.GetClosestEnemy( Player.Position2D, onlyCountsAsKill: false );
if ( closestEnemy.IsValid() )
{
var dir = (closestEnemy.Position2D - Player.Position2D).Normal;
Player.AimDir = dir;
}
_timeSinceCheck = 0f;
}
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.Radius:
default:
return 1.0f * (isPercent ? 1f : Utils.Meter2Unit);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Legendary, includedAtStart: false, locked: true, alwaysOfferDebug: false )]
public class PerkBulletBounceCopy : Perk
{
private enum Mod { Chance };
static PerkBulletBounceCopy()
{
Register<PerkBulletBounceCopy>(
name: "Scatterbounce",
imagePath: "textures/icons/vector/bullet_bounce_copy.png",
description: level => $"{(int)GetValue( level, Mod.Chance, true )}% chance for bullet-icon\nto clone on first bounce",
upgradeDescription: level => $"{(int)GetValue( level - 1, Mod.Chance, true )}%→{(int)GetValue( level, Mod.Chance, true )}% chance for bullet-icon\nto clone on first bounce"
);
}
public override void Start()
{
base.Start();
HighlightColor = new Color( 0.9f, 0.6f, 0.8f );
HighlightDuration = 0.1f;
HighlightOpacity = 2f;
}
public override void Refresh()
{
base.Refresh();
}
public override void OnBulletBounce( Bullet bullet, Thing other )
{
base.OnBulletBounce( bullet, other );
if ( (int)bullet.Stats[BulletStat.NumBouncing] != bullet.StartingNumBounce - 1 )
return;
if ( Game.Random.Float( 0f, 1f ) > GetValue( Level, Mod.Chance ) )
return;
var dmg = bullet.Stats[BulletStat.Damage];
var dir = Utils.RotateVector( bullet.Velocity.Normal, Game.Random.Float( 15f, 40f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f) );
var b = Player.SpawnBullet( bullet.Position2D, dir, dmg, isFromClip: false, bulletType: bullet.BulletType );
b.Stats[BulletStat.NumPiercing] = bullet.Stats[BulletStat.NumPiercing];
b.Stats[BulletStat.NumBouncing] = bullet.StartingNumBounce;
b.Velocity = dir * bullet.Velocity.Length;
if ( bullet.Stats[BulletStat.ArcHeight] > 0f )
b.SetupArc( bullet.Stats[BulletStat.ArcHeight], 0f );
b.Stats[BulletStat.NumBouncing] -= 1;
b.ShowBounce = b.Stats[BulletStat.NumBouncing] > 0f;
// todo: cloned bullet should have same fire/freeze etc instead of random
if ( other.IsValid() )
b.HitThings.Add( other );
Manager.Instance.SpawnRingRpc( bullet.Position2D, Game.Random.Float( 8f, 12f ), new Color( 0f, 1f, 0f, 0.5f ), lifetime: Game.Random.Float( 0.3f, 0.4f ), path: "ring_spiky" );
Manager.Instance.PlaySfxNearbyRpc( "bounce_copy", bullet.Position2D, pitch: Game.Random.Float( 1f, 1.2f ), volume: 2.2f, maxDist: 350f );
//Highlight();
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.Chance:
default:
return isPercent
? 3f + 10f * level
: 0.03f + 0.10f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Uncommon, alwaysOfferDebug: false )]
public class PerkBulletEarlierShotDamage : Perk
{
private enum Mod { EarlierShotDamage };
static PerkBulletEarlierShotDamage()
{
Register<PerkBulletEarlierShotDamage>(
name: "Build Up",
imagePath: "textures/icons/vector/bullet_damage_earlier_shots.png",
description: level => $"+{GetValue( level, Mod.EarlierShotDamage ).ToString( "0.##" )} bullet dmg each time you shoot\n(resets on reload)",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.EarlierShotDamage ).ToString("0.##")}→{GetValue( level, Mod.EarlierShotDamage ).ToString("0.##")} bullet dmg each time\nyou shoot (resets on reload)"
);
}
// todo: needs a cap, so can't go infinite with PerkBulletCritReload?
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.DamagePerEarlierShot, GetValue( Level, Mod.EarlierShotDamage ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.EarlierShotDamage:
default:
return 0.05f + 0.06f * level;
}
}
public override void OnShoot()
{
RefreshDisplayText();
}
void RefreshDisplayText()
{
DisplayText = Player.Stats[PlayerStat.ShotNum] > 0f ? string.Format( "{0:0.00}", Player.Stats[PlayerStat.ShotNum] * GetValue( Level, Mod.EarlierShotDamage ) ) : " ";
DisplayTextOpacity = Utils.Map( Player.Stats[PlayerStat.ShotNum], 1, 10, 0.75f, 3f );
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, locked: true, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.BulletBounce, PerkCategory.SelfDmg })]
public class PerkBulletHurtSelfBounce : Perk
{
private enum Mod { NumBouncing, ReloadSpeed };
static PerkBulletHurtSelfBounce()
{
Register<PerkBulletHurtSelfBounce>(
name: "Reckless Bouncer",
imagePath: "textures/icons/vector/bullet_hurt_self_bounce.png",
description: level => $"+{(int)GetValue( level, Mod.NumBouncing )} bullet-icon bounces\nYour bullet-icon can hurt you"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.BulletNumBouncing, GetValue( Level, Mod.NumBouncing ), ModifierType.Add );
Player.Modify( this, PlayerStat.BulletCanHitShooter, 1f, ModifierType.Add );
// todo: hurt you for X% instead of full dmg?
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.NumBouncing:
default:
return 3f;
}
}
}
using Sandbox;
using System;
using System.IO;
[Perk( Rarity.Rare, locked: true, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe })]
public class PerkBurstHeal : Perk
{
private enum Mod { Cooldown, HpAmount };
private bool _isReady;
private float _stillTimer;
private float _cooldownTimer;
private const float HEAL_RADIUS = 180f;
private const float DISPLAY_RADIUS = 50f;
private ParticleEffect _particleEffect;
private ParticleSpriteRenderer _particleRenderer;
private ParticleEffect _particleEffectBg;
private ParticleSpriteRenderer _particleRendererBg;
private float _bgCurrOpacity;
private const float STILL_TIME = 2f;
static PerkBurstHeal()
{
Register<PerkBurstHeal>(
name: "Burst Heal",
imagePath: "textures/icons/vector/burse_heal.png",
description: level => $"Stop moving for {STILL_TIME}s to heal nearby players for {(int)GetValue( level, Mod.HpAmount)} hp __ (cooldown: {GetValue( level, Mod.Cooldown )}s)",
upgradeDescription: level => $"Stop moving for {STILL_TIME}s to\nheal nearby players for {(int)GetValue( level - 1, Mod.HpAmount )}→{(int)GetValue( level, Mod.HpAmount )} hp\n(cooldown: {GetValue( level - 1, Mod.Cooldown )}→{GetValue( level, Mod.Cooldown )}s)"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
var particleGo = GameObject.Clone( "prefabs/effects/burst_heal_indicator.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
particleGo.LocalPosition = new Vector3( 0f, 0f, 4f );
_particleEffect = particleGo.GetComponent<ParticleEffect>();
_particleRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
_particleRenderer.Scale = 0f;
var particleBgGo = GameObject.Clone( "prefabs/effects/burst_heal_indicator_bg.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
particleBgGo.LocalPosition = new Vector3( 0f, 0f, 3.5f );
_particleEffectBg = particleBgGo.GetComponent<ParticleEffect>();
_particleRendererBg = particleBgGo.GetComponent<ParticleSpriteRenderer>();
_particleRendererBg.Scale = DISPLAY_RADIUS * Player.LocalScale.x;
HighlightColor = new Color( 0.7f, 0.7f, 1f );
HighlightDuration = 0.25f;
HighlightOpacity = 4f;
}
public override void IncreaseLevel()
{
base.IncreaseLevel();
_isReady = true;
_stillTimer = 0f;
_bgCurrOpacity = 0f;
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.Cooldown:
default:
return 105f - 15f * level;
case Mod.HpAmount:
return 20f + 5f * level;
}
}
public override void Update( float dt )
{
base.Update( dt );
var displayRadius = DISPLAY_RADIUS * Player.LocalScale.x;
if ( _isReady )
{
if ( Player.IsMoving )
{
_stillTimer = 0f;
}
else
{
_stillTimer += dt;
if ( _stillTimer > STILL_TIME )
{
Shoot();
_stillTimer = 0f;
}
}
float progress = Utils.Map( _stillTimer, 0f, STILL_TIME, 0f, 1f );
_particleRenderer.Scale = progress * displayRadius;
_particleEffect.Alpha = Utils.Map( progress, 0f, 1f, 0f, 1f, EasingType.QuadOut );
}
else
{
_cooldownTimer += dt;
if ( _cooldownTimer > GetValue( Level, Mod.Cooldown ) )
{
_isReady = true;
Highlight();
}
_particleRenderer.Scale = 0f;
_particleEffect.Alpha = 0f;
}
float bgTargetOpacity = !_isReady ? 0f : (Player.IsMoving ? 0.1f : 0.4f);
_bgCurrOpacity = Utils.DynamicEaseTo( _bgCurrOpacity, bgTargetOpacity, 0.2f, dt );
_particleRendererBg.Scale = displayRadius;
_particleEffectBg.Alpha = _bgCurrOpacity;
DisplayText = _isReady ? " " : $"{MathX.CeilToInt( GetValue( Level, Mod.Cooldown ) - _cooldownTimer )}";
DisplayCooldown = _isReady ? 0f : Utils.Map( _cooldownTimer, 0f, GetValue( Level, Mod.Cooldown ), 1f, 0f );
//Utils.DrawCircle(Player.Position2D, GetRadius(visual: false), 20, 0f, Color.Blue);
}
void Shoot()
{
_isReady = false;
_cooldownTimer = 0f;
Manager.Instance.SpawnRingRpc( Player.Position2D, GetRadius(visual: true), new Color( 0.3f, 1f, 0.3f, 0.3f ), lifetime: 0.35f, path: "ring2" );
Manager.Instance.PlaySfxNearbyRpc( "heal", Player.Position2D, pitch: Game.Random.Float( 1f, 1.1f ), volume: 1.1f, maxDist: 400f );
Player.Heal( amount: GetValue( Level, Mod.HpAmount ) );
var pos = Player.Position2D;
var radius = GetRadius( visual: false );
var traceResults = Player.Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "player" ).HitTriggersOnly().RunAll().ToList();
foreach ( var tr in traceResults )
{
var gameObject = tr.GameObject;
var player = gameObject.GetComponent<Player>();
if ( player.IsDead || !(player.HpPercent < 1f) || player == Player )
continue;
player.HealRpc( amount: GetValue( Level, Mod.HpAmount ), otherPlayerHealer: Player );
}
}
float GetRadius( bool visual = false )
{
return HEAL_RADIUS * Player.Stats[PlayerStat.RadiusMultiplier] * (visual ? 1.1f : 1f);
}
public override void OnDie()
{
base.OnDie();
_particleEffect.Alpha = 0f;
_particleEffectBg.Alpha = 0f;
}
public override void Remove( bool restart = false )
{
base.Remove( restart );
if ( _particleEffect != null )
_particleEffect.Destroy();
if ( _particleEffectBg != null )
_particleEffectBg.Destroy();
}
}
using System;
using Sandbox;
[Perk( Rarity.Common, locked: true, alwaysOfferDebug: false )]
public class PerkDamageSlowerReload : Perk
{
private enum Mod { OverallDamageMultiplier, ReloadSpeed };
static PerkDamageSlowerReload()
{
Register<PerkDamageSlowerReload>(
name: "Preparation",
imagePath: "textures/icons/vector/overall_damage_slower_reload.png",
description: level => $"+{GetValue( level, Mod.OverallDamageMultiplier, true )}% dmg\n-{GetValue( level, Mod.ReloadSpeed, true )}% reload speed",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.OverallDamageMultiplier, true )}%→{GetValue( level, Mod.OverallDamageMultiplier, true )}% dmg\n-{GetValue( level - 1, Mod.ReloadSpeed, true )}%→-{GetValue( level, Mod.ReloadSpeed, true )}% reload speed"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
}
public override void Refresh()
{
base.Refresh();
// todo: change to non-bullet dmg only?
Player.Modify( this, PlayerStat.OverallDamageMultiplier, GetValue( Level, Mod.OverallDamageMultiplier ), ModifierType.Mult );
Player.Modify( this, PlayerStat.ReloadSpeed, GetValue( Level, Mod.ReloadSpeed ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.OverallDamageMultiplier:
default:
return isPercent
? 2f + 6f * level
: 1f + 0.02f + 0.06f * level;
case Mod.ReloadSpeed:
return isPercent
? 4f + 5f * level
: 1f - (0.04f + 0.05f * level);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Mythic, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe, PerkCategory.Fire })]
public class PerkDashFire : Perk
{
private enum Mod { FireChance };
static PerkDashFire()
{
Register<PerkDashFire>(
name: "Rug Burn",
imagePath: "textures/icons/vector/dash_fire.png",
description: level => $"{(int)GetValue( level, Mod.FireChance, true )}% chance to\nstart a fire when you dash",
upgradeDescription: level => $"{(int)GetValue( level - 1, Mod.FireChance, true )}%→{(int)GetValue( level, Mod.FireChance, true )}% chance to\nstart a fire when you dash"
);
}
public override void Start()
{
base.Start();
HighlightColor = new Color( 1f, 1f, 1f );
HighlightDuration = 0.5f;
HighlightOpacity = 1f;
}
public override void Refresh()
{
base.Refresh();
}
public override void OnDashStartedEarly( Vector2 dir )
{
base.OnDashStartedEarly( dir );
if ( Game.Random.Float( 0f, 1f ) < GetValue( Level, Mod.FireChance ) )
{
Manager.Instance.SpawnFireGroundRpc(
Player.Position2D,
Player,
enemySource: null,
enemyType: EnemyType.None,
damage: Player.Stats[PlayerStat.FireDamage],
lifetime: Player.Stats[PlayerStat.FireLifetime],
spreadChance: Player.Stats[PlayerStat.FireSpreadChance],
canStack: Player.Stats[PlayerStat.FireDmgStack] > 0f,
scale: Player.Stats[PlayerStat.RadiusMultiplier],
colorA: Color.Red,
colorB: Color.Yellow
);
Manager.Instance.PlaySfxNearbyRpc( "burn", Player.Position2D, pitch: Game.Random.Float( 0.95f, 1f ), volume: 0.9f, maxDist: 300f );
Highlight();
Player.DodgeDuckRpc( dir, time: Game.Random.Float( 0.15f, 0.2f ) );
}
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.FireChance:
default:
return isPercent
? 25f + 25f * level
: 0.25f + 0.25f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Rare, alwaysOfferDebug: false )]
public class PerkDashLength : Perk
{
private enum Mod { DashLength };
static PerkDashLength()
{
Register<PerkDashLength>(
name: "Leg Day",
imagePath: "textures/icons/vector/dash_strength.png",
description: level => $"+{GetValue( level, Mod.DashLength, true )}% dash distance",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.DashLength, true )}%→{GetValue( level, Mod.DashLength, true )}% dash distance"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.DashStrength, GetValue( Level, Mod.DashLength ), ModifierType.Mult );
Player.Modify( this, PlayerStat.DashInvulnTime, GetValue( Level, Mod.DashLength ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.DashLength:
default:
return isPercent
? 15f + 25f * level
: 1f + 0.15f + 0.25f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Common, includedAtStart: false, alwaysOfferDebug: false )]
public class PerkExplosionSize : Perk
{
private enum Mod { ExplosionSizeMultiplier };
static PerkExplosionSize()
{
Register<PerkExplosionSize>(
name: "Bigger Booms",
imagePath: "textures/icons/vector/explosion_size.png",
description: level => $"+{(int)GetValue( level, Mod.ExplosionSizeMultiplier, true )}% explosion size",
upgradeDescription: level => $"+{(int)GetValue( level - 1, Mod.ExplosionSizeMultiplier, true )}%→{(int)GetValue( level, Mod.ExplosionSizeMultiplier, true )}% explosion size"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.ExplosionSizeMultiplier, GetValue( Level, Mod.ExplosionSizeMultiplier ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.ExplosionSizeMultiplier:
default:
return isPercent
? 5f + 15f * level
: 1f + (0.05f + 0.15f * level);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Rare, includedAtStart: false, locked: true, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe, PerkCategory.Explosion })]
public class PerkFearBomb : Perk
{
private enum Mod { BombChance };
static PerkFearBomb()
{
Register<PerkFearBomb>(
name: "Martyrdom",
imagePath: "textures/icons/vector/fear_drop_grenade.png",
description: level => $"{GetValue( level, Mod.BombChance, true )}% chance for scared enemies\nyou kill to drop a bomb",
upgradeDescription: level => $"{GetValue( level - 1, Mod.BombChance, true )}→{GetValue( level, Mod.BombChance, true )}% chance for scared enemies\nyou kill to drop a bomb"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.FearDropGrenadeChance, GetValue( Level, Mod.BombChance ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.BombChance:
default:
return isPercent
? 15 * level
: 0.15f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Common, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Fire })]
public class PerkFireBullet : Perk
{
private enum Mod { ShootFireIgniteChance };
static PerkFireBullet()
{
Register<PerkFireBullet>(
name: "Burning Bullets",
imagePath: "textures/icons/vector/fire_bullet.png",
description: level => $"{(int)GetValue( level, Mod.ShootFireIgniteChance, true )}% chance for bullet-icon to ignite",
upgradeDescription: level => $"{(int)GetValue( level - 1, Mod.ShootFireIgniteChance, true )}%→{(int)GetValue( level, Mod.ShootFireIgniteChance, true )}% chance for\nbullet-icon to ignite"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.ShootFireIgniteChance, GetValue( Level, Mod.ShootFireIgniteChance ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.ShootFireIgniteChance:
default:
return isPercent
? 3f + 6f * level
: 0.03f + 0.06f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Rare, alwaysOfferDebug: false )]
public class PerkHealEffectiveness : Perk
{
private enum Mod { HealEffectiveness };
static PerkHealEffectiveness()
{
Register<PerkHealEffectiveness>(
name: "Improved Healing",
imagePath: "textures/icons/vector/heal_effectiveness.png",
description: level => $"+{GetValue( level, Mod.HealEffectiveness, true )}% healing received",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.HealEffectiveness, true )}%→{GetValue( level, Mod.HealEffectiveness, true )}% healing received"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.HealEffectiveness, GetValue( Level, Mod.HealEffectiveness ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.HealEffectiveness:
default:
switch(level)
{
case 1: default: return isPercent ? 10f : 1f + 0.1f;
case 2: return isPercent ? 15f : 1f + 0.15f;
case 3: return isPercent ? 20f : 1f + 0.20f;
case 4: return isPercent ? 25f : 1f + 0.25f;
case 5: return isPercent ? 30f : 1f + 0.30f;
}
//return isPercent
// ? 6f + 4f * level
// : 1f + (0.06f + 0.04f * level);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Mythic, locked: true, alwaysOfferDebug: false )]
public class PerkLessAmmoLongerLifetime : Perk
{
private enum Mod { BulletLifetime, MaxAmmoCount };
static PerkLessAmmoLongerLifetime()
{
Register<PerkLessAmmoLongerLifetime>(
name: "More Propellant",
imagePath: "textures/icons/vector/less_ammo_longer_lifetime.png",
description: level => $"+{GetValue( level, Mod.BulletLifetime, true )}% bullet lifetime\n-{GetValue( level, Mod.MaxAmmoCount )} ammo",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.BulletLifetime, true )}%→{GetValue( level, Mod.BulletLifetime, true )}% bullet lifetime\n-{GetValue( level - 1, Mod.MaxAmmoCount )}→-{GetValue( level, Mod.MaxAmmoCount )} ammo"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.BulletLifetime, GetValue( Level, Mod.BulletLifetime ), ModifierType.Mult );
Player.Modify( this, PlayerStat.MaxAmmoCount, -GetValue( Level, Mod.MaxAmmoCount ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.BulletLifetime:
default:
return isPercent
? 10f + 20f * level
: 1f + (0.10f + 0.20f * level);
case Mod.MaxAmmoCount:
return level;
}
}
}
using System;
using System.Numerics;
using Sandbox;
[Perk( Rarity.Unique, locked: true, alwaysOfferDebug: false )]
public class PerkLoseWhenGainXp : Perk
{
private enum Mod { BulletDamageAddition };
static PerkLoseWhenGainXp()
{
Register<PerkLoseWhenGainXp>(
name: "Anarchist",
imagePath: "textures/icons/vector/lose_when_gain_xp.png",
description: level => $"+{GetValue( level, Mod.BulletDamageAddition )} bullet dmg\nRemove and banish this perk\nwhen you get xp coin"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.BulletDamage, GetValue( Level, Mod.BulletDamageAddition ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.BulletDamageAddition:
default:
return 4f;
}
}
public override void OnGainXpCoin( float xp )
{
base.OnGainXpCoin( xp );
Player.RemovePerk( TypeLibrary.GetType( GetType() ) );
Player.BanishPerk( TypeLibrary.GetType( GetType() ) );
Manager.Instance.Chat.AddLocalChatMessage( $"{Perk.GetRichTextToken( GetType() )} Removed and banished because you got a coin", from: "" );
Manager.Instance.PlaySfxNearby( "burn", Player.Position2D, pitch: 0.7f, volume: 0.8f, maxDist: 200f );
}
}
using System;
using Sandbox;
[Perk( Rarity.Uncommon, alwaysOfferDebug: false )]
public class PerkMaxHealth : Perk
{
private enum Mod { MaxHp };
static PerkMaxHealth()
{
Register<PerkMaxHealth>(
name: "Healthy Diet",
imagePath: "textures/icons/vector/max_health.png",
description: level => $"+{(int)GetValue( level, Mod.MaxHp, true )}% max hp",
upgradeDescription: level => $"+{(int)GetValue( level - 1, Mod.MaxHp, true )}%→{(int)GetValue( level, Mod.MaxHp, true )}% max hp"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
// todo: how does this interact with things that change max hp by a flat amount? should this instead add a flat amount based on current hp, instead of multiplying each time max hp stat is refreshed?
Player.Modify( this, PlayerStat.MaxHp, GetValue( Level, Mod.MaxHp ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.MaxHp:
default:
return isPercent
? (5f + 15f * level)
: 1f + (0.05f + 0.15f * level);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Mythic, includedAtStart: false, locked: true, alwaysOfferDebug: false )]
public class PerkOnly1HpMoveSpeed : Perk
{
private enum Mod { MoveSpeed };
private bool _isActive;
static PerkOnly1HpMoveSpeed()
{
Register<PerkOnly1HpMoveSpeed>(
name: "Adrenaline",
imagePath: "textures/icons/vector/only_1hp_movespeed.png",
description: level => $"+{GetValue( level, Mod.MoveSpeed, true )}% move speed while at 1 hp",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.MoveSpeed, true )}%→{GetValue( level, Mod.MoveSpeed, true )}% move speed\nwhile at 1 hp"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
DisplayCooldownColor = new Color( 0.3f, 0.5f, 1f, 0.25f );
}
public override void Refresh()
{
base.Refresh();
if ( !(Player.Health > 1f) )
Enable();
}
public override void Update( float dt )
{
base.Update( dt );
if( _isActive )
{
if ( Player.Health > 1f )
Disable();
}
else
{
if ( !(Player.Health > 1f) )
Enable();
}
}
void Enable()
{
Player.Modify( this, PlayerStat.MoveSpeedMultiplier, GetValue( Level, Mod.MoveSpeed ), ModifierType.Mult );
_isActive = true;
DisplayCooldown = 1f;
DisplayText = $"{GetValue( Level, Mod.MoveSpeed, true )}%";
}
void Disable()
{
Player.StopModifying( this, PlayerStat.MoveSpeedMultiplier );
_isActive = false;
DisplayCooldown = 0f;
DisplayText = " ";
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.MoveSpeed:
default:
return isPercent
? 12f + 10f * level
: 1f + 0.12f + 0.10f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Rare, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Punch })]
public class PerkPunch : Perk
{
private enum Mod { DamagePercent, AttackSpeed };
public const float PUNCH_LIFETIME = 0.04f;
public const float PUNCH_SPEED = 1320f;
public override float ImportanceMultiplier => 1.4f;
static PerkPunch()
{
Register<PerkPunch>(
name: "Puncher",
imagePath: "textures/icons/vector/punch.png",
description: level => $"You no longer shoot bullets\nPunch for {(int)GetValue( level, Mod.DamagePercent, true )}% bullet dmg\n-{GetValue( level, Mod.AttackSpeed, true )}% attack speed",
upgradeDescription: level => $"You no longer shoot bullets\nPunch for {(int)GetValue( level - 1, Mod.DamagePercent, true )}%→{(int)GetValue( level, Mod.DamagePercent, true )}% bullet dmg\n-{GetValue( level - 1, Mod.AttackSpeed, true )}%→-{GetValue( level, Mod.AttackSpeed, true )}% attack speed"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
// todo: add to stats screen - Punch Damage
Player.Modify( this, PlayerStat.PunchDamagePercent, GetValue( Level, Mod.DamagePercent ), ModifierType.Add );
Player.Modify( this, PlayerStat.PunchBullets, 1f, ModifierType.Add );
Player.Modify( this, PlayerStat.AttackSpeed, GetValue( Level, Mod.AttackSpeed ), ModifierType.Mult );
Player.SetGunVisible( false );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.DamagePercent:
default:
return isPercent
? 150f + 20f * level + (level == 5 ? 10f : 0f)
: 1.50f + 0.20f * level + (level == 5 ? 0.10f : 0f);
case Mod.AttackSpeed:
return isPercent
? 30f + 5f * level
: 1f - (0.30f + 0.05f * level);
}
}
public override void Remove( bool restart = false )
{
Player.SetGunVisible( true );
}
}
using System;
using Sandbox;
[Perk( Rarity.Common, includedAtStart: false, locked: true, minUnlocksReq: 2, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.NumProjectiles, PerkCategory.ArcBullets })]
public class PerkPunchBullets : Perk
{
private enum Mod { NumBullets, SpreadModifier };
static PerkPunchBullets()
{
Register<PerkPunchBullets>(
name: "Backblast",
imagePath: "textures/icons/vector/punch_bullets.png",
description: level => $"Lob {(int)GetValue( level, Mod.NumBullets )} bullet-icon from enemies\nyou kill with punches",
upgradeDescription: level => $"Lob {(int)GetValue( level - 1, Mod.NumBullets )}→{(int)GetValue( level, Mod.NumBullets )} bullet-icon from enemies\nyou kill with punches"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
}
public override void OnKill( Enemy enemy, DamageType damageType, bool countsAsKill )
{
base.OnKill( enemy, damageType, countsAsKill );
if ( !countsAsKill ) return;
if ( damageType != DamageType.Punch )
return;
int numBullets = (int)GetValue( Level, Mod.NumBullets );
float damage = Player.GetBulletDamage( isFromClip: false, isLastAmmo: false );
float spread = Player.Stats[PlayerStat.BulletSpread] * GetValue( Level, Mod.SpreadModifier );
var dir = Player.FacingDir;
float currAngleOffset = numBullets == 1 ? 0f : -spread * 0.5f;
float increment = numBullets == 1 ? 0f : spread / (float)(numBullets - 1);
for ( int i = 0; i < numBullets; i++ )
{
var spawnOffset = 10f;
var currDir = Utils.RotateVector( dir, currAngleOffset + increment * i );
var pos = enemy.Position2D + currDir * spawnOffset * Player.Stats[PlayerStat.Scale];
var bullet = Player.SpawnBullet( pos, currDir, damage, isFromClip: false );
bullet.SetupArc( arcHeight: Game.Random.Float( 120f, 160f ), Player.Stats[PlayerStat.ArcBulletBounces] );
bullet.Velocity *= Game.Random.Float( 0.05f, 0.45f );
bullet.Stats[BulletStat.Lifetime] *= Game.Random.Float( 1f, 1.4f );
}
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.NumBullets:
default:
switch(level)
{
case 1: default: return 2;
case 2: return 3;
case 3: return 4;
case 4: return 5;
case 5: return 6;
case 6: return 7;
case 7: return 8;
}
case Mod.SpreadModifier:
return 1.3f + 0.55f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Uncommon, includedAtStart: false, alwaysOfferDebug: false )]
public class PerkRandomExisting : Perk
{
private int _perkCount;
private TimeSince _timeSincePerk;
public override float ImportanceMultiplier => 0.15f;
static PerkRandomExisting()
{
Register<PerkRandomExisting>(
name: "Specialization",
imagePath: "textures/icons/vector/random_existing_upgrade.png",
description: level => $"+1 random perk you already have",
upgradeDescription: level => $"+1 random perk you already have",
tooltipInfo: "Won't give you a curse perk"
);
}
public override void Start()
{
base.Start();
}
public override void IncreaseLevel()
{
base.IncreaseLevel();
ShouldUpdate = true;
_perkCount++;
_timeSincePerk = 0f;
}
public override void Refresh()
{
base.Refresh();
}
public override void Update( float dt )
{
base.Update( dt );
if ( _perkCount > 0 && _timeSincePerk > 0.2f )
{
if ( !Player.GiveRandomExistingPerk( ignoreType: typeof( PerkRandomExisting ) ) )
//if ( !Player.GiveRandomExistingPerk( ignoreType: typeof( PerkRandomExisting ), showMessage: true, perkType: TypeLibrary.GetType( typeof( PerkRandomExisting ) ) ) )
{
//Player.PlaySfxUI( "error2", pitch: 0.55f, volume: 0.9f );
//Manager.Instance.SpawnFloaterText( Player.WorldPosition.WithZ( 65f ), "ALREADY MAX!", new Color( 1f, 0.5f, 0.5f ), 1.3f, FloaterType.NegativeMessage );
Manager.Instance.SpawnFloaterText( Player.WorldPosition.WithZ( 65f ), "FAILED!", new Color( 1f, 0.5f, 0.5f ), 1.3f, FloaterType.NegativeMessage );
}
_perkCount--;
if ( _perkCount <= 0 )
ShouldUpdate = false;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, locked: true, alwaysOfferDebug: false )]
public class PerkRarityUnique : Perk
{
private enum Mod { Chance };
static PerkRarityUnique()
{
Register<PerkRarityUnique>(
name: "Ultra Rare",
imagePath: "textures/icons/vector/rarity_unique.png",
description: level => $"+{(int)GetValue( level, Mod.Chance, true )}% chance of\nLegendary and Unique perks"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.RarityIncreaseLegendary, GetValue( Level, Mod.Chance ), ModifierType.Add );
Player.Modify( this, PlayerStat.RarityIncreaseUnique, GetValue( Level, Mod.Chance ), ModifierType.Add );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.Chance:
default:
return isPercent
? 300f * level
: 3.00f * level;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, locked: true, minUnlocksReq: 2, alwaysOfferDebug: false )]
public class PerkShootOnlyWhenClick : Perk
{
private enum Mod { DamageGainSpeed, MaxDamage };
private float _accumulatedDamage;
private int _currDmgInt;
static PerkShootOnlyWhenClick()
{
Register<PerkShootOnlyWhenClick>(
name: "Trigger Discipline",
imagePath: "textures/icons/vector/shoot_only_when_click.png",
description: level => $"Shoot with left-click\nWhile not shooting or reloading,\n+{GetValue( level, Mod.DamageGainSpeed ).ToString("0.##")} dmg/s for next shot (max: [n]+{(int)GetValue( level, Mod.MaxDamage )}[/n])"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.OnlyShootWithMouse1, 1f, ModifierType.Add );
DisplayCooldownColor = new Color( 0.4f, 0.4f, 1f );
}
public override void Update( float dt )
{
base.Update( dt );
if ( !Player.IsReloading )
{
var maxDmg = GetValue( Level, Mod.MaxDamage );
_accumulatedDamage = Math.Min( _accumulatedDamage + GetValue( Level, Mod.DamageGainSpeed ) * dt, maxDmg );
Player.Modify( this, PlayerStat.ShotDamageAdd, _accumulatedDamage, ModifierType.Add );
DisplayCooldown = Utils.Map( _accumulatedDamage, 0f, maxDmg, 0f, 1f );
}
DisplayText = $"+{_accumulatedDamage.ToString("0.#")}";
var targetScale = Input.Down( "click" )
? 1f
: 1.3f;
IconScale = MathX.Lerp( IconScale, targetScale, Time.Delta * 20f );
var newDmg = _accumulatedDamage.FloorToInt();
if( newDmg != _currDmgInt )
{
if(!Input.Down( "click" ) )
{
IconScale *= 1.1f;
Manager.Instance.PlaySfxUI( "click", pitch: Utils.Map( _currDmgInt, 1, 50, 1f, 3f ), volume: Utils.Map( _currDmgInt, 1, 20, 0.05f, 0.3f ) );
}
_currDmgInt = newDmg;
}
// todo: different holdtype while not shooting
}
public override void OnShoot()
{
base.OnShoot();
_accumulatedDamage = 0f;
Player.StopModifying( this, PlayerStat.ShotDamageAdd );
DisplayCooldown = 0f;
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.DamageGainSpeed:
default:
return 1f;
case Mod.MaxDamage:
return 30f;
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Unique, alwaysOfferDebug: false )]
public class PerkTurnSpeed : Perk
{
private enum Mod { TurnSpeed, ReloadSpeed };
static PerkTurnSpeed()
{
Register<PerkTurnSpeed>(
name: "Quick Reflexes",
imagePath: "textures/icons/vector/turn_speed.png",
description: level => $"+{GetValue( level, Mod.TurnSpeed, true )}% turn speed\n+{(int)GetValue( level, Mod.ReloadSpeed, true )}% reload speed"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.TurnSpeed, GetValue( Level, Mod.TurnSpeed ), ModifierType.Mult );
Player.Modify( this, PlayerStat.ReloadSpeed, GetValue( Level, Mod.ReloadSpeed ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.TurnSpeed:
default:
return isPercent
? 250f
: 1f + 2.5f;
case Mod.ReloadSpeed:
return isPercent
? 8f
: 1f + (0.08f * level);
}
}
}
using System;
using Sandbox;
[Perk( Rarity.Legendary, alwaysOfferDebug: false )]
public class PerkWrapArenaEdge : Perk
{
private enum Mod { Cooldown };
private TimeSince _timeSinceWarping;
private bool _isActive;
static PerkWrapArenaEdge()
{
Register<PerkWrapArenaEdge>(
name: "Wraparound",
imagePath: "textures/icons/vector/wrap_arena_edge.png",
description: level => $"Teleport when you touch the fence __ (cooldown: {GetValue( level, Mod.Cooldown )}s)",
upgradeDescription: level => $"Teleport when you touch the fence __ (cooldown: {GetValue( level - 1, Mod.Cooldown )}→{GetValue( level, Mod.Cooldown )}s)"
);
}
public override void Start()
{
base.Start();
ShouldUpdate = true;
_timeSinceWarping = 60f;
// todo: too annoying to teleport without expecting it?
// add a indicator circle or something
}
public override void IncreaseLevel()
{
base.IncreaseLevel();
_isActive = true;
}
public override void Refresh()
{
base.Refresh();
}
public override void Update( float dt )
{
base.Update( dt );
float cooldown = GetValue( Level, Mod.Cooldown );
if ( _isActive && !Player.IsInTheAir )
{
var pos = Player.Position2D;
var amount = 1.15f;
var minX = Manager.Instance.BOUNDS_MIN.x - Player.BoundsExpand + Player.Radius * amount;
var maxX = Manager.Instance.BOUNDS_MAX.x + Player.BoundsExpand - Player.Radius * amount;
var minY = Manager.Instance.BOUNDS_MIN.y - Player.BoundsExpand + Player.Radius * amount;
var maxY = Manager.Instance.BOUNDS_MAX.y + Player.BoundsExpand - Player.Radius * amount;
if ( pos.x > maxX && pos.y > maxY ) WarpTo( new Vector2( minX, minY ) );
else if ( pos.x > maxX && pos.y < minY ) WarpTo( new Vector2( minX, maxY ) );
else if ( pos.x < minX && pos.y > maxY ) WarpTo( new Vector2( maxX, minY ) );
else if ( pos.x < minX && pos.y < minY ) WarpTo( new Vector2( maxX, maxY ) );
else if ( pos.x > maxX ) WarpTo( new Vector2( minX, pos.y ) );
else if ( pos.x < minX ) WarpTo( new Vector2( maxX, pos.y ) );
else if ( pos.y > maxY ) WarpTo( new Vector2( pos.x, minY ) );
else if ( pos.y < minY ) WarpTo( new Vector2( pos.x, maxY ) );
}
else
{
if ( _timeSinceWarping > cooldown )
{
_isActive = true;
HighlightColor = new Color( 0.6f, 0.6f, 1f );
HighlightDuration = 0.25f;
HighlightOpacity = 1.2f;
Highlight();
}
}
DisplayText = !_isActive ? $"{MathX.CeilToInt( cooldown - _timeSinceWarping )}" : " ";
DisplayCooldown = !_isActive ? Utils.Map( _timeSinceWarping, 0f, cooldown, 1f, 0f ) : 0f;
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.Cooldown:
default:
return level == 1 ? 60f : 30f;
}
}
void WarpTo( Vector2 pos )
{
Player.Teleport( pos );
_timeSinceWarping = 0f;
_isActive = false;
HighlightColor = new Color( 0.1f, 0.2f, 1f );
HighlightDuration = 0.5f;
HighlightOpacity = 4f;
Highlight();
}
}
using System;
using Sandbox;
[Perk( Rarity.Epic, alwaysOfferDebug: false )]
public class PerkZoomOut : Perk
{
private enum Mod { CoinAttractRange, CameraDistance };
static PerkZoomOut()
{
Register<PerkZoomOut>(
name: "Spyglass",
imagePath: "textures/icons/vector/zoom_out.png",
description: level => $"+{GetValue( level, Mod.CameraDistance, true )}% camera distance\n-{GetValue( level, Mod.CoinAttractRange, true )}% xp coin attract range",
upgradeDescription: level => $"+{GetValue( level - 1, Mod.CameraDistance, true )}%→{GetValue( level, Mod.CameraDistance, true )}% camera distance\n-{GetValue( level - 1, Mod.CoinAttractRange, true )}%→-{GetValue( level, Mod.CoinAttractRange, true )}% xp coin attract range"
);
}
public override void Start()
{
base.Start();
}
public override void Refresh()
{
base.Refresh();
Player.Modify( this, PlayerStat.CoinAttractRange, GetValue( Level, Mod.CoinAttractRange ), ModifierType.Mult );
Player.Modify( this, PlayerStat.CameraDistance, GetValue( Level, Mod.CameraDistance ), ModifierType.Mult );
}
private static float GetValue( int level, Mod mod, bool isPercent = false )
{
switch ( mod )
{
case Mod.CoinAttractRange:
default:
return isPercent
? 15f * level
: 1f - 0.15f * level;
case Mod.CameraDistance:
return isPercent
? 10f * level
: 1f + 0.10f * level;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
public enum ShopItemCategory
{
Gun,
Charm,
Gem,
}
public struct ShopItemDef
{
public string Id;
public ShopItemCategory Category;
public string Name;
public int Price;
public string IconPath;
public readonly string PrefabPath => Category switch
{
ShopItemCategory.Gun => $"prefabs/guns/{Id}.prefab",
ShopItemCategory.Charm => $"prefabs/charms/{Id}.prefab",
ShopItemCategory.Gem => $"prefabs/gems/{Id}.prefab",
_ => null
};
public int[] UpgradePrices; // gems only: null/empty = not upgradeable; each entry is the cost per upgrade level
public int RequiredPurchases; // 0 = no gate; N = must own N items in this category first
public int GemSocketCount; // guns only: number of gem sockets (0/3 = three sockets)
public int CharmSlotCount; // guns only: number of charm slots (0/1 = one slot)
public Func<string>? ItemDescription; // guns and charms: rich description (no level)
public Func<int, string>? GemDescription; // gems only: description at the given 1-indexed level (shown in Loadout)
public Func<int, string>? GemUpgradeDescription; // gems only: upgrade progression description at the given 1-indexed level (shown in Shop)
}
public enum ProgressStat
{
EnemyKills,
MinibossKills,
BossKills,
DamageDealt,
TotalRuns,
RunsWon,
Deaths,
PerksCollected,
ChestsOpened,
EvilChestsOpened,
ItemsCollected,
FireDamageDealt,
BarrelsDestroyed,
EnemiesFrozen,
PoisonDamageDealt,
BuzzsawDamageDealt,
Rerolls,
PerksBanished,
DamageTaken,
HpHealed,
SelfDamageTaken,
ArmorDamageBlocked,
BulletBounces,
BulletPierces,
SecondsStandingStill,
EnemiesExecuted,
UniquePerksCollected,
CursesCollected,
MagnetsCollected,
ExplosionKills,
BoomerangKills,
TimesDodged,
XpCoinsCollected,
PerkMaxedOut,
FlyingSkullsDashDestroyed,
ShieldsLost,
EnemiesScared,
TimesDashed,
RadiationDamageDealt,
SecondsAfterBoss,
ZombieEliteKills,
ChargerEliteKills,
SpikerEliteKills,
SpitterEliteKills,
RunnerEliteKills,
ExploderEliteKills,
ExploderMiniKills,
ProjectileHits,
CriticalHits,
ShockKills,
HealthPacksCollected,
SecondsAtOneHp,
Revives,
HpHealedOthers,
MushroomsDisturbed,
}
public enum QuestId
{
// Combat (Tab 0)
KillEnemies,
KillMinibosses,
KillBosses,
DealDamage,
DealFireDamage,
// Exploration (Tab 1)
CompleteRuns,
WinRuns,
OpenChests,
OpenEvilChests,
DestroyBarrels,
// Collection (Tab 2)
CollectPerks,
CollectItems,
RerollPerks,
BanishPerks,
// Combat (continued)
FreezeEnemies,
DealPoisonDamage,
DealBuzzsawDamage,
TakeDamage,
TakeSelfDamage,
BlockDamageWithArmor,
BounceWithBullets,
PierceWithBullets,
// Survival (continued)
HealHp,
StandStill,
// Collection (continued)
GetUniquePerks,
GetCurses,
GetMagnets,
// Combat (continued)
ExecuteEnemies,
KillWithExplosions,
// Survival (Tab 3)
DieDeaths,
// Combat (continued)
KillWithBoomerangs,
DestroyFlyingSkullsDashing,
// Survival (continued)
DodgeTimes,
LoseShield,
// Collection (continued)
CollectXpCoins,
MaxOutPerks,
CollectHealthPacks,
// Combat (continued)
ScareEnemies,
DealRadiationDamage,
LandCriticalHits,
KillWithShock,
KillEliteZombies,
KillEliteChargers,
KillEliteSpikers,
KillEliteSpitters,
KillEliteWerewolves,
KillEliteExploders,
KillExploderMinis,
// Survival (continued)
DashTimes,
SurviveAfterBoss,
SurviveAtOneHp,
ReviveTimes,
HealOtherPlayers,
DisturbMushrooms,
// Combat (continued)
GetHitByProjectiles,
}
public struct QuestDef
{
public QuestId Id;
public string Name; // Use {0} as placeholder for target value
public string Description; // Short description shown below the title
public ProgressStat Stat;
public int[] LevelTargets; // per-level targets; length = max level
public int[] RewardAmounts; // reward per level, same length as LevelTargets
public string RewardIcon; // path to icon image, shared across all levels
public Color Color;
public int Tab; // 0=Combat, 1=Exploration, 2=Collection, 3=Survival
}
public struct AchievementDef
{
public string Name; // unique key used in UnlockAchievement calls
public string DisplayName;
public string Description;
public int RewardAmount;
public string RewardIcon;
public Color Color;
}
public class ProgressData
{
public Dictionary<ProgressStat, float> Stats { get; set; } = new();
public Dictionary<QuestId, int> QuestLevels { get; set; } = new();
public HashSet<string> UnlockedAchievements { get; set; } = new();
public HashSet<string> ClaimedAchievements { get; set; } = new();
public int Coins { get; set; }
public HashSet<string> OwnedShopItems { get; set; } = new();
public HashSet<string> UnlockedLockedPerks { get; set; } = new();
public string SelectedGunId { get; set; }
public string SelectedCharmId { get; set; }
public List<string> SelectedCharmIds { get; set; } = new();
public Dictionary<string, int> GemUpgradeLevels { get; set; } = new(); // 0 = base level (just bought)
public List<string> EquippedGems { get; set; } = new(); // max 3
public float PerkUnlockXp { get; set; } // XP accumulated toward next unlock
}
public static partial class ProgressManager
{
private static ProgressData _data;
private static string FilePath => "progress.json";
private static bool _isDirty = false;
private static RealTimeSince _timeSinceLastSave = 0f;
private const float SaveInterval = 5f;
public static int StateVersion { get; private set; } = 0;
// Perk unlock XP progress
public static float PerkUnlockXp { get { if ( _data == null ) Load(); return _data.PerkUnlockXp; } }
public static int UnlockedLockedPerksCount { get { if ( _data == null ) Load(); return _data.UnlockedLockedPerks.Count; } }
private static float _perkUnlockXpBeforeRun = 0f;
private static float _runXpEarned = 0f;
private static int _runPerkUnlocksEarned = 0;
public static float PerkUnlockXpBeforeRun => _perkUnlockXpBeforeRun;
public static float RunXpEarned => _runXpEarned;
public static int RunPerkUnlocksEarned => _runPerkUnlocksEarned;
public static float GetXpRequiredForNextPerkUnlock( int extraUnlocksSoFar = 0 )
{
if ( _data == null ) Load();
int total = _data.UnlockedLockedPerks.Count + extraUnlocksSoFar;
const float Base = 50f;
const float Step = 5f;
const float Max = 1000f;
return Math.Min( Base + Step * total, Max );
}
public static void ProcessRunXp( float xp )
{
if ( _data == null ) Load();
_perkUnlockXpBeforeRun = _data.PerkUnlockXp;
_runXpEarned = xp;
_runPerkUnlocksEarned = 0;
if ( AreAllLockedPerksUnlocked() ) return;
_data.PerkUnlockXp += xp;
float required = GetXpRequiredForNextPerkUnlock();
if ( _data.PerkUnlockXp >= required )
{
_runPerkUnlocksEarned = 1;
_data.PerkUnlockXp = 0f;
}
_isDirty = true;
StateVersion++;
}
public static bool AreAllLockedPerksUnlocked()
{
if ( _data == null ) Load();
var allLocked = TypeLibrary.GetTypes<Perk>()
.Where( t => t.GetAttribute<PerkAttribute>() is { Locked: true, Disabled: false } )
.ToList();
return allLocked.All( t => _data.UnlockedLockedPerks.Contains( t.FullName ) );
}
public static void ClearPerkUnlockXp()
{
if ( _data == null ) Load();
_data.PerkUnlockXp = 0f;
_runPerkUnlocksEarned = 0;
_isDirty = true;
StateVersion++;
Save();
}
// Quest definitions — defined in code, never serialized.
// CONSTRAINT: Each QuestId must map to a unique ProgressStat.
// CollectQuestReward resets that stat to the remainder; two quests sharing a stat would corrupt each other.
private static QuestDef[] _quests;
private static string[] _questTabNames;
public static QuestDef[] Quests => _quests ??= BuildQuests();
public static string[] QuestTabNames => _questTabNames ??= GetQuestGroups().OrderBy( g => g.Tab ).Select( g => g.Name ).ToArray();
private record struct QuestGroup( int Tab, string Name, QuestDef[] Quests );
private static QuestDef[] BuildQuests()
{
var result = new List<QuestDef>();
foreach ( var group in GetQuestGroups() )
foreach ( var quest in group.Quests )
{
var q = quest;
q.Tab = group.Tab;
result.Add( q );
}
return result.ToArray();
}
public static void Load()
{
_data = FileSystem.Data.ReadJsonSafe<ProgressData>( FilePath, new ProgressData() );
// Migrate single SelectedCharmId to SelectedCharmIds list
if ( (_data.SelectedCharmIds == null || _data.SelectedCharmIds.Count == 0) && !string.IsNullOrEmpty( _data.SelectedCharmId ) )
{
_data.SelectedCharmIds = [_data.SelectedCharmId];
}
_data.SelectedCharmIds ??= [];
_quests = null;
_achievements = null;
}
public static void Save()
{
FileSystem.Data.WriteJson( FilePath, _data );
_isDirty = false;
_timeSinceLastSave = 0f;
}
public static void Tick()
{
if ( _isDirty && _timeSinceLastSave > SaveInterval )
Save();
}
public static void IncrementStat( ProgressStat stat, float amount )
{
if ( _data == null ) Load();
if ( !_data.Stats.ContainsKey( stat ) ) _data.Stats[stat] = 0f;
_data.Stats[stat] += amount;
_isDirty = true;
StateVersion++;
}
public static float GetStat( ProgressStat stat )
{
if ( _data == null ) Load();
return _data.Stats.TryGetValue( stat, out float v ) ? v : 0f;
}
public static int GetQuestLevel( QuestId id )
{
if ( _data == null ) Load();
return _data.QuestLevels.TryGetValue( id, out int v ) ? v : 0;
}
public static bool IsQuestCompleted( QuestId id )
{
var def = GetDef( id );
return GetQuestLevel( id ) >= def.LevelTargets.Length;
}
public static bool IsQuestReadyToCollect( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.LevelTargets.Length ) return false;
return GetStat( def.Stat ) >= def.LevelTargets[level];
}
/// <summary>
/// Collects the current level reward for a quest. Advances the quest level and resets the
/// tracking stat to zero — overflow is always discarded. Returns false if the quest is not
/// ready to collect.
/// </summary>
public static bool CollectQuestReward( QuestId id )
{
if ( !IsQuestReadyToCollect( id ) ) return false;
var def = GetDef( id );
int level = GetQuestLevel( id );
int reward = def.RewardAmounts[level];
_data.Stats[def.Stat] = 0f;
_data.QuestLevels[id] = level + 1;
_data.Coins += reward;
StateVersion++;
Save(); // immediate save on reward collection
return true;
}
/// <summary>
/// Returns (current, target) for the active level of the quest. If completed, returns (lastTarget, lastTarget).
/// </summary>
public static (float current, int target) GetQuestProgress( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.LevelTargets.Length )
{
int last = def.LevelTargets[^1];
return (last, last);
}
return (GetStat( def.Stat ), def.LevelTargets[level]);
}
public static int GetCurrentRewardAmount( QuestId id )
{
var def = GetDef( id );
int level = GetQuestLevel( id );
if ( level >= def.RewardAmounts.Length ) return def.RewardAmounts[^1];
return def.RewardAmounts[level];
}
// Achievement definitions — defined in code, never serialized.
// Run snapshot — captures stat values at run start so we can diff at run end.
private static Dictionary<ProgressStat, float> _runSnapshot = new();
public static HashSet<string> AchievementsUnlockedThisRun { get; private set; } = new();
public static void TakeRunSnapshot()
{
_runSnapshot.Clear();
AchievementsUnlockedThisRun.Clear();
if ( _data == null ) Load();
foreach ( var kvp in _data.Stats )
_runSnapshot[kvp.Key] = kvp.Value;
}
public static float GetStatGainedThisRun( ProgressStat stat )
{
var current = GetStat( stat );
var snapshot = _runSnapshot.TryGetValue( stat, out float v ) ? v : 0f;
return Math.Max( 0f, current - snapshot );
}
private static AchievementDef[] _achievements;
public static AchievementDef[] Achievements => _achievements ??= BuildAchievements();
// BuildAchievements() is defined in AchievementDefs.cs
/// <summary>
/// Marks an achievement as unlocked and ready to collect. Does nothing if already claimed.
/// Call this from gameplay code when the achievement condition is met.
/// </summary>
public static void UnlockAchievement( string name )
{
if ( _data == null ) Load();
if ( _data.ClaimedAchievements.Contains( name ) ) return;
if ( _data.UnlockedAchievements.Add( name ) )
{
AchievementsUnlockedThisRun.Add( name );
StateVersion++;
Save();
}
}
public static bool IsAchievementUnlocked( string name )
{
if ( _data == null ) Load();
return _data.UnlockedAchievements.Contains( name );
}
public static bool IsAchievementClaimed( string name )
{
if ( _data == null ) Load();
return _data.ClaimedAchievements.Contains( name );
}
/// <summary>
/// Collects the reward for an unlocked achievement. Returns false if not yet unlocked or already claimed.
/// </summary>
public static bool ClaimAchievementReward( string name )
{
if ( _data == null ) Load();
if ( !_data.UnlockedAchievements.Contains( name ) ) return false;
if ( _data.ClaimedAchievements.Contains( name ) ) return false;
var achDef = Achievements.FirstOrDefault( a => a.Name == name );
int reward = achDef.Name != null ? achDef.RewardAmount : 0;
_data.UnlockedAchievements.Remove( name );
_data.ClaimedAchievements.Add( name );
_data.Coins += reward;
StateVersion++;
Save();
return true;
}
public static void DebugClearOwnedByCategory( ShopItemCategory category )
{
if ( _data == null ) Load();
var ids = ShopItems.Where( d => d.Category == category ).Select( d => d.Id ).ToHashSet();
_data.OwnedShopItems.ExceptWith( ids );
if ( category == ShopItemCategory.Gem )
{
_data.GemUpgradeLevels.Clear();
_data.EquippedGems.RemoveAll( id => ids.Contains( id ) );
}
StateVersion++;
Save();
}
public static void DebugResetQuestLevels()
{
if ( _data == null ) Load();
_data.QuestLevels.Clear();
_data.UnlockedAchievements.Clear();
_data.ClaimedAchievements.Clear();
StateVersion++;
Save();
}
public static void DebugResetStats()
{
if ( _data == null ) Load();
_data.Stats.Clear();
StateVersion++;
Save();
}
public static IEnumerable<QuestDef> GetQuestsForTab( int tab )
=> Quests.Where( d => d.Tab == tab );
private static QuestDef GetDef( QuestId id )
=> Quests.First( d => d.Id == id );
// ── Coins ──────────────────────────────────────────────────────────────
public static int GetCoins()
{
if ( _data == null ) Load();
return _data.Coins;
}
public static void AddCoins( int amount )
{
if ( _data == null ) Load();
_data.Coins += amount;
_isDirty = true;
StateVersion++;
}
// ── Shop ────────────────────────────────────────────────────────────────
public static string GetItemRichTextToken( string id ) => $"[item:{id}]";
private static ShopItemDef[] _shopItems;
public static ShopItemDef[] ShopItems => _shopItems ??= BuildShopItems();
// BuildGunItems(), BuildCharmItems(), BuildGemItems() are defined in GunDefs.cs, CharmDefs.cs, GemDefs.cs
private static ShopItemDef[] BuildShopItems()
{
var items = BuildGunItems().Concat( BuildCharmItems() ).Concat( BuildGemItems() ).ToArray();
var duplicates = items.GroupBy( d => d.Id ).Where( g => g.Count() > 1 ).Select( g => g.Key ).ToList();
foreach ( var id in duplicates )
Log.Error( $"ShopItemDef duplicate ID detected: \"{id}\"" );
return items;
}
[ConCmd( "reload_shop_item_defs" )]
public static void ReloadShopItemDefs()
{
_shopItems = null;
StateVersion++;
Log.Info( $"Shop item defs reloaded ({ShopItems.Length} items)" );
}
public static bool IsItemOwned( string id )
{
if ( _data == null ) Load();
return _data.OwnedShopItems.Contains( id );
}
/// <summary>
/// Attempts to purchase a shop item. Deducts coins and marks as owned.
/// Returns false if already owned or can't afford it.
/// </summary>
public static bool BuyItem( string id )
{
if ( _data == null ) Load();
if ( _data.OwnedShopItems.Contains( id ) ) return false;
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null ) return false;
if ( _data.Coins < def.Price ) return false;
_data.Coins -= def.Price;
_data.OwnedShopItems.Add( id );
StateVersion++;
Save();
return true;
}
/// <summary>Returns the number of shop items the player owns in the given category.</summary>
public static int GetOwnedCountByCategory( ShopItemCategory category )
{
if ( _data == null ) Load();
return ShopItems.Count( d => d.Category == category && _data.OwnedShopItems.Contains( d.Id ) );
}
/// <summary>
/// Returns true when the player has bought enough items in the item's category
/// to satisfy its RequiredPurchases gate. Always true when RequiredPurchases == 0.
/// </summary>
public static bool IsItemUnlocked( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.RequiredPurchases == 0 ) return true;
return GetOwnedCountByCategory( def.Category ) >= def.RequiredPurchases;
}
/// <summary>
/// Returns how many more purchases in the same category are needed before this
/// item unlocks. Returns 0 if already unlocked.
/// </summary>
public static int GetPurchasesNeeded( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.RequiredPurchases == 0 ) return 0;
return Math.Max( 0, def.RequiredPurchases - GetOwnedCountByCategory( def.Category ) );
}
// ── Gems ────────────────────────────────────────────────────────────────
/// <summary>Returns 1 = base level (Lv1), 2+ = upgraded. Returns 0 if not owned.</summary>
public static int GetGemDisplayLevel( string id )
{
int raw = GetGemLevel( id );
return raw < 0 ? 0 : raw + 1;
}
/// <summary>Returns 0 = base level, 1+ = upgraded. Returns -1 if not owned.</summary>
public static int GetGemLevel( string id )
{
if ( _data == null ) Load();
if ( !_data.OwnedShopItems.Contains( id ) ) return -1;
return _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0;
}
/// <summary>Returns the upgrade cost at the gem's current level, or null if not upgradeable or already maxed.</summary>
public static int? GetGemUpgradeCost( string id )
{
var def = ShopItems.FirstOrDefault( d => d.Id == id );
if ( def.Id == null || def.UpgradePrices == null || def.UpgradePrices.Length == 0 ) return null;
int level = GetGemLevel( id );
if ( level < 0 || level >= def.UpgradePrices.Length ) return null;
return def.UpgradePrices[level];
}
public static bool IsGemMaxed( string id )
{
if ( !IsItemOwned( id ) ) return false;
return GetGemUpgradeCost( id ) == null;
}
/// <summary>Upgrades a gem by one level. Deducts coins. Returns false if not upgradeable or can't afford.</summary>
public static bool UpgradeGem( string id )
{
if ( _data == null ) Load();
int? cost = GetGemUpgradeCost( id );
if ( cost == null || _data.Coins < cost.Value ) return false;
_data.Coins -= cost.Value;
_data.GemUpgradeLevels[id] = ( _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0 ) + 1;
StateVersion++;
Save();
return true;
}
public static List<string> GetEquippedGems()
{
if ( _data == null ) Load();
return _data.EquippedGems ?? ( _data.EquippedGems = new List<string>() );
}
public static List<string> GetEffectiveEquippedGems()
{
return Manager.HideProgressionSystem ? [] : GetEquippedGems();
}
public static int GetSelectedGunSocketCount()
{
var gunId = GetSelectedGunId();
if ( gunId == DefaultGun.Id ) return DefaultGun.GemSocketCount > 0 ? DefaultGun.GemSocketCount : 3;
var count = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun ).GemSocketCount;
return count > 0 ? count : 3;
}
public static bool EquipGem( string id, int maxCount = 3 )
{
if ( _data == null ) Load();
if ( !IsItemOwned( id ) ) return false;
var equipped = GetEquippedGems();
if ( equipped.Contains( id ) ) return false;
if ( equipped.Count >= maxCount ) return false;
equipped.Add( id );
StateVersion++;
_isDirty = true;
return true;
}
public static bool UnequipGem( string id )
{
if ( _data == null ) Load();
var equipped = GetEquippedGems();
if ( !equipped.Remove( id ) ) return false;
StateVersion++;
_isDirty = true;
return true;
}
// ── Loadout ─────────────────────────────────────────────────────────────
public static string GetSelectedGunId()
{
if ( _data == null ) Load();
return string.IsNullOrEmpty( _data.SelectedGunId ) ? "gun_default" : _data.SelectedGunId;
}
public static string GetEffectiveSelectedGunId()
{
return Manager.HideProgressionSystem ? DefaultGun.Id : GetSelectedGunId();
}
public static void SetSelectedGunId( string id )
{
if ( _data == null ) Load();
_data.SelectedGunId = id;
StateVersion++;
_isDirty = true;
}
public static string GetSelectedCharmId()
{
var ids = GetSelectedCharmIds();
return ids.Count > 0 ? ids[0] : "";
}
public static void SetSelectedCharmId( string id )
{
SetSelectedCharmIds( string.IsNullOrEmpty( id ) ? [] : [id] );
}
public static List<string> GetSelectedCharmIds()
{
if ( _data == null ) Load();
return _data.SelectedCharmIds ??= [];
}
public static List<string> GetEffectiveSelectedCharmIds()
{
return Manager.HideProgressionSystem ? [] : GetSelectedCharmIds();
}
public static void SetSelectedCharmIds( List<string> ids )
{
if ( _data == null ) Load();
_data.SelectedCharmIds = ids ?? [];
_data.SelectedCharmId = _data.SelectedCharmIds.Count > 0 ? _data.SelectedCharmIds[0] : "";
StateVersion++;
_isDirty = true;
}
public static int GetSelectedGunCharmSlotCount()
{
var gunId = GetSelectedGunId();
ShopItemDef def;
if ( gunId == DefaultGun.Id )
def = DefaultGun;
else
def = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun );
return def.CharmSlotCount > 0 ? def.CharmSlotCount : 1;
}
public static bool IsLockedPerkUnlocked( TypeDescription perkType )
{
if ( _data == null ) Load();
return _data.UnlockedLockedPerks.Contains( perkType.FullName );
}
public static void UnlockLockedPerk( TypeDescription perkType )
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Add( perkType.FullName ) )
{
StateVersion++;
Save();
}
}
public static void RelockLockedPerk( TypeDescription perkType )
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Remove( perkType.FullName ) )
{
StateVersion++;
Save();
}
}
public static void RelockAllLockedPerks()
{
if ( _data == null ) Load();
if ( _data.UnlockedLockedPerks.Count == 0 ) return;
_data.UnlockedLockedPerks.Clear();
StateVersion++;
Save();
}
/// <summary>
/// Returns all items in a category that the player owns.
/// </summary>
public static List<ShopItemDef> GetOwnedItemsByCategory( ShopItemCategory category )
{
return ShopItems
.Where( d => d.Category == category && IsItemOwned( d.Id ) )
.ToList();
}
/// <summary>
/// Looks up the prefab path for a shop item by its id. Returns null if not found.
/// </summary>
public static string GetPrefabPath( string id )
{
if ( string.IsNullOrEmpty( id ) ) return null;
var def = ShopItems.FirstOrDefault( d => d.Id == id );
return def.Id != null ? def.PrefabPath : null;
}
}
using Sandbox;
public sealed class SpitterBlinkEffect : Component
{
[Property] public SkinnedModelRenderer ModelRenderer { get; set; }
public float AnimTime { get; set; }
private Color _startingColor;
private TimeSince _timeSinceSpawn;
private float _lifetime;
protected override void OnStart()
{
base.OnStart();
_startingColor = ModelRenderer.Tint;
_timeSinceSpawn = 0f;
_lifetime = 1f;
}
protected override void OnUpdate()
{
ModelRenderer.SceneModel.CurrentSequence.Time = AnimTime;
ModelRenderer.Tint = ModelRenderer.Tint.WithAlpha( Utils.Map( _timeSinceSpawn, 0f, _lifetime, _startingColor.a, 0f, EasingType.Linear ) );
if( _timeSinceSpawn > _lifetime )
{
GameObject.Destroy();
}
}
}
using System;
using Sandbox;
public class AcidPuddle : Thing
{
[Property] public Decal Decal { get; set; }
public float Damage { get; set; }
[Sync] public float Lifetime { get; set; }
public Enemy EnemySource { get; set; }
public EnemyType EnemyType { get; set; }
public Player PlayerSource { get; set; }
private Dictionary<Thing, float> _damageTimes;
private const float DAMAGE_INTERVAL_PLAYER = 0.3f;
private const float DAMAGE_INTERVAL_ENEMY = 0.5f;
private float _colorTimeOffset;
private Vector3 _modelScaleBase;
public override bool UseSpawnScale => false;
[Property, Hide] public Color ColorA { get; set; }
[Property, Hide] public Color ColorB { get; set; }
protected override void OnStart()
{
base.OnStart();
_colorTimeOffset = Game.Random.Float( 0f, 99f );
_modelScaleBase = new Vector3( 0.2f, 0.15f, 0.2f );
Decal.ColorTint = ColorA.WithAlpha( 0f );
if ( IsProxy )
return;
//WorldPosition = WorldPosition.WithZ( 0f );
_damageTimes = new();
}
protected override void OnUpdate()
{
base.OnUpdate();
var color = Color.Lerp( ColorA, ColorB, 0.5f + Utils.FastSin( Time.Now * 32f ) * 0.5f );
Decal.ColorTint = color.WithAlpha( Utils.Map( TimeSinceSpawn, Lifetime - 0.5f, Lifetime, color.a, 0f ) );
var decalSize = Radius * Utils.Map( WorldScale.x, 0.5f, 2f, 0.2f, 0.04f ) * 1.45f * Utils.Map( TimeSinceSpawn, 0f, 0.25f, 0f, 1f, EasingType.SineOut );
Decal.Size = new Vector2( decalSize, decalSize );
//var alpha = Utils.Map( TimeSinceSpawn, 0f, 0.5f, 0f, 1f, EasingType.QuadOut ) * Utils.Map( TimeSinceSpawn, Lifetime - 0.5f, Lifetime, 1f, 0f, EasingType.QuadOut );
//ModelRenderer.Tint = Color.Lerp( ColorA, ColorB, 0.5f + Utils.FastSin( _colorTimeOffset + TimeSinceSpawn * 32f ) * 0.5f ).WithAlpha( alpha );
if ( Manager.Instance.IsGameOver )
return;
//var scaleModifier = Utils.Map( TimeSinceSpawn, 0f, 0.5f, 0f, 1f, EasingType.QuadOut ) * Utils.Map( TimeSinceSpawn, Lifetime - 1f, Lifetime, 1f, 1.3f, EasingType.Linear );
//ModelRenderer.LocalScale = new Vector3(_modelScaleBase.x, _modelScaleBase.y, _modelScaleBase.z) * scaleModifier;
if ( IsProxy )
return;
var playerInterval = DAMAGE_INTERVAL_PLAYER * Utils.Select( Manager.Instance.Difficulty, 1.25f, 1f, 1f );
for ( int i = _damageTimes.Count - 1; i >= 0; i-- )
{
var pair = _damageTimes.ElementAt( i );
var interval = pair.Key is Player player
? playerInterval
: DAMAGE_INTERVAL_ENEMY;
if ( Time.Now > pair.Value + interval )
_damageTimes.Remove( pair.Key );
}
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"Radius: {Radius}\nTimeSinceSpawn: {TimeSinceSpawn}/{Lifetime}", new global::Transform( WorldPosition ) );
//Gizmo.Draw.Text( $"{WorldScale.x}", new global::Transform( WorldPosition ) );
//Gizmo.Draw.Color = Color.Blue;
//Gizmo.Draw.LineSphere( WorldPosition.WithZ( 2f ), Radius);
if ( TimeSinceSpawn > Lifetime )
{
GameObject.Destroy();
}
}
public override void Colliding( Thing other, float percent, float dt )
{
base.Colliding( other, percent, dt );
if ( TimeSinceSpawn < 0.15f || TimeSinceSpawn > Lifetime - 0.33f || _damageTimes.ContainsKey( other ) )
return;
if ( other is Player player )
{
if ( !player.IsDead && !player.IsInTheAir )
{
var acidHealPercent = player.GetSyncStat(PlayerStat.AcidHealHpPercent);
if ( acidHealPercent > 0f && (player.Health / player.GetSyncStat( PlayerStat.MaxHp )) < acidHealPercent )
{
player.HealRpc( Damage, playSfx: true );
player.HighlightPerkRpc( PerkManager.TypeToIdentity( TypeLibrary.GetType( typeof( PerkAcidHeal ) ) ) );
}
else
{
Vector2 dir = (Position2D - player.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (player.Position2D - Position2D).Normal
: Utils.GetRandomVector();
player.DamageRpc( Damage, DamageType.Acid, player.Position2D, dir, upwardAmount: Game.Random.Float( 0f, 0.2f ), force: 0f, ragdollForce: 0f, EnemySource, enemyType: EnemyType );
}
_damageTimes.Add( other, Time.Now );
}
}
else if ( other is Enemy enemy )
{
if ( !enemy.IsDying && !enemy.IsInTheAir )
{
Vector2 dir = (Position2D - enemy.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (enemy.Position2D - Position2D).Normal
: Utils.GetRandomVector();
var hitPos = enemy.Position2D - dir * enemy.Radius;
var shouldFlinch = Damage < enemy.MaxHealth * 0.05f ? false : true;
enemy.DamageRpc( Damage, PlayerSource, DamageType.Acid, new Vector3( hitPos.x, hitPos.y, 10f ), force: Vector2.Zero, isCrit: false, shouldFlinch );
_damageTimes.Add( other, Time.Now );
}
}
}
}
using System;
using Sandbox;
public enum BulletType { Normal, Armor, FrozenShard, Punch }
public enum BulletStat
{
Damage, Force, AddTempWeight, Lifetime, NumPiercing, NumBouncing, CriticalChance, CriticalMultiplier,
ApplyFire, ApplyFreeze, ApplyPoison,
BulletSpread, BulletInaccuracy, BulletLifetime,
GrowDamageAmount, DistanceDamageAmount, HealTeammateAmount, MoveRandomly, HomingRadius, IsReturning, SplashDamagePercent, BounceDamageIncrease, BounceResetLifetime,
CanHitShooter, FriendlyFire, BounceTarget, ArcHeight, OverflowPercent, IsArmorBullet, StartFromGround, NumGroundHops,
NumPunchExtraHits, IsForcePunch, AimAtCursorProgress, ForceRandomDir, Explosive, LifestealPercent,
}
public class Bullet : Thing
{
[Property] public ModelRenderer Model { get; set; }
public Dictionary<BulletStat, float> Stats = new();
public Player Shooter { get; set; }
public TimeSince TimeSinceInitialSpawn { get; protected set; } // not restarted when bouncing etc
private bool _hasFinishedFadingIn;
public const float FADE_IN_TIME = 0.1f;
private TimeSince _timeSinceBounce = 99f;
public float BaseZPos { get; set; }
public List<Thing> HitThings { get; private set; } = new();
public BulletHomingDetector HomingDetector { get; set; }
public bool HasHomed { get; set; }
private bool _hasReturned;
private TimeSince _timeSinceSteer;
private bool _hasDoneFirstUpdate;
public int StartingNumPierce { get; set; }
public int StartingNumBounce { get; set; }
private bool _sizeDirty;
private TimeSince _timeSinceUpdateDamage;
private const float UPDATE_DAMAGE_INTERVAL = 0.2f;
private bool _shouldMoveRandomly;
private TimeSince _timeSinceRandomMove;
private float _randomMoveDelay;
private const float SPLASH_RADIUS = 65f;
[Property, Hide] public bool ShowPierce { get; set; }
[Property, Hide] public bool ShowSplash { get; set; }
[Property, Hide] public bool ShowBounce { get; set; }
[Property, Hide] public BulletType BulletType { get; set; }
[Property, Hide] public Color Color { get; set; } = Color.White;
public bool IsLensBuffed { get; set; }
private bool _isRemoved;
private bool _shouldAimAtCursor;
public Vector2 LastPos2D { get; private set; }
protected override void OnAwake()
{
base.OnAwake();
Model.Tint = Color.White.WithAlpha( 0f );
if ( IsProxy )
return;
}
protected override void OnStart()
{
base.OnStart();
Radius = 5f;
Transform.ClearInterpolation();
TimeSinceInitialSpawn = 0f;
if ( IsProxy )
return;
CollideWithTags.Add( "enemy" );
CollideWithTags.Add( "orbiter_shield_enemy" );
CollideWithTags.Add( "obstacle" );
_timeSinceUpdateDamage = UPDATE_DAMAGE_INTERVAL * 2f;
_sizeDirty = true;
_timeSinceRandomMove = 0f;
_randomMoveDelay = Game.Random.Float( 0.025f, 0.15f );
LastPos2D = Position2D;
}
public void Init()
{
DetermineSize();
StartingNumPierce = (int)Stats[BulletStat.NumPiercing];
StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;
_shouldMoveRandomly = Stats[BulletStat.MoveRandomly] > 0f;
_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
ShowPierce = Stats[BulletStat.NumPiercing] > 0f;
ShowSplash = Stats[BulletStat.SplashDamagePercent] > 0f || Stats[BulletStat.Explosive] > 0f;
ShowBounce = Stats[BulletStat.NumBouncing] > 0f;
Vector3 colorVec = new Vector3( 1f, 1f, 1f );
int numColors = 1;
//if ( Stats[BulletStat.HomingRadius] > 0f )
//colorVec += new Vector3( 0.6f, 0.6f, 0f );
if ( Stats[BulletStat.ApplyFire] > 0f )
{
colorVec += new Vector3( 10f, 0f, 0f );
numColors++;
}
if ( Stats[BulletStat.ApplyFreeze] > 0f )
{
colorVec += new Vector3( 2f, 2f, 9f );
numColors++;
}
if ( Stats[BulletStat.ApplyPoison] > 0f )
{
colorVec += new Vector3( 0f, 10f, 0f );
numColors++;
}
if ( Stats[BulletStat.HealTeammateAmount] > 0f )
{
colorVec += new Vector3( 0f, 10f, 0f );
numColors++;
}
if ( numColors > 1 )
{
colorVec = (colorVec / numColors).Normal;
Color = new Color( colorVec.x, colorVec.y, colorVec.z );
}
if ( BulletType == BulletType.Punch )
{
//Color = Color.WithAlpha( 0.5f );
Model.Enabled = false;
}
}
void FirstUpdate()
{
if ( BulletType == BulletType.Normal )
RefreshBodyGroups();
//if ( BulletType != BulletType.Punch )
// Model.Tint = Color;
if ( IsProxy )
return;
if ( Stats[BulletStat.HomingRadius] > 0f )
{
var detectorGo = GameObject.Clone( "prefabs/bullet_homing_detector.prefab", new CloneConfig { Parent = GameObject, StartEnabled = true } );
HomingDetector = detectorGo.GetComponent<BulletHomingDetector>();
HomingDetector.Radius = Stats[BulletStat.HomingRadius];
HomingDetector.SphereCollider.Radius = Stats[BulletStat.HomingRadius];
HomingDetector.Bullet = this;
}
if ( Stats[BulletStat.CanHitShooter] > 0f || Stats[BulletStat.FriendlyFire] > 0f || Stats[BulletStat.HealTeammateAmount] > 0f )
CollideWithTags.Add( "player" );
if ( BulletType == BulletType.Armor )
{
Model.Model = Manager.Instance.ArmorBulletModel;
SetDirection( Velocity.Normal );
_sizeDirty = true;
// todo: needs to change color or appearance for pierce, fire, etc?
}
else if ( BulletType == BulletType.FrozenShard )
{
Model.Model = Manager.Instance.FrozenShardBulletModel;
_sizeDirty = true;
// todo: needs to change color or appearance for pierce, fire, etc?
}
_hasDoneFirstUpdate = true;
}
void RefreshBodyGroups()
{
if ( ShowPierce )
Model.SetBodyGroup( 2, 1 );
else
Model.SetBodyGroup( 2, 0 );
if ( ShowSplash )
Model.SetBodyGroup( 1, 1 );
else
Model.SetBodyGroup( 1, 0 );
if ( ShowBounce )
Model.SetBodyGroup( 0, 1 );
else
Model.SetBodyGroup( 0, 0 );
}
void DetermineSize()
{
var damage = Stats[BulletStat.Damage];
var scale = damage < 30f
? Utils.Map( damage, 0f, 30f, 0.4f, 2.25f, EasingType.QuadOut )
: Utils.Map( damage, 30f, 150f, 2.25f, 3.5f, EasingType.QuadIn );
Radius = 5f * scale;
float scaleModifier = 1f;
if ( BulletType == BulletType.Armor ) // todo: armor sphereCollider is small, since the model is too large and is scaled down
scaleModifier = Utils.Map( damage, 0f, 5f, 0.2f, 0.13f );
else if ( BulletType == BulletType.FrozenShard )
scaleModifier = 0.92f;
//else if ( BulletType == BulletType.Punch )
// scaleModifier = 3f;
WorldScale = new Vector3( scale * scaleModifier );
//_pfakeshadow.Set("Size", 9f * Scale);
_sizeDirty = false;
_timeSinceUpdateDamage = 0f;
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( !_hasDoneFirstUpdate )
FirstUpdate();
if( BulletType != BulletType.Punch )
{
if ( !_hasFinishedFadingIn )
{
if ( TimeSinceInitialSpawn < FADE_IN_TIME )
{
Model.Tint = Color.WithAlpha( Utils.Map( TimeSinceInitialSpawn, 0f, FADE_IN_TIME, 0f, 1f ) );
}
else
{
Model.Tint = Color;
_hasFinishedFadingIn = true;
}
}
}
if ( IsProxy )
return;
//var sphereCollider = Collider as SphereCollider;
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"{sphereCollider.Radius}", new global::Transform( WorldPosition ) );
//Gizmo.Draw.Color = Color.White;
//Gizmo.Draw.Text( $"{Stats[BulletStat.NumBouncing]}/{StartingNumBounce}", new global::Transform( WorldPosition + new Vector3( 0f, 0f, -5f ) ) );
//Gizmo.Draw.LineCircle( WorldPosition, Vector3.Up, Radius );
if ( Stats[BulletStat.DistanceDamageAmount] > 0f )
{
var dist = (Position2D - LastPos2D).Length;
Stats[BulletStat.Damage] += Stats[BulletStat.DistanceDamageAmount] * Utils.Unit2Meter * dist;
_sizeDirty = true;
}
LastPos2D = Position2D;
if ( Math.Abs( Stats[BulletStat.GrowDamageAmount] ) > 0f )
{
Stats[BulletStat.Damage] += Stats[BulletStat.GrowDamageAmount] * Time.Delta;
_sizeDirty = true;
if ( Stats[BulletStat.Damage] <= 0f )
{
Remove();
return;
}
}
if ( _sizeDirty && _timeSinceUpdateDamage > UPDATE_DAMAGE_INTERVAL )
DetermineSize();
if( Shooter.IsValid() && Shooter.Stats[PlayerStat.BulletSteering] > 0f )
HandleSteering();
if ( _shouldMoveRandomly )
HandleRandomMovement();
if ( Stats[BulletStat.IsReturning] > 0f )
HandleReturning();
if ( _shouldAimAtCursor )
HandleAimAtCursor();
var lifetime = Stats[BulletStat.Lifetime];
float zPos;
if( BulletType == BulletType.Punch )
{
zPos = BaseZPos;
}
else if( Stats[BulletStat.ArcHeight] > 0f )
{
var startZPos = Stats[BulletStat.StartFromGround] > 0f ? 0f : BaseZPos;
zPos = TimeSinceSpawn < lifetime * 0.5f
? Utils.Map( TimeSinceSpawn, 0f, lifetime * 0.5f, startZPos, Stats[BulletStat.ArcHeight], EasingType.QuadOut )
: Utils.Map( TimeSinceSpawn, lifetime * 0.5f, lifetime, Stats[BulletStat.ArcHeight], 0f, EasingType.QuadIn );
WorldRotation = Rotation.From( Utils.Map(TimeSinceSpawn, 0f, lifetime, -65f, 65f ), WorldRotation.Yaw(), 0f );
}
else if( Stats[BulletStat.StartFromGround] > 0f )
{
zPos = Utils.MapReturn( TimeSinceSpawn, 0f, lifetime, 0f, BaseZPos, EasingType.QuadOut );
WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, -45f, 45f ), WorldRotation.Yaw(), 0f );
}
else
{
zPos = Utils.Map( TimeSinceSpawn, 0f, lifetime, BaseZPos, 0f, EasingType.QuartIn );
if ( BulletType != BulletType.Armor )
WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, 0f, 25f, EasingType.QuartIn ), WorldRotation.Yaw(), 0f );
}
if ( Manager.Instance.IsWindActive )
Velocity += Manager.Instance.GlobalWindForce * 2f * Time.Delta; // todo: less affected by wind if larger
WorldPosition = (WorldPosition + (Vector3)Velocity * Time.Delta).WithZ( zPos );
if ( TimeSinceSpawn > lifetime )
{
HitGround();
return;
}
}
void HandleSteering()
{
if ( _timeSinceSteer < 0.075f )
return;
if( Stats[BulletStat.IsReturning] > 0f )
{
var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
if ( lifetimeProgress > 0.5f && lifetimeProgress < 0.75f )
return;
}
if ( _timeSinceBounce < 0.15f )
return;
//if ( _shouldMoveRandomly && _timeSinceRandomMove < 0.05f )
// return;
var targetDir = Shooter.FacingDir;
//var targetDir = (Manager.Instance.MouseWorldPos - Position2D).Normal;
SetDirection( Utils.DynamicEaseTo( Velocity.Normal, targetDir, Utils.Map( TimeSinceSpawn, 0f, 0.1f, 0f, (_shouldMoveRandomly || HasHomed) ? 0.1f : 0.5f, EasingType.QuadIn ), _timeSinceSteer ) );
//SetDirection( targetDir );
_timeSinceSteer = 0f;
}
void HandleRandomMovement()
{
if( _timeSinceRandomMove > _randomMoveDelay )
{
var newDir = Utils.RotateVector( Velocity, Game.Random.Float( 10f, 45f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f) ).Normal;
SetDirection( newDir );
_timeSinceRandomMove = 0f;
_randomMoveDelay = Game.Random.Float( 0.1f, 0.3f );
}
}
void HandleReturning()
{
var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
if ( !_hasReturned && lifetimeProgress > 0.5f )
{
if ( Shooter.IsValid() )
{
Vector2 dir = (Shooter.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (Shooter.Position2D - Position2D).Normal
: Utils.GetRandomVector();
SetDirection( dir );
}
else
{
SetDirection( -Velocity.Normal );
}
_hasReturned = true;
HitThings.Clear();
if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
HomingDetector.Refresh();
_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
}
}
void HandleAimAtCursor()
{
var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
if ( lifetimeProgress > Stats[BulletStat.AimAtCursorProgress] ) // todo: if you get LazyBullets perk, it feels like it takes too long to activate. activate on whichever comes first, lifetime progress or elapsed time threshold?
{
var aimWorldPos = Input.UsingController && Shooter.IsValid() ? Shooter.Position2D + Shooter.AimDir * 1000f : Manager.Instance.MouseWorldPos;
var targetDir = (aimWorldPos - Position2D).Normal;
SetDirection( targetDir );
_shouldAimAtCursor = false;
}
}
public override void Colliding( Thing other, float percent, float dt )
{
base.Colliding( other, percent, dt );
if ( _isRemoved || !Shooter.IsValid() || HitThings.Contains( other ) )
return;
bool didHit = false;
bool hitObstacle = false;
float dmg = Stats[BulletStat.Damage];
float dmgUsed = 0f;
if ( other is Enemy enemy )
{
//if ( enemy.IsDying || (enemy.IsSpawning && enemy.SpawnProgress < 0.7f) )
if ( enemy.IsSpawning && enemy.SpawnProgress < 0.7f )
return;
if ( Stats[BulletStat.ArcHeight] > 0f && TimeSinceSpawn < Stats[BulletStat.Lifetime] * 0.25f )
return;
didHit = true;
bool isCrit = CheckCrit( ref dmg );
dmgUsed = enemy.IsInvincible ? dmg : Math.Min( enemy.Health, dmg );
bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;
if( !enemy.IsInvincible )
{
if ( Stats[BulletStat.ApplyFire] > 0f )
enemy.Ignite( playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );
if ( Stats[BulletStat.ApplyFreeze] > 0f )
enemy.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );
if ( Stats[BulletStat.ApplyPoison] > 0f && !enemy.IsInanimate )
{
enemy.Poison(
Shooter,
enemySource: null,
enemyType: EnemyType.None,
Shooter.Stats[PlayerStat.PoisonDamage],
Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
Shooter.Stats[PlayerStat.RadiusMultiplier],
Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
);
}
}
Vector2 dir = Velocity.Normal;
float damageDealt = overflow ? dmgUsed : dmg;
// todo: overflow damage doesn't include additional dmg to enemy...
var shouldFlinch = damageDealt < enemy.MaxHealth * 0.05f ? false : true;
var forceFactor = Stats[BulletStat.Damage] < 5f
? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );
var forceDir = Stats[BulletStat.ForceRandomDir] > 0f ? (Game.Random.Float( 0f, 1f ) < 0.4f ? Utils.GetRandomVectorInCone( -dir, 180f ) : Utils.GetRandomVector()) : dir;
var force = forceDir * Stats[BulletStat.Force] * forceFactor;
if ( Manager.Instance.LaunchEnemies && !enemy.IsInTheAir && !enemy.IsSpawning )
{
var targetPos = enemy.Position2D + Utils.GetRandomVector() * Game.Random.Float( 80f, 350f );
enemy.JumpRpc( Manager.Instance.ClampPosToBounds( targetPos ), height: Game.Random.Float( 80f, 120f ), lifetime: Game.Random.Float( 1.1f, 1.5f ) );
}
bool shouldDmgEnemy = true;
if( Stats[BulletStat.Explosive] > 0f )
{
int numPierce = (int)Stats[BulletStat.NumPiercing];
int numBounce = (int)Stats[BulletStat.NumBouncing];
if ( numPierce == 0 && numBounce == 0 )
shouldDmgEnemy = false; // it should only apply explosion damage, not direct hit damage
}
if( shouldDmgEnemy )
{
var damageType = BulletType == BulletType.Punch ? DamageType.Punch : DamageType.Bullet;
enemy.DamageRpc( damageDealt, Shooter, damageType, WorldPosition, force, isCrit, shouldFlinch );
if( Stats[BulletStat.LifestealPercent] > 0f && Shooter.IsValid() && !Shooter.IsDead )
{
float healAmount = damageDealt * (Stats[BulletStat.LifestealPercent]);
Shooter.Heal( healAmount );
}
}
if ( Stats[BulletStat.IsForcePunch] > 0f )
enemy.Punched( Shooter );
}
else if ( other is Player player )
{
if ( Stats[BulletStat.HealTeammateAmount] > 0f && player != Shooter && player.Health < player.GetSyncStat(PlayerStat.MaxHp) )
{
didHit = true;
// todo: sfx
player.HealRpc( Stats[BulletStat.HealTeammateAmount], otherPlayerHealer: Shooter );
}
else if ( TimeSinceSpawn > 0.1f && ( ( Stats[BulletStat.CanHitShooter] > 0f && player == Shooter && !player.IsProxy) || (player != Shooter && player.GetSyncStat(PlayerStat.TakeFriendlyDmg) > 0f) ) )
{
if ( Stats[BulletStat.ApplyFire] > 0f )
player.Ignite( Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );
if ( Stats[BulletStat.ApplyFreeze] > 0f )
player.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );
if ( Stats[BulletStat.ApplyPoison] > 0f )
{
player.Poison(
Shooter,
enemySource: null,
enemyType: EnemyType.None,
Shooter.Stats[PlayerStat.PoisonDamage],
Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
Shooter.Stats[PlayerStat.RadiusMultiplier],
Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
);
}
Vector2 dir = (player.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (player.Position2D - Position2D).Normal
: Utils.GetRandomVector();
var forceFactor = Stats[BulletStat.Damage] < 5f
? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );
//if ( Stats[BulletStat.Force] > 0f )
// player.AddVelocity( dir * Stats[BulletStat.Force] * (1f / 2f) ); //(1f / player.Weight);
var force = Stats[BulletStat.Force] * (1f / player.Weight) * forceFactor;
didHit = true;
dmgUsed = Math.Min( player.Health, dmg );
bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;
bool isCrit = Game.Random.Float( 0f, 1f ) < Stats[BulletStat.CriticalChance];
float damage = (overflow ? dmgUsed : dmg) * (isCrit ? Stats[BulletStat.CriticalMultiplier] : 1f);
var hitPos = player.Position2D - dir * player.Radius;
var isSelfInflicted = Stats[BulletStat.CanHitShooter] > 0f && player == Shooter;
var damageFlags = PlayerDamageFlags.None;
if ( isSelfInflicted )
damageFlags |= PlayerDamageFlags.SelfInflicted;
player.DamageRpc( damage, DamageType.Bullet, hitPos, dir, upwardAmount: 0f, force, ragdollForce: force * 0.1f, enemySource: null, enemyType: EnemyType.None, damageFlags: damageFlags );
}
}
else if ( other is OrbiterShieldEnemy orbiterShieldEnemy )
{
if ( orbiterShieldEnemy.IsActive )
{
orbiterShieldEnemy.Block( Position2D );
var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
Remove();
}
}
else if( other is Obstacle obstacle )
{
didHit = true;
var normal = (Position2D - obstacle.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (Position2D - obstacle.Position2D).Normal
: Utils.GetRandomVector();
Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, normal, Color.White );
obstacle.PlayHitSfxRpc( WorldPosition );
hitObstacle = true;
}
if ( didHit )
{
int numPierce = (int)Stats[BulletStat.NumPiercing];
int numBounce = (int)Stats[BulletStat.NumBouncing];
if ( numPierce > 0 && !hitObstacle )
{
Pierce( other );
}
else
{
if ( numBounce > 0 )
{
Bounce( other );
}
else
{
if ( Stats[BulletStat.SplashDamagePercent] > 0f )
Splash( except: other as Enemy );
if ( Stats[BulletStat.Explosive] > 0f )
Explode();
bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;
if ( overflow )
{
Stats[BulletStat.Damage] = (dmg - dmgUsed) * Stats[BulletStat.OverflowPercent];
_sizeDirty = true;
HitThings.Add( other );
}
else
{
if ( BulletType == BulletType.Punch && (int)Stats[BulletStat.NumPunchExtraHits] > 0f )
{
Punch( other );
return;
}
//Manager.Instance.SpawnBulletImpactParticles( WorldPosition, -Velocity, Color.White );
Remove();
}
}
}
}
}
bool CheckCrit( ref float dmg )
{
bool playCritSfx = false;
float sfxPitch = 0.85f;
bool isCrit = false;
var critChance = Stats[BulletStat.CriticalChance];
while ( true )
{
if ( Game.Random.Float( 0f, 1f ) < critChance )
{
if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritStreak] > 0f && Shooter.Stats[PlayerStat.CritStreakDmgAmount] > 0f )
dmg *= (1f + Shooter.Stats[PlayerStat.CritStreak] * Shooter.Stats[PlayerStat.CritStreakDmgAmount]);
dmg *= Math.Max( Stats[BulletStat.CriticalMultiplier], 0f );
isCrit = true;
playCritSfx = true;
sfxPitch *= 1.2f;
if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritMultipleChance] > 0f )
{
critChance *= Shooter.Stats[PlayerStat.CritMultipleChance];
continue;
}
}
break;
}
if( playCritSfx )
Manager.Instance.PlaySfxNearbyRpc( "crit2", Position2D, pitch: sfxPitch * Game.Random.Float(1.2f, 1.3f), volume: 0.75f, maxDist: 300f );
return isCrit;
}
void Punch( Thing other )
{
Stats[BulletStat.NumPunchExtraHits] -= 1f;
HitThings.Add( other );
}
void Pierce( Thing other )
{
Stats[BulletStat.NumPiercing] -= 1f;
HitThings.Add( other );
if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
HomingDetector.Refresh();
Shooter?.BulletPierce( this, other );
if( (int)Stats[BulletStat.NumPiercing] <= 0 )
{
ShowPierce = false;
if ( BulletType == BulletType.Normal )
RefreshBodyGroups();
}
if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
{
_shouldAimAtCursor = true;
Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
}
}
void Bounce( Thing other )
{
Vector2 dir = (Position2D - other.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (Position2D - other.Position2D).Normal
: Utils.GetRandomVector();
if ( Stats[BulletStat.BounceTarget] > 0f )
dir = GetBounceTargetDir( dir, other );
SetDirection( dir );
ApplyBounceEffects();
HitThings.Add( other );
Shooter?.BulletBounce( this, other );
}
void ApplyBounceEffects( bool outOfBounds = false )
{
Stats[BulletStat.NumBouncing] -= 1f;
ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;
HitThings.Clear();
if ( Stats[BulletStat.BounceDamageIncrease] > 0f )
{
Stats[BulletStat.Damage] *= (1f + Stats[BulletStat.BounceDamageIncrease]);
_sizeDirty = true;
}
_timeSinceBounce = 0f;
if ( Stats[BulletStat.BounceResetLifetime] > 0f )
TimeSinceSpawn = 0f;
if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
HomingDetector.Refresh();
_hasReturned = false;
if ( (int)Stats[BulletStat.NumBouncing] <= 0 )
{
ShowBounce = false;
if ( BulletType == BulletType.Normal )
RefreshBodyGroups();
}
if( Stats[BulletStat.ArcHeight] > 0f && !outOfBounds )
{
TimeSinceSpawn = 0f;
BaseZPos = WorldPosition.z;
}
if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
{
_shouldAimAtCursor = true;
Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
}
}
public void SetupArc( float arcHeight, float extraBounces )
{
Stats[BulletStat.ArcHeight] = arcHeight;
Stats[BulletStat.NumBouncing] += extraBounces;
if ( Stats[BulletStat.NumBouncing] > 0f )
{
ShowBounce = true;
ShouldCheckBounds = true;
}
StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
}
Vector2 GetBounceTargetDir( Vector2 dir, Thing other )
{
var closestUnit = Manager.Instance.GetClosestEnemy( Position2D, onlyCountsAsKill: false, except: other );
if ( closestUnit.IsValid() )
{
dir = (closestUnit.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
? (closestUnit.Position2D - Position2D).Normal
: Utils.GetRandomVector();
}
return dir;
}
// bullet doesn't ShouldCheckBounds unless it has bounces
protected override void OnOutOfBounds( Direction direction )
{
base.OnOutOfBounds( direction );
Vector2 dir = Velocity.Normal;
if ( Stats[BulletStat.BounceTarget] > 0f )
dir = GetBounceTargetDir( dir, other: null );
SetDirection( dir );
ApplyBounceEffects( outOfBounds: true );
var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.4f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, dir, Color.White, scaleMultiplier );
Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 250f );
// todo: when enabled, PerkBulletBounceCopy seems to trigger way too often
//Shooter?.BulletBounce( this, other: null );
}
public void Restart()
{
TimeSinceSpawn = 0f;
_timeSinceBounce = 99f;
HitThings.Clear();
Stats[BulletStat.NumPiercing] = StartingNumPierce;
Stats[BulletStat.NumBouncing] = StartingNumBounce;
ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;
if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
HomingDetector.Refresh();
_hasReturned = false;
_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
}
public void SetDirection( Vector2 dir )
{
Velocity = dir * Velocity.Length;
if( BulletType == BulletType.Armor )
{
WorldRotation = Rotation.From( -90f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
return;
}
WorldRotation = Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
}
public void ApplyHoming( Vector2 dir )
{
SetDirection( dir );
HitThings.Clear();
_timeSinceRandomMove = 0f;
}
public void Splash( Enemy except = null )
{
// todo: sfx *****
float damage = Stats[BulletStat.Damage] * Stats[BulletStat.SplashDamagePercent];
float radius = SPLASH_RADIUS * (Shooter?.Stats[PlayerStat.RadiusMultiplier] ?? 1f);
//SplashDamageEffect( radius );
Manager.Instance.SpawnRingRpc( Position2D, radius, new Color( 0.4f, 0.4f, 1f, 0.55f ), lifetime: Game.Random.Float(0.15f, 0.25f), path: "ring_spiky_2" );
//Gizmo.Draw.Color = new Color( 1f, 0f, 1f, 1f );
//Gizmo.Draw.LineSphere( WorldPosition, radius );
//Manager.Instance.SpawnRing( Position2D, radius, Game.Random.Float( 0.2f, 0.3f ), new Color( 0.3f, 0.3f, 1f, 0.6f ) );
Manager.Instance.DamageNearbyEnemies( Position2D, radius, damage, Stats[BulletStat.Force], DamageType.BulletSplash, Shooter, except );
}
[Rpc.Broadcast]
public void RemoveRpc()
{
if ( IsProxy )
return;
GameObject.Destroy();
}
public void Remove()
{
_isRemoved = true;
GameObject.Destroy();
}
[Rpc.Broadcast]
public void LensBuff( float multiplier )
{
if ( IsLensBuffed )
return;
IsLensBuffed = true;
var outline = GetComponent<HighlightOutline>( includeDisabled: true );
if ( outline.IsValid() )
outline.Enabled = true;
//RenderColor = Color.Lerp( RenderColor, Color.FromBytes( 255, 30, 90, 255 ), 0.6f );
//var glow = Components.GetOrCreate<Glow>();
//glow.Width = 0.2f;
//glow.Color = Color.FromBytes( 120, 0, 70, 255 );
Manager.Instance.PlaySfxNearby( "lens", Position2D, pitch: Game.Random.Float( 1.1f, 1.25f ), volume: 0.4f, maxDist: 250f );
if ( IsProxy )
return;
Stats[BulletStat.Damage] *= multiplier;
_sizeDirty = true;
}
void HitGround()
{
// arc bullets might be able to bounce on ground
if ( Stats[BulletStat.ArcHeight] > 0f && Stats[BulletStat.NumBouncing] > 0f && Shooter.IsValid() && Shooter.Stats[PlayerStat.ArcBulletsBounceGround] > 0f )
{
Stats[BulletStat.StartFromGround] = 1f;
if ( Stats[BulletStat.BounceTarget] > 0f )
{
var dir = GetBounceTargetDir( Velocity.Normal, other: null );
SetDirection( dir );
}
if ( BulletType != BulletType.Punch )
{
var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
}
ApplyBounceEffects();
Shooter?.BulletBounce( this, other: null );
//Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.6f, 1.7f ), volume: 0.5f, maxDist: 200f );
return;
}
if ( BulletType != BulletType.Punch )
{
var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
}
if ( Shooter.IsValid() && BulletType != BulletType.Punch )
Shooter.BulletHitGround( this );
if ( Stats[BulletStat.SplashDamagePercent] > 0f )
Splash();
if ( Stats[BulletStat.Explosive] > 0f )
Explode();
Remove();
}
void Explode()
{
var damage = Stats[BulletStat.Damage];
var radius = 75f
* Utils.Map( damage, 1f, 5f, 0.4f, 1f )
* Utils.Map( damage, 5f, 30f, 1f, 1.3f )
* (Shooter.IsValid() ? Shooter.Stats[PlayerStat.RadiusMultiplier] * Shooter.Stats[PlayerStat.ExplosionSizeMultiplier] : 1f);
Manager.Instance.CreateExplosionRpc( (Vector2)WorldPosition, radius, damage, repelRadius: radius * 1.15f, repelForce: damage * 20f, playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Color.Red );
}
}