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 );
	}
}