Search the source of every open source package.
3169 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();
}
}using Sandbox.UI;
namespace Sandbox;
public interface ICleanupEvents
{
public void OnCleanup( int removedObjects, int restoredObjects );
}
/// <summary>
/// A system that tracks the baseline scene state and allows resetting the map to its original state.
/// Removes all spawned props and restores destroyed map objects while leaving players untouched.
/// </summary>
internal sealed class CleanupSystem : GameObjectSystem<CleanupSystem>, ISceneLoadingEvents
{
/// <summary>
/// Set of GameObjects that existed in the original scene baseline.
/// </summary>
private readonly HashSet<Guid> _baselineObjectIds = new();
/// <summary>
/// Serialized data of baseline objects so we can restore them if destroyed.
/// </summary>
private readonly Dictionary<Guid, string> _baselineObjectData = new();
private static bool _restorePersistedBaseline;
private static HashSet<Guid> _persistedBaselineIds;
private static Dictionary<Guid, string> _persistedBaselineData;
/// <summary>
/// Whether a baseline has been captured.
/// </summary>
public bool HasBaseline => _baselineObjectIds.Count > 0;
public CleanupSystem( Scene scene ) : base( scene )
{
}
/// <summary>
/// Call from SaveSystem before Game.ChangeScene() to snapshot the current baseline
/// </summary>
public static void PreserveBaselineForSaveLoad()
{
if ( Current is null || !Current.HasBaseline ) return;
_restorePersistedBaseline = true;
_persistedBaselineIds = new HashSet<Guid>( Current._baselineObjectIds );
_persistedBaselineData = new Dictionary<Guid, string>( Current._baselineObjectData );
}
void ISceneLoadingEvents.BeforeLoad( Scene scene, SceneLoadOptions options )
{
// Clear any existing baseline when a new scene is loading
_baselineObjectIds.Clear();
_baselineObjectData.Clear();
}
async Task ISceneLoadingEvents.OnLoad( Scene scene, SceneLoadOptions options, LoadingContext context )
{
// We don't care if the game is not playing
if ( !Game.IsPlaying ) return;
// Wait for next frame to ensure all objects are spawned
await Task.Yield();
// Could be null if the scene was unloaded before this runs
if ( !Scene.IsValid() ) return;
// When loading a save, restore the baseline captured before the scene was destroyed
if ( _restorePersistedBaseline && _persistedBaselineIds is not null )
{
_baselineObjectIds.UnionWith( _persistedBaselineIds );
foreach ( var kvp in _persistedBaselineData )
_baselineObjectData.TryAdd( kvp.Key, kvp.Value );
_restorePersistedBaseline = false;
Log.Info( $"CleanupSystem: Restored persisted baseline with {_baselineObjectIds.Count} objects." );
}
else
{
CaptureBaseline();
}
}
/// <summary>
/// Captures the current scene state as the baseline.
/// All objects that exist at this point are considered part of the original map.
/// </summary>
public void CaptureBaseline()
{
_baselineObjectIds.Clear();
_baselineObjectData.Clear();
foreach ( var go in Scene.Children?.ToArray() ?? [] )
{
CaptureObjectRecursive( go );
}
Log.Info( $"CleanupSystem: Captured baseline with {_baselineObjectIds.Count} objects." );
}
private void CaptureObjectRecursive( GameObject go )
{
if ( !go.IsValid() )
return;
// Skip player objects
if ( IsPlayerObject( go ) )
return;
if ( go.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
return;
_baselineObjectIds.Add( go.Id );
var serialized = go.Serialize();
if ( serialized is not null )
{
_baselineObjectData[go.Id] = serialized.ToJsonString();
}
foreach ( var child in go.Children?.ToArray() ?? [] )
{
CaptureObjectRecursive( child );
}
}
/// <summary>
/// Determines if a GameObject is a player or belongs to a player.
/// </summary>
private static bool IsPlayerObject( GameObject go )
{
if ( !go.IsValid() )
return false;
if ( go.Components.Get<Player>( true ) is not null )
return true;
if ( go.Components.Get<PlayerData>( true ) is not null )
return true;
var parent = go.Parent;
while ( parent is not null && parent != go.Scene )
{
if ( parent.Components.Get<Player>( true ) is not null )
return true;
if ( parent.Components.Get<PlayerData>( true ) is not null )
return true;
parent = parent.Parent;
}
return false;
}
/// <summary>
/// Cleans up the scene by removing all spawned objects and restoring destroyed baseline objects.
/// Players and their belongings are preserved.
/// </summary>
public void Cleanup()
{
if ( !HasBaseline )
{
Log.Warning( "CleanupSystem: No baseline captured. Cannot cleanup." );
return;
}
if ( !Networking.IsHost )
{
Log.Warning( "CleanupSystem: Only the host can perform cleanup." );
return;
}
var removedCount = 0;
var restoredCount = 0;
var objectsToRemove = new List<GameObject>();
var existingBaselineIds = new HashSet<Guid>();
foreach ( var go in Scene.GetAllObjects( true ) )
{
if ( !go.IsValid() )
continue;
// Never remove player objects
if ( IsPlayerObject( go ) )
continue;
if ( go.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
continue;
if ( _baselineObjectIds.Contains( go.Id ) )
{
existingBaselineIds.Add( go.Id );
}
else
{
if ( go.Parent == Scene )
{
objectsToRemove.Add( go );
}
}
}
// Remove spawned objects
foreach ( var go in objectsToRemove )
{
if ( go.IsValid() )
{
go.Destroy();
removedCount++;
}
}
// Restore destroyed baseline objects
foreach ( var kvp in _baselineObjectData )
{
var id = kvp.Key;
// Skip if the object still exists
if ( existingBaselineIds.Contains( id ) )
continue;
// Skip if we already processed the parent object
var go = Scene.Directory.FindByGuid( id );
if ( go.IsValid() )
continue;
try
{
var json = System.Text.Json.Nodes.JsonNode.Parse( kvp.Value );
if ( json is System.Text.Json.Nodes.JsonObject jso )
{
var restored = new GameObject();
restored.Deserialize( jso );
restoredCount++;
}
}
catch ( System.Exception ex )
{
Log.Warning( $"CleanupSystem: Failed to restore object {id}: {ex.Message}" );
}
}
BroadcastCleanup( removedCount, restoredCount );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private static void BroadcastCleanup( int removedObjects, int restoredObjects )
{
Game.ActiveScene?.RunEvent<ICleanupEvents>( x => x.OnCleanup( removedObjects, restoredObjects ) );
Log.Info( $"Cleanup complete. Removed {removedObjects} spawned objects, restored {restoredObjects} destroyed objects." );
}
/// <summary>
/// Console command to cleanup the map.
/// </summary>
[ConCmd( "cleanup" )]
public static void CleanupCommand( string targetName = null )
{
if ( !Networking.IsHost ) return;
//
// Targeted cleanup, doesn't use the same cleanup shit
//
if ( !string.IsNullOrEmpty( targetName ) )
{
var target = GameManager.FindPlayerWithName( targetName );
if ( target is not null )
{
CleanupPlayer( target );
}
else
{
Notices.AddNotice( "cleaning_services", Color.Red, $"Can't find {targetName} to clean up" );
}
return;
}
if ( Current is null )
{
Log.Warning( "CleanupSystem: No active cleanup system." );
return;
}
Current.Cleanup();
}
[Rpc.Host]
public static void RpcCleanUpMine()
{
CleanupPlayer( Rpc.Caller );
}
[Rpc.Host]
public static void RpcCleanUpAll()
{
if ( !Rpc.Caller.HasPermission( "admin" ) ) return;
Current?.Cleanup();
}
[Rpc.Host]
public static void RpcCleanUpTarget( Connection target )
{
if ( !Rpc.Caller.HasPermission( "admin" ) ) return;
CleanupPlayer( target );
}
public static void CleanupPlayer( Connection caller )
{
Assert.True( Networking.IsHost, "Only the host may call this method!" );
var removable = Game.ActiveScene.GetAllComponents<Ownable>()
.Where( o => o.Owner == caller );
var count = 0;
foreach ( var ownable in removable.ToArray() )
{
ownable.GameObject.Destroy();
count++;
}
Notices.SendNotice( caller, "cleaning_services", Color.Green, $"Cleaned up {count} objects" );
}
}
[Alias( "dynamite" )]
public sealed class DynamiteEntity : Component, IPlayerControllable, Component.IDamageable
{
[Property, Range( 1, 500 ), Step( 1 ), ClientEditable]
public float Damage { get; set; } = 128;
[Property, Range( 16, 4096 ), Step( 16 ), ClientEditable]
public float Radius { get; set; } = 1024f;
[Property, Range( 1, 100 ), Step( 1 ), ClientEditable]
public float Force { get; set; } = 1;
[Property, Sync, ClientEditable]
public ClientInput Activate { get; set; }
bool _isDead = false;
[Rpc.Host]
public void Explode()
{
_isDead = true;
var explosionPrefab = ResourceLibrary.Get<PrefabFile>( "/prefabs/engine/explosion_med.prefab" );
if ( explosionPrefab == null )
{
Log.Warning( "Can't find /prefabs/engine/explosion_med.prefab" );
return;
}
var go = GameObject.Clone( explosionPrefab, new CloneConfig { Transform = WorldTransform.WithScale( 1 ), StartEnabled = false } );
if ( !go.IsValid() ) return;
go.RunEvent<RadiusDamage>( x =>
{
x.Radius = Radius;
x.PhysicsForceScale = Force;
x.DamageAmount = Damage;
x.Attacker = go;
}, FindMode.EverythingInSelfAndDescendants );
go.Enabled = true;
go.NetworkSpawn( true, null );
GameObject.Destroy();
}
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( _isDead ) return;
if ( IsProxy ) return;
Explode();
}
void IPlayerControllable.OnControl()
{
if ( Activate.Pressed() )
{
Explode();
}
}
void IPlayerControllable.OnEndControl()
{
// nothing to do
}
void IPlayerControllable.OnStartControl()
{
// nothing to do
}
}
/// <summary>
/// Whether the emitter fires while the input is held, or toggles on/off with a press.
/// </summary>
public enum EmitMode
{
/// <summary>
/// Press once to turn on, press again to turn off.
/// </summary>
Toggle,
/// <summary>
/// Emits only while the input is held down.
/// </summary>
Hold,
}
/// <summary>
/// A world-placed SENT that spawns and controls a particle/VFX emitter.
/// The emitter prefab is defined by a <see cref="ScriptedEmitter"/> resource.
/// </summary>
[Alias( "emitter" )]
public sealed class EmitterEntity : Component, IPlayerControllable
{
/// <summary>
/// The emitter definition points to a prefab containing a particle system.
/// </summary>
[Property, ClientEditable]
public ScriptedEmitter Emitter { get; set; }
/// <summary>
/// Whether this emitter toggles on/off with a press, or emits only while held.
/// </summary>
[Property, ClientEditable]
public EmitMode Mode { get; set; } = EmitMode.Toggle;
/// <summary>
/// Used when <see cref="Mode"/> is <see cref="EmitMode.Toggle"/>.
/// </summary>
[Property, Sync, ClientEditable, Group( "Input" )]
public ClientInput ToggleInput { get; set; }
/// <summary>
/// Used when <see cref="Mode"/> is <see cref="EmitMode.Hold"/>.
/// </summary>
[Property, Sync, ClientEditable, Group( "Input" )]
public ClientInput HoldInput { get; set; }
/// <summary>
/// Whether the emitter is currently active. Synced to all clients.
/// </summary>
[Sync] public bool IsEmitting { get; private set; }
/// <summary>
/// When enabled, forces the emitter on regardless of input or mode.
/// Can be set from the editor or wired up externally.
/// </summary>
[Property, ClientEditable]
public bool ManualOn
{
get => _manualOn;
set { _manualOn = value; if ( !IsProxy ) UpdateEmitState(); }
}
private bool _manualOn;
private bool _inputEmitting;
private GameObject _particleInstance;
private ScriptedEmitter _lastEmitter;
protected override void OnStart() { }
protected override void OnUpdate()
{
// Emitter resource changed — destroy existing instance so it gets recreated
if ( _lastEmitter != Emitter && _particleInstance.IsValid() )
DestroyParticle();
_lastEmitter = Emitter;
if ( IsEmitting && !_particleInstance.IsValid() )
SpawnParticle();
else if ( !IsEmitting && _particleInstance.IsValid() )
DestroyParticle();
}
void IPlayerControllable.OnStartControl() { }
void IPlayerControllable.OnEndControl()
{
if ( Mode == EmitMode.Hold )
{
_inputEmitting = false;
UpdateEmitState();
}
}
void IPlayerControllable.OnControl()
{
if ( Mode == EmitMode.Toggle )
{
if ( ToggleInput.Pressed() )
{
_inputEmitting = !_inputEmitting;
UpdateEmitState();
}
}
else
{
var held = HoldInput.Down();
if ( held != _inputEmitting )
{
_inputEmitting = held;
UpdateEmitState();
}
}
}
private void UpdateEmitState() => SetEmitting( _inputEmitting || _manualOn );
[Rpc.Broadcast]
private void SetEmitting( bool active )
{
IsEmitting = active;
}
private void SpawnParticle()
{
if ( !Emitter.IsValid() || Emitter.Prefab is null ) return;
_particleInstance = GameObject.Clone( Emitter.Prefab, new CloneConfig
{
Parent = GameObject,
Transform = new Transform( Vector3.Forward * 4f ),
StartEnabled = true,
} );
}
private void DestroyParticle()
{
_particleInstance.Destroy();
_particleInstance = null;
}
}
public sealed class SpotLightEntity : Component, IPlayerControllable
{
[Property, ClientEditable, Group( "Light" )]
public bool On { get; set { field = value; UpdateLight(); } } = true;
[Property, ClientEditable, Group( "Light" )]
public bool Shadows { get; set { field = value; UpdateLight(); } } = true;
[Property, Range( 0, 1 ), ClientEditable, Group( "Light" )]
public Color Color { get; set { field = value; UpdateLight(); } }
[Property, Range( 0, 50 ), ClientEditable, Group( "Light" )]
public float Brightness { get; set { field = value; UpdateLight(); } } = 2;
[Property, Range( 0, 1000 ), ClientEditable, Group( "Light" )]
public float Radius { get; set { field = value; UpdateLight(); } } = 500;
[Property, Range( 0, 90 ), ClientEditable, Group( "Light" )]
public float Angle { get; set { field = value; UpdateLight(); } } = 35;
[Property, Range( 0, 16 ), ClientEditable, Group( "Light" )]
public float Attenuation { get; set { field = value; UpdateLight(); } } = 2.4f;
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput TurnOn { get; set; }
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput TurnOff { get; set; }
[Property, Sync, ClientEditable, Group( "State" )]
public ClientInput Toggle { get; set; }
[Property]
public GameObject OnGameObject { get; set; }
[Property]
public GameObject OffGameObject { get; set; }
void IPlayerControllable.OnControl()
{
if ( Toggle.Pressed() )
{
On = !On;
}
if ( TurnOn.Pressed() )
{
On = true;
}
if ( TurnOff.Pressed() )
{
On = false;
}
}
void IPlayerControllable.OnEndControl()
{
}
void IPlayerControllable.OnStartControl()
{
}
void UpdateLight()
{
OnGameObject?.Enabled = On;
OffGameObject?.Enabled = !On;
if ( GetComponentInChildren<SpotLight>( true ) is not SpotLight light )
return;
light.Enabled = On;
var color = Color;
color.r *= Brightness;
color.g *= Brightness;
color.b *= Brightness;
light.Shadows = Shadows;
light.LightColor = color;
light.Radius = Radius;
light.Attenuation = Attenuation;
light.ConeOuter = Angle;
light.ConeInner = Angle * 0.5f;
Network.Refresh();
}
}
public partial class BaseBulletWeapon : BaseWeapon
{
[Property]
public SoundEvent ShootSound { get; set; }
[Property, Group( "Bullet" )]
public BulletConfiguration Bullet { get; set; } = new()
{
Damage = 12f,
BulletRadius = 1f,
Range = 4096f,
AimConeBase = new Vector2( 0.5f, 0.25f ),
AimConeSpread = new Vector2( 3f, 3f ),
AimConeRecovery = 0.2f,
RecoilPitch = new Vector2( -0.3f, -0.1f ),
RecoilYaw = new Vector2( -0.1f, 0.1f ),
CameraRecoilStrength = 1f,
CameraRecoilFrequency = 1f,
};
[Property, Group( "Bullet" ), ClientEditable, Range( 0f, 500000f ), Step( 10f )]
public float ShootForce { get; set; } = 100000f;
protected TimeSince TimeSinceShoot = 0;
/// <summary>
/// Returns 0 for no aim spread, 1 for full aim cone, based on time since last shot.
/// </summary>
protected float GetAimConeAmount( float recovery )
{
return TimeSinceShoot.Relative.Remap( 0, recovery, 1, 0 );
}
/// <summary>
/// Returns the aim cone amount using the configured recovery time
/// </summary>
protected float GetAimConeAmount()
{
return GetAimConeAmount( Bullet.AimConeRecovery );
}
/// <inheritdoc cref="ShootBullet(float, in BulletConfiguration)"/>
protected void ShootBullet( float fireRate )
{
ShootBullet( fireRate, Bullet );
}
/// <summary>
/// Shoot a bullet out of the front of the gun.
/// When held by a player, fires from the player's eye with aim cone and recoil.
/// When standalone (no owner), fires straight from the weapon's muzzle.
/// </summary>
protected void ShootBullet( float fireRate, in BulletConfiguration config )
{
if ( HasOwner && ( !HasAmmo() || IsReloading() ) )
{
TryAutoReload();
return;
}
if ( TimeUntilNextShotAllowed > 0 )
return;
// Only consume ammo when held by a player
if ( HasOwner && !TakeAmmo( 1 ) )
{
AddShootDelay( 0.2f );
return;
}
AddShootDelay( fireRate );
var aimConeAmount = GetAimConeAmount( config.AimConeRecovery );
var forward = AimRay.Forward
.WithAimCone(
config.AimConeBase.x + aimConeAmount * config.AimConeSpread.x,
config.AimConeBase.y + aimConeAmount * config.AimConeSpread.y
);
var traceRay = AimRay with { Forward = forward };
var tr = Scene.Trace.Ray( traceRay, config.Range )
.IgnoreGameObjectHierarchy( AimIgnoreRoot )
.WithCollisionRules( "bullet" )
.WithoutTags( "playercontroller" )
.Radius( config.BulletRadius )
.UseHitboxes()
.Run();
ShootEffects( tr.EndPosition, tr.Hit, tr.Normal, tr.GameObject, tr.Surface );
TraceAttack( TraceAttackInfo.From( tr, config.Damage ) );
TimeSinceShoot = 0;
// Recoil only applies when held by a player
if ( !HasOwner )
{
// Simulate physical recoil by pushing the weapon opposite to its fire direction
if ( ShootForce > 0f && GetComponent<Rigidbody>( true ) is var rb )
{
var muzzle = WeaponModel?.MuzzleTransform?.WorldTransform ?? WorldTransform;
rb.ApplyForce( muzzle.Rotation.Up * ShootForce );
}
return;
}
Owner.Controller.EyeAngles += new Angles(
Random.Shared.Float( config.RecoilPitch.x, config.RecoilPitch.y ),
Random.Shared.Float( config.RecoilYaw.x, config.RecoilYaw.y ),
0
);
if ( !Owner.Controller.ThirdPerson && Owner.IsLocalPlayer )
{
_ = new Sandbox.CameraNoise.Recoil( config.CameraRecoilStrength, config.CameraRecoilFrequency );
}
}
[Rpc.Broadcast]
public void ShootEffects( Vector3 hitpoint, bool hit, Vector3 normal, GameObject hitObject, Surface hitSurface, Vector3? origin = null, bool noEvents = false )
{
if ( Application.IsDedicatedServer ) return;
if ( !hitSurface.IsValid() ) return;
Owner?.Controller.Renderer.Set( "b_attack", true );
if ( !noEvents )
{
if ( WeaponModel.IsValid() )
{
WeaponModel.GameObject.RunEvent<WeaponModel>( x => x.OnAttack() );
WeaponModel.GameObject.RunEvent<WeaponModel>( x => x.CreateRangedEffects( this, hitpoint, origin ) );
}
if ( ShootSound.IsValid() )
{
var snd = GameObject.PlaySound( ShootSound );
// If we're shooting, the sound should not be spatialized
if ( HasOwner && Owner.IsLocalPlayer && snd.IsValid() )
{
snd.SpacialBlend = 0;
}
}
}
if ( !hit || !hitObject.IsValid() )
return;
var baseSurface = hitSurface.GetBaseSurface();
var bulletSound = hitSurface.SoundCollection.Bullet ?? baseSurface?.SoundCollection.Bullet;
if ( bulletSound.IsValid() )
{
Sound.Play( bulletSound, hitpoint );
}
var prefab = hitSurface.PrefabCollection.BulletImpact ?? baseSurface?.PrefabCollection.BulletImpact;
// Still null?
if ( prefab is null )
return;
var fwd = Rotation.LookAt( normal * -1.0f, Vector3.Random );
var impact = prefab.Clone();
impact.WorldPosition = hitpoint;
impact.WorldRotation = fwd;
impact.SetParent( hitObject, true );
if ( hitObject.GetComponentInChildren<SkinnedModelRenderer>() is not { CreateBoneObjects: true } skinned )
return;
// find closest bone
var bones = skinned.GetBoneTransforms( true );
var closestDist = float.MaxValue;
for ( var i = 0; i < bones.Length; i++ )
{
var bone = bones[i];
var dist = bone.Position.Distance( hitpoint );
if ( dist < closestDist )
{
closestDist = dist;
impact.SetParent( skinned.GetBoneObject( i ), true );
}
}
}
public record struct BulletConfiguration
{
public float Damage { get; set; }
public float BulletRadius { get; set; }
public Vector2 AimConeBase { get; set; }
public Vector2 AimConeSpread { get; set; }
public float AimConeRecovery { get; set; }
public Vector2 RecoilPitch { get; set; }
public Vector2 RecoilYaw { get; set; }
public float CameraRecoilStrength { get; set; }
public float CameraRecoilFrequency { get; set; }
public float Range { get; set; }
}
}
using System.Threading;
public partial class BaseWeapon
{
/// <summary>
/// Should we consume 1 bullet per reload instead of filling the clip?
/// </summary>
[Property, Feature( "Ammo" )]
public bool IncrementalReloading { get; set; } = false;
/// <summary>
/// Extra delay after the first shell reload before subsequent shells begin (e.g. longer carrier insertion animation).
/// Only used with incremental reloading. If zero, no extra delay is added.
/// </summary>
[Property, Feature( "Ammo" ), ShowIf( nameof( IncrementalReloading ), true )]
public float FirstShellReloadTime { get; set; } = 0f;
/// <summary>
/// Delay before the first shell is inserted during incremental reload.
/// If zero, uses <see cref="ReloadTime"/>.
/// </summary>
[Property, Feature( "Ammo" ), ShowIf( nameof( IncrementalReloading ), true )]
public float ReloadStartTime { get; set; } = 0f;
/// <summary>
/// Can we cancel reloads?
/// </summary>
[Property, Feature( "Ammo" )]
public bool CanCancelReload { get; set; } = true;
private CancellationTokenSource reloadToken;
private bool isReloading;
public bool CanReload()
{
if ( !UsesClips ) return false;
if ( ClipContents >= ClipMaxSize ) return false;
if ( isReloading ) return false;
if ( !WeaponConVars.InfiniteReserves && ReserveAmmo <= 0 ) return false;
return true;
}
public bool IsReloading() => isReloading;
public virtual void CancelReload()
{
if ( reloadToken?.IsCancellationRequested == false )
{
reloadToken?.Cancel();
isReloading = false;
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadCancel() );
}
}
public virtual async void OnReloadStart()
{
if ( !CanReload() )
return;
CancelReload();
var cts = new CancellationTokenSource();
reloadToken = cts;
isReloading = true;
try
{
await ReloadAsync( cts.Token );
}
finally
{
// Only clean up our own reload
if ( reloadToken == cts )
{
isReloading = false;
reloadToken = null;
}
cts.Dispose();
}
}
[Rpc.Broadcast]
private void BroadcastReload()
{
if ( !HasOwner ) return;
Assert.True( Owner.Controller.IsValid(), "BaseWeapon::BroadcastReload - Player Controller is invalid!" );
Assert.True( Owner.Controller.Renderer.IsValid(), "BaseWeapon::BroadcastReload - Renderer is invalid!" );
Owner.Controller.Renderer.Set( "b_reload", true );
}
protected virtual async Task ReloadAsync( CancellationToken ct )
{
// Capture so we can tell if a newer reload has replaced us by the time finally runs.
var mySource = reloadToken;
var isFirstShell = ClipContents == 0;
try
{
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadStart() );
BroadcastReload();
var firstIteration = true;
while ( ClipContents < ClipMaxSize && !ct.IsCancellationRequested )
{
var delay = (firstIteration && IncrementalReloading && ReloadStartTime > 0f) ? ReloadStartTime : ReloadTime;
firstIteration = false;
await Task.DelaySeconds( delay, ct );
var needed = IncrementalReloading ? 1 : (ClipMaxSize - ClipContents);
if ( WeaponConVars.InfiniteReserves )
{
ViewModel?.RunEvent<ViewModel>( x => x.OnIncrementalReload( isFirstShell ) );
ClipContents += needed;
}
else
{
var available = Math.Min( needed, ReserveAmmo );
if ( available <= 0 )
break;
ViewModel?.RunEvent<ViewModel>( x => x.OnIncrementalReload( isFirstShell ) );
ReserveAmmo -= available;
ClipContents += available;
}
// After the first shell, wait longer before the next one starts
if ( isFirstShell && FirstShellReloadTime > 0f )
{
await Task.DelaySeconds( FirstShellReloadTime, ct );
}
isFirstShell = false;
}
}
finally
{
if ( reloadToken == mySource )
{
ViewModel?.RunEvent<ViewModel>( x => x.OnReloadFinish() );
}
}
}
}
/// <summary>
/// The local user's preferences in Deathmatch
/// </summary>
internal static class GamePreferences
{
/// <summary>
/// Enables automatic switching to better weapons on item pickup
/// </summary>
[ConVar( "sb.autoswitch", ConVarFlags.UserInfo | ConVarFlags.Saved )]
public static bool AutoSwitch { get; set; } = true;
/// <summary>
/// Enables fast switching between inventory weapons
/// </summary>
[ConVar( "sb.fastswitch", ConVarFlags.Saved )]
public static bool FastSwitch { get; set; } = false;
/// <summary>
/// Intensity of your camera's screenshake
/// </summary>
[ConVar( "sb.viewbob", ConVarFlags.Saved )]
[Group( "Camera" )]
public static bool ViewBobbing { get; set; } = true;
/// <summary>
/// Intensity of your camera's screenshake
/// </summary>
[ConVar( "sb.screenshake", ConVarFlags.Saved )]
[Range( 0.1f, 2f ), Step( 0.1f ), Group( "Camera" )]
public static float Screenshake { get; set; } = 0.3f;
}
namespace Sandbox.Npcs;
/// <summary>
/// Console variables that control NPC AI behaviour globally.
/// </summary>
public static class NpcConVars
{
/// <summary>
/// When disabled, all NPC AI thinking is paused — they just stand idle.
/// </summary>
[ConVar( "sb.ai.enabled", ConVarFlags.Replicated | ConVarFlags.Saved, Help = "Enable or disable NPC AI thinking." )]
public static bool Enabled { get; set; } = true;
/// <summary>
/// When enabled, NPCs cannot target players.
/// </summary>
[ConVar( "sb.ai.notarget", ConVarFlags.Replicated | ConVarFlags.Saved, Help = "When enabled, NPCs cannot target players." )]
public static bool NoTarget { get; set; } = false;
}
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Tasks;
namespace Sandbox.Npcs.Schedules;
/// <summary>
/// Panic flee — scream while sprinting away from the source.
/// </summary>
public sealed class ScientistFleeSchedule : ScheduleBase
{
private static readonly string[] PanicLines =
[
"AHHH!",
"Don't hurt me!",
"Help! HELP!",
"Stay away from me!",
"I'm just a scientist!",
"Please, no!",
"Somebody help!",
"Oh god oh god oh god!",
"What did I do?!",
"Leave me alone!",
];
public GameObject Source { get; set; }
/// <summary>
/// 0–1 panic intensity. Higher values mean faster speed and longer flee distance.
/// </summary>
public float PanicLevel { get; set; } = 0.5f;
protected override void OnStart()
{
if ( !Source.IsValid() ) return;
// Sprint speed scales with panic (200–350)
Npc.Navigation.WishSpeed = 200f + 150f * PanicLevel;
// Don't stare at the player — look where we're running
Npc.Animation.ClearLookTarget();
// Scream immediately — but only if not already mid-speech
if ( Npc.Speech.CanSpeak )
{
var line = PanicLines[Game.Random.Int( 0, PanicLines.Length - 1 )];
Npc.Speech.Say( line, 2f );
}
// Flee direction — away from the attacker with some randomness
var awayDir = (GameObject.WorldPosition - Source.WorldPosition).WithZ( 0 ).Normal;
var randomAngle = Game.Random.Float( -40f, 40f );
awayDir = Rotation.FromAxis( Vector3.Up, randomAngle ) * awayDir;
// Distance scales with panic (200–500)
var fleeDist = 512f + 1024f * PanicLevel;
var fleeTarget = GameObject.WorldPosition + awayDir * fleeDist;
// Snap to navmesh
if ( Npc.Scene.NavMesh.GetClosestPoint( fleeTarget ) is { } navPoint )
{
AddTask( new MoveTo( navPoint, 15f ) );
}
else
{
AddTask( new MoveTo( fleeTarget, 15f ) );
}
}
protected override void OnEnd()
{
// Reset to normal walk speed
// TODO: this is shit, can we scope these somehow so the IDisposable handles all this ?
Npc.Navigation.WishSpeed = 100f;
}
protected override bool ShouldCancel()
{
return !Source.IsValid();
}
}
/// <summary>
/// Apply fall damage to the player
/// </summary>
public class PlayerFallDamage : Component, Local.IPlayerEvents
{
[RequireComponent] public Player Player { get; set; }
/// <summary>
/// Fatal fall speed, you will die if you fall at or above this speed
/// </summary>
[Property] public float FatalFallSpeed { get; set; } = 1536.0f;
/// <summary>
/// Maximum safe fall speed, you won't take damage at or below this speed
/// </summary>
[Property] public float MaxSafeFallSpeed { get; set; } = 512.0f;
/// <summary>
/// Multiply damage amount by this much
/// </summary>
[Property] public float DamageMultiplier { get; set; } = 1.0f;
/// <summary>
/// Fall damage sound
/// </summary>
[Property] public SoundEvent FallSound { get; set; }
[Rpc.Owner]
private void PlayFallSound()
{
GameObject.PlaySound( FallSound );
}
void Local.IPlayerEvents.OnLand( float distance, Vector3 velocity )
{
var fallSpeed = Math.Abs( velocity.z );
if ( fallSpeed <= MaxSafeFallSpeed )
return;
var damageAmount = MathX.Remap( fallSpeed, MaxSafeFallSpeed, FatalFallSpeed, 0f, 100f ) * DamageMultiplier;
if ( damageAmount < 1 ) return;
if ( Networking.IsHost && damageAmount >= Player.Health )
Player.PlayerData?.AddStat( "player.fall.death" );
TakeFallDamage( damageAmount );
}
[Rpc.Broadcast]
public void TakeFallDamage( float amount )
{
if ( !Networking.IsHost ) return;
if ( Player is IDamageable damage )
{
var dmg = new DamageInfo( amount.CeilToInt(), Player.GameObject, null );
dmg.Tags.Add( DamageTags.Fall );
damage.OnDamage( dmg );
PlayFallSound();
}
}
}
/// <summary>
/// Manages loadout persistence, presets, and restoration for a player.
/// Lives on the Player GameObject alongside PlayerInventory.
/// Listens to inventory events to auto-save, and handles all loadout RPCs directly.
/// </summary>
public sealed class PlayerLoadout : Component, Local.IPlayerEvents, Global.IPlayerEvents, Global.ISaveEvents
{
[RequireComponent] public Player Player { get; set; }
[RequireComponent] public PlayerInventory Inventory { get; set; }
private bool _isRestoringLoadout;
/// <summary>
/// One entry in a serialized loadout: the prefab resource path and the slot it occupies.
/// </summary>
public struct LoadoutEntry
{
public string PrefabPath { get; set; }
public int Slot { get; set; }
public string SpawnerDataPayload { get; set; }
}
public struct SavedPreset
{
public string Name { get; set; }
public string LoadoutJson { get; set; }
}
public static IReadOnlyList<SavedPreset> GetLoadoutPresets()
{
return LocalData.Get<List<SavedPreset>>( "presets", new() );
}
public static void SaveLoadoutPreset( string name, string loadoutJson )
{
var presets = LocalData.Get<List<SavedPreset>>( "presets", new() );
var idx = presets.FindIndex( p => p.Name == name );
var entry = new SavedPreset { Name = name, LoadoutJson = loadoutJson };
if ( idx >= 0 )
presets[idx] = entry;
else
presets.Add( entry );
LocalData.Set( "presets", presets );
}
public static void DeleteLoadoutPreset( string name )
{
var presets = LocalData.Get<List<SavedPreset>>( "presets", new() );
presets.RemoveAll( p => p.Name == name );
LocalData.Set( "presets", presets );
}
public string SerializeLoadout()
{
var entries = Inventory.Weapons
.Where( w => !string.IsNullOrEmpty( w.GameObject.PrefabInstanceSource ) )
.Select( w => new LoadoutEntry
{
PrefabPath = w.GameObject.PrefabInstanceSource,
Slot = w.InventorySlot,
SpawnerDataPayload = (w as SpawnerWeapon)?.SpawnerData
} )
.ToList();
return entries.Count > 0 ? Json.Serialize( entries ) : null;
}
public void SaveLoadout()
{
if ( _isRestoringLoadout ) return;
var json = SerializeLoadout();
if ( string.IsNullOrEmpty( json ) ) return;
if ( Player.IsLocalPlayer )
{
LocalData.Set( "hotbar", json );
}
else
{
PushLoadoutToClient( json );
}
}
public void GiveLoadoutWeapons( string json )
{
var entries = Json.Deserialize<List<LoadoutEntry>>( json );
if ( entries is null ) return;
_isRestoringLoadout = true;
try
{
foreach ( var entry in entries )
{
if ( !Inventory.Pickup( entry.PrefabPath, entry.Slot, false ) )
continue;
if ( !string.IsNullOrEmpty( entry.SpawnerDataPayload ) && Inventory.GetSlot( entry.Slot ) is SpawnerWeapon spawnerWeapon )
{
spawnerWeapon.RestoreSpawnerData( entry.SpawnerDataPayload );
}
}
}
finally
{
_isRestoringLoadout = false;
}
}
private static async Task EnsureMountedAsync( string json )
{
var entries = Json.Deserialize<List<LoadoutEntry>>( json );
if ( entries is null ) return;
var needsMounts = entries.Any( e => !string.IsNullOrEmpty( e.SpawnerDataPayload )
&& e.SpawnerDataPayload.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase ) );
if ( !needsMounts ) return;
foreach ( var entry in Sandbox.Mounting.Directory.GetAll().Where( e => e.Available ) )
await Sandbox.Mounting.Directory.Mount( entry.Ident );
}
public void SwitchToPreset( string loadoutJson )
{
if ( !Networking.IsHost )
{
HostSwitchToPreset( loadoutJson );
return;
}
_ = SwitchToPresetAsync( loadoutJson );
}
public void ResetToDefault()
{
if ( !Networking.IsHost )
{
HostResetToDefault();
return;
}
_ = ResetToDefaultAsync();
}
[Rpc.Host]
private void HostSwitchToPreset( string loadoutJson )
{
_ = SwitchToPresetAsync( loadoutJson );
}
[Rpc.Host]
private void HostResetToDefault()
{
_ = ResetToDefaultAsync();
}
private async Task SwitchToPresetAsync( string loadoutJson )
{
var previousSlot = Inventory.ActiveWeapon?.InventorySlot ?? 0;
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
await EnsureMountedAsync( loadoutJson );
GiveLoadoutWeapons( loadoutJson );
var toEquip = Inventory.GetSlot( previousSlot ) ?? Inventory.GetBestWeapon();
if ( toEquip.IsValid() )
Inventory.SwitchWeapon( toEquip );
SaveLoadout();
}
private async Task ResetToDefaultAsync()
{
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
Inventory.GiveDefaultWeapons();
Inventory.SwitchWeapon( Inventory.GetBestWeapon() );
SaveLoadout();
}
[Rpc.Owner]
private void PushLoadoutToClient( string loadoutJson )
{
LocalData.Set( "hotbar", loadoutJson );
}
[Rpc.Owner]
private void RequestClientLoadout()
{
var json = LocalData.Get<string>( "hotbar" );
if ( !string.IsNullOrEmpty( json ) )
HostRestoreLoadoutFromClient( json );
}
/// <summary>
/// Clears the current inventory, waits a frame, then gives the loadout from JSON and equips the best weapon.
/// </summary>
private async Task ReplaceLoadoutAsync( string json )
{
foreach ( var weapon in Inventory.Weapons.ToList() )
weapon.DestroyGameObject();
await Task.Yield();
await EnsureMountedAsync( json );
GiveLoadoutWeapons( json );
var best = Inventory.GetBestWeapon();
if ( best.IsValid() )
Inventory.SwitchWeapon( best );
}
[Rpc.Host]
private async void HostRestoreLoadoutFromClient( string loadoutJson )
{
await ReplaceLoadoutAsync( loadoutJson );
}
void Global.IPlayerEvents.OnPlayerSpawned( Player player )
{
if ( player != Player ) return;
if ( !Networking.IsHost ) return;
_ = RestoreOnSpawnAsync();
}
private async Task RestoreOnSpawnAsync()
{
if ( Player.IsLocalPlayer )
{
var json = LocalData.Get<string>( "hotbar" );
if ( !string.IsNullOrEmpty( json ) )
{
await ReplaceLoadoutAsync( json );
return;
}
}
else
{
RequestClientLoadout();
return;
}
Inventory.GiveDefaultWeapons();
var bestWeapon = Inventory.GetBestWeapon();
if ( bestWeapon.IsValid() )
Inventory.SwitchWeapon( bestWeapon );
}
void Local.IPlayerEvents.OnDied( PlayerDiedParams args )
{
if ( !Networking.IsHost ) return;
SaveLoadout();
}
void Local.IPlayerEvents.OnPickup( PlayerPickupEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
SaveLoadout();
}
void Local.IPlayerEvents.OnDrop( PlayerDropEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
_ = SaveLoadoutAfterYield();
}
void Local.IPlayerEvents.OnRemoveWeapon( PlayerRemoveWeaponEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
_ = SaveLoadoutAfterYield();
}
void Local.IPlayerEvents.OnMoveSlot( PlayerMoveSlotEvent e )
{
if ( e.Cancelled ) return;
if ( !Networking.IsHost ) return;
SaveLoadout();
}
private async Task SaveLoadoutAfterYield()
{
await Task.Yield();
SaveLoadout();
}
void Global.ISaveEvents.BeforeSave( string filename )
{
if ( !Networking.IsHost ) return;
var steamId = (long)(Player.Network.Owner?.SteamId ?? 0);
if ( steamId == 0 ) return;
var json = SerializeLoadout();
if ( string.IsNullOrEmpty( json ) ) return;
SaveSystem.Current?.SetMetadata( $"Loadout_{steamId}", json );
}
void Global.ISaveEvents.AfterLoad( string filename )
{
if ( !Networking.IsHost ) return;
var steamId = (long)(Player.Network.Owner?.SteamId ?? 0);
if ( steamId == 0 ) return;
var json = SaveSystem.Current?.GetMetadata( $"Loadout_{steamId}" );
if ( string.IsNullOrEmpty( json ) ) return;
_ = RestoreLoadoutFromSaveAsync( json );
}
private async Task RestoreLoadoutFromSaveAsync( string json )
{
await ReplaceLoadoutAsync( json );
}
}
/// <summary>
/// Dead players become these. They try to observe their last corpse.
/// </summary>
internal sealed class PlayerObserver : Component
{
Angles EyeAngles;
TimeSince timeSinceStarted;
DeathCameraTarget _cachedCorpse;
float currentDistance;
protected override void OnEnabled()
{
base.OnEnabled();
EyeAngles = Scene.Camera.WorldRotation;
timeSinceStarted = 0;
currentDistance = 32;
_cachedCorpse = Scene.GetAllComponents<DeathCameraTarget>()
.Where( x => x.Connection == Network.Owner )
.OrderByDescending( x => x.Created )
.FirstOrDefault();
}
protected override void OnUpdate()
{
// Don't allow immediate respawn
if ( timeSinceStarted < 1 )
return;
// If pressed a button, or has been too long
if ( Input.Pressed( "attack1" ) || Input.Pressed( "jump" ) || timeSinceStarted > 4f )
{
GameManager.Current?.RequestRespawn();
GameObject.Destroy();
}
}
protected override void OnPreRender()
{
if ( IsProxy ) return;
if ( _cachedCorpse.IsValid() )
{
RotateAround( _cachedCorpse );
}
}
private void RotateAround( Component target )
{
// Find the corpse eyes
if ( target.Components.Get<SkinnedModelRenderer>().TryGetBoneTransform( "pelvis", out var tx ) )
{
tx.Position += Vector3.Up * 25;
}
var e = EyeAngles;
e += Input.AnalogLook;
e.pitch = e.pitch.Clamp( -90, 90 );
e.roll = 0.0f;
EyeAngles = e;
currentDistance = currentDistance.LerpTo( 150, Time.Delta * 5 );
var center = tx.Position;
var targetPos = center - EyeAngles.Forward * currentDistance;
var tr = Scene.Trace.FromTo( center, targetPos ).Radius( 1.0f ).WithoutTags( "ragdoll", "effect" ).Run();
Scene.Camera.WorldPosition = tr.EndPosition;
Scene.Camera.WorldRotation = EyeAngles;
}
}
namespace Sandbox.UI;
public sealed class ResourceSelectAttribute : System.Attribute
{
public string Extension { get; set; }
public bool AllowPackages { get; set; }
}
public interface ISpawnMenuTab
{
}
namespace Sandbox.UI;
public class NoticePanel : Panel
{
bool initialized;
Vector3.SpringDamped _springy;
public RealTimeUntil TimeUntilDie;
/// <summary>
/// If true, the notice won't auto-dismiss. Call <see cref="Dismiss"/> to remove it.
/// </summary>
public bool Manual { get; set; }
public bool IsDead => !Manual && TimeUntilDie < 0;
public bool wasDead = false;
/// <summary>
/// Dismiss a manual notice, causing it to slide out and be deleted.
/// </summary>
public void Dismiss()
{
Manual = false;
TimeUntilDie = 0;
}
internal void UpdatePosition( Vector2 vector2 )
{
if ( initialized == false )
{
_springy = new Vector3.SpringDamped( new Vector3( Screen.Width + 50, vector2.y + Random.Shared.Float( -10, 10 ), 0 ), 0.0f );
_springy.Velocity = Vector3.Random * 1000;
initialized = true;
}
if ( !Manual && TimeUntilDie < 0.4f )
{
vector2.x -= 50;
}
// we're dead, push us out to rhe right
if ( IsDead )
{
vector2.x = Screen.Width + 50;
// we've been dead for 2 seconds, get rid of us
if ( TimeUntilDie < -2 )
{
Delete();
return;
}
wasDead = true;
}
_springy.Target = new Vector3( vector2.x, vector2.y, 0 );
_springy.Frequency = 4;
_springy.Damping = 0.5f;
_springy.Update( RealTime.Delta * 1.0f );
Style.Left = _springy.Current.x * ScaleFromScreen;
Style.Top = _springy.Current.y * ScaleFromScreen;
}
}
public static class Extensions
{
public static Vector3 WithAimCone( this Vector3 direction, float degrees )
{
var angle = Rotation.LookAt( direction );
angle *= new Angles( Game.Random.Float( -degrees / 2.0f, degrees / 2.0f ), Game.Random.Float( -degrees / 2.0f, degrees / 2.0f ), 0 );
return angle.Forward;
}
public static Vector3 WithAimCone( this Vector3 direction, float horizontalDegrees, float verticalDegrees )
{
var angle = Rotation.LookAt( direction );
angle *= new Angles( Game.Random.Float( -verticalDegrees / 2.0f, verticalDegrees / 2.0f ), Game.Random.Float( -horizontalDegrees / 2.0f, horizontalDegrees / 2.0f ), 0 );
return angle.Forward;
}
}
using Sandbox.Rendering;
public sealed class CameraWeapon : BaseWeapon
{
float fov;
float roll = 0;
bool focusing;
[Property] SoundEvent CameraShoot { get; set; }
/// <summary>
/// The RT camera's resolution
/// </summary>
private static int _cameraResolution = 512;
/// <summary>
/// The render target texture produced by this camera. Read by <see cref="TVEntity"/>.
/// </summary>
public Texture RenderTexture => _renderTexture;
private Texture _renderTexture;
private CameraComponent _rtCamera;
public override bool WantsHideHud => true;
protected override void OnEnabled()
{
base.OnEnabled();
EnsureRTCamera();
EnsureRenderTexture();
}
protected override void OnDisabled()
{
base.OnDisabled();
CleanupRenderTexture();
_rtCamera = null;
}
protected override void OnDestroy()
{
CleanupRenderTexture();
_rtCamera = null;
}
protected override void OnPreRender()
{
if ( !_rtCamera.IsValid() ) return;
EnsureRenderTexture();
if ( HasOwner && Scene.Camera.IsValid() )
{
// When held, mirror the player's camera so the TV shows their POV.
// TODO: network some props to the TV so they show up in the RT camera when held by a player other than the host.
_rtCamera.WorldPosition = Scene.Camera.WorldPosition;
_rtCamera.WorldRotation = Scene.Camera.WorldRotation;
_rtCamera.FieldOfView = Scene.Camera.FieldOfView;
if ( !_rtCamera.RenderExcludeTags.Has( "viewer" ) )
_rtCamera.RenderExcludeTags.Add( "viewer" );
}
else
{
_rtCamera.RenderExcludeTags.Remove( "viewer" );
_rtCamera.FieldOfView = 40f;
}
}
/// <summary>
/// We want to control the camera fov when held by a player.
/// </summary>
public override void OnCameraSetup( Player player, Sandbox.CameraComponent camera )
{
if ( !player.Network.IsOwner || !Network.IsOwner ) return;
if ( fov > 0 )
camera.FieldOfView = fov;
camera.WorldRotation = camera.WorldRotation * new Angles( 0, 0, roll );
}
public override void OnCameraMove( Player player, ref Angles angles )
{
if ( Input.Down( "attack2" ) )
{
angles = default;
}
var currentFov = fov > 0 ? fov : Scene.Camera.FieldOfView;
float sensitivity = currentFov.Remap( 1, 70, 0.01f, 1 );
angles *= sensitivity;
}
public override void OnControl( Player player )
{
base.OnControl( player );
if ( Input.Pressed( "reload" ) )
{
fov = 0;
roll = 0;
}
if ( Input.Down( "attack2" ) )
{
fov = ((fov > 0 ? fov : Scene.Camera.FieldOfView) + Input.AnalogLook.pitch).Clamp( 1, 150 );
roll -= Input.AnalogLook.yaw;
}
if ( focusing && Input.Released( "attack1" ) )
{
Game.TakeScreenshot();
Sandbox.Services.Stats.Increment( "photos", 1 );
GameObject?.PlaySound( CameraShoot );
}
focusing = Input.Down( "attack1" );
}
private void EnsureRTCamera()
{
_rtCamera = GetComponentInChildren<CameraComponent>( true );
if ( _rtCamera is null )
{
var go = new GameObject( GameObject, true, "rt_camera" );
_rtCamera = go.AddComponent<CameraComponent>();
}
_rtCamera.IsMainCamera = false;
_rtCamera.BackgroundColor = Color.Black;
_rtCamera.ClearFlags = ClearFlags.Color | ClearFlags.Depth | ClearFlags.Stencil;
_rtCamera.FieldOfView = Scene.Camera.FieldOfView;
_rtCamera.RenderExcludeTags.Add( "viewmodel" );
}
private void EnsureRenderTexture()
{
if ( _renderTexture.IsValid() && _renderTexture.Width == _cameraResolution && _renderTexture.Height == _cameraResolution )
return;
CleanupRenderTexture();
_renderTexture = Texture.CreateRenderTarget()
.WithSize( _cameraResolution, _cameraResolution )
.Create();
if ( _rtCamera.IsValid() )
{
_rtCamera.RenderTarget = _renderTexture;
}
}
private void CleanupRenderTexture()
{
if ( _rtCamera.IsValid() )
{
_rtCamera.RenderTarget = null;
}
_renderTexture?.Dispose();
_renderTexture = null;
}
public override void DrawHud( HudPainter painter, Vector2 crosshair )
{
// nothing!
}
}
using Sandbox.Rendering;
using Sandbox.Utility;
public sealed class RpgWeapon : BaseWeapon
{
[Property] public float TimeBetweenShots { get; set; } = 2f;
[Property] public GameObject ProjectilePrefab { get; set; }
[Property] public SoundEvent ShootSound { get; set; }
[Property] public float ProjectileSpeed { get; set; } = 1024f;
/// <summary>
/// When enabled, fired rockets will continuously track toward the player's crosshair.
/// Toggle with right-click (player) or SecondaryInput (standalone/seat).
/// </summary>
[Property, Sync, ClientEditable] public bool IsTrackedAim { get; set; } = false;
public override bool IsTargetedAim => IsTrackedAim;
[Sync( SyncFlags.FromHost )] RpgProjectile Projectile { get; set; }
TimeSince TimeSinceShoot;
private bool _hasFired;
private bool _waitingForReload;
/// <summary>
/// Whether a live rocket is currently being guided toward the crosshair.
/// </summary>
public bool IsGuiding => IsTrackedAim && Projectile.IsValid();
protected override float GetPrimaryFireRate() => TimeBetweenShots;
public override bool CanSecondaryAttack() => false;
public override void OnControl( Player player )
{
base.OnControl( player );
if ( Input.Pressed( "attack2" ) )
ToggleTrackedAim();
// Auto-reload after firing
if ( _hasFired && Input.Released( "attack1" ) )
{
_hasFired = false;
if ( IsGuiding )
_waitingForReload = true;
else if ( CanReload() )
OnReloadStart();
}
if ( IsGuiding )
{
var target = GetAimTarget();
Projectile.UpdateWithTarget( target, ProjectileSpeed );
}
else if ( _waitingForReload )
{
_waitingForReload = false;
if ( CanReload() )
OnReloadStart();
}
}
/// <summary>
/// Standalone / seat control — uses SecondaryInput to toggle tracking.
/// </summary>
public override void OnControl()
{
base.OnControl();
if ( HasOwner || IsProxy ) return;
if ( SecondaryInput.Pressed() )
ToggleTrackedAim();
if ( IsGuiding )
{
var target = GetAimTarget();
Projectile.UpdateWithTarget( target, ProjectileSpeed );
}
}
[Rpc.Host]
private void ToggleTrackedAim()
{
IsTrackedAim = !IsTrackedAim;
}
/// <summary>
/// Traces from AimRay and returns the world-space point the player is looking at.
/// </summary>
private Vector3 GetAimTarget()
{
var ray = AimRay;
var tr = Scene.Trace.Ray( ray, 16384f )
.IgnoreGameObjectHierarchy( AimIgnoreRoot )
.WithoutTags( "trigger", "projectile" )
.Run();
return tr.Hit ? tr.HitPosition : ray.Position + ray.Forward * 16384f;
}
public override void PrimaryAttack()
{
if ( HasOwner && !TakeAmmo( 1 ) )
{
TryAutoReload();
return;
}
TimeSinceShoot = 0;
AddShootDelay( TimeBetweenShots );
if ( ViewModel.IsValid() )
ViewModel.RunEvent<ViewModel>( x => x.OnAttack() );
else if ( WorldModel.IsValid() )
WorldModel.RunEvent<WorldModel>( x => x.OnAttack() );
if ( ShootSound.IsValid() )
GameObject.PlaySound( ShootSound );
var ray = AimRay;
var muzzlePos = MuzzleTransform.WorldTransform.Position;
var spawnPos = muzzlePos + ray.Forward * 64f;
if ( HasOwner )
{
spawnPos = CheckThrowPosition( Owner, muzzlePos, spawnPos );
Owner.Controller.EyeAngles += new Angles( Random.Shared.Float( -0.2f, -0.3f ), Random.Shared.Float( -0.1f, 0.1f ), 0 );
if ( !Owner.Controller.ThirdPerson && Owner.IsLocalPlayer )
{
new Sandbox.CameraNoise.Punch( new Vector3( Random.Shared.Float( 45, 35 ), Random.Shared.Float( -10, -5 ), 0 ), 1.5f, 2, 0.5f );
new Sandbox.CameraNoise.Shake( 1f, 0.6f );
_hasFired = true;
}
}
CreateProjectile( spawnPos, ray.Forward, ProjectileSpeed );
}
private Vector3 CheckThrowPosition( Player player, Vector3 eyePosition, Vector3 grenadePosition )
{
var tr = Scene.Trace.Box( BBox.FromPositionAndSize( Vector3.Zero, 8.0f ), eyePosition, grenadePosition )
.WithoutTags( "trigger", "ragdoll", "player", "effect" )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
if ( tr.Hit )
return tr.EndPosition;
return grenadePosition;
}
/// <summary>
/// Creates the projectile with the host's permission
/// </summary>
[Rpc.Host]
void CreateProjectile( Vector3 start, Vector3 direction, float speed )
{
var go = ProjectilePrefab?.Clone( start );
var projectile = go.GetComponent<RpgProjectile>();
Assert.True( projectile.IsValid(), "RpgProjectile not on projectile prefab" );
if ( Owner.IsValid() )
projectile.Instigator = Owner;
else if ( ClientInput.Current.IsValid() )
projectile.Instigator = ClientInput.Current;
go.NetworkSpawn();
Projectile = projectile;
projectile.UpdateDirection( direction, speed );
}
public override void DrawCrosshair( HudPainter hud, Vector2 center )
{
var tss = TimeSinceShoot.Relative.Remap( 0, 0.2f, 1, 0 );
var w = 2;
hud.SetBlendMode( BlendMode.Lighten );
if ( IsTrackedAim )
{
// Diamond crosshair when in tracked aim mode
Color guideColor = IsGuiding ? new Color( 1f, 0.5f, 0.1f ) : CrosshairCanShoot;
var size = 32f;
hud.DrawLine( center + new Vector2( 0, -size ), center + new Vector2( size, 0 ), w, guideColor );
hud.DrawLine( center + new Vector2( size, 0 ), center + new Vector2( 0, size ), w, guideColor );
hud.DrawLine( center + new Vector2( 0, size ), center + new Vector2( -size, 0 ), w, guideColor );
hud.DrawLine( center + new Vector2( -size, 0 ), center + new Vector2( 0, -size ), w, guideColor );
return;
}
Color color = !CanPrimaryAttack() ? CrosshairNoShoot : CrosshairCanShoot;
var squareSize = 64f;
hud.DrawLine( center + new Vector2( -squareSize / 2, -squareSize / 2 ), center + new Vector2( squareSize / 2, -squareSize / 2 ), w, color );
hud.DrawLine( center + new Vector2( squareSize / 2, -squareSize / 2 ), center + new Vector2( squareSize / 2, squareSize / 2 ), w, color );
hud.DrawLine( center + new Vector2( squareSize / 2, squareSize / 2 ), center + new Vector2( -squareSize / 2, squareSize / 2 ), w, color );
hud.DrawLine( center + new Vector2( -squareSize / 2, squareSize / 2 ), center + new Vector2( -squareSize / 2, -squareSize / 2 ), w, color );
}
}
using System.Text.Json.Nodes;
/// <summary>
/// Holds a bunch of GameObject json, a bounding box, and some preview models for a
/// duplication. This is what gets serialized to a string and stored in the Duplicator tool.
/// The objects and the bounds are created in selection space. Where the user right clicked to
/// select is 0,0,0, and the player's view yaw is the rotation identity.
/// </summary>
public class DuplicationData
{
/// <summary>
/// An array of JsonObject objects, which are serialzed GameObjects
/// </summary>
public JsonArray Objects { get; set; }
/// <summary>
/// The bounds are used to work out where to place the duplication, so it
/// doesn't clip through the floor.
/// </summary>
public BBox Bounds { get; set; }
/// <summary>
/// Describes where to draw a model for the preview
/// </summary>
public record struct PreviewModel( Model Model, Transform Transform, Transform[] Bones, BBox Bounds );
/// <summary>
/// A list of preview models to help visualze where the duplication will be placed
/// </summary>
public List<PreviewModel> PreviewModels { get; set; }
/// <summary>
/// Packages used in this
/// </summary>
public List<string> Packages { get; set; }
/// <summary>
/// Create DuplicationData from a bunch of objects.
/// center is the transform to use as the origin for the duplication.
/// The rotation of center should be the player's view yaw when they made the selection.
/// </summary>
public static DuplicationData CreateFromObjects( IEnumerable<GameObject> objects, Transform center )
{
var dupe = new DuplicationData();
dupe.Objects = new JsonArray();
dupe.Bounds = BBox.FromPositionAndSize( 0, 0.01f );
dupe.PreviewModels = new();
List<BBox> worldBounds = new List<BBox>();
foreach ( var obj in objects )
{
var entry = obj.Serialize();
worldBounds.Add( GetWorldBounds( obj ) );
var localized = center.ToLocal( obj.WorldTransform );
entry["Position"] = JsonValue.Create( localized.Position );
entry["Rotation"] = JsonValue.Create( localized.Rotation );
entry["Scale"] = JsonValue.Create( localized.Scale );
dupe.Objects.Add( entry );
foreach ( var renderer in obj.GetComponentsInChildren<ModelRenderer>() )
{
var model = renderer.Model ?? Model.Cube;
if ( model.IsError ) continue;
Transform[] bones = null;
if ( renderer is SkinnedModelRenderer skinned )
{
bones = skinned.GetBoneTransforms( false );
}
var modelTx = center.ToLocal( renderer.WorldTransform );
dupe.PreviewModels.Add( new DuplicationData.PreviewModel( model, modelTx, bones, model.Bounds ) );
}
}
if ( worldBounds.Count > 0 )
{
var txi = new Transform( -center.Position, center.Rotation.Inverse );
dupe.Bounds = BBox.FromBoxes( worldBounds.Select( x => x.Transform( txi ) ) );
}
var packages = Cloud.ResolvePrimaryAssetsFromJson( dupe.Objects );
dupe.Packages = packages.Select( x => x.FullIdent ).ToList();
return dupe;
}
public static BBox GetWorldBounds( GameObject go )
{
BBox box = BBox.FromPositionAndSize( 0, 0.01f );
var rb = go.GetComponentsInChildren<Collider>( false, true ).ToArray();
if ( rb.Length > 0 )
{
box = rb[0].GetWorldBounds();
foreach ( var b in rb )
{
box = box.AddBBox( b.GetWorldBounds() );
}
}
return box;
}
}
[Icon( "🔗" )]
[Title( "#tool.name.linker" )]
[ClassName( "linker" )]
[Group( "#tool.group.constraints" )]
public sealed class LinkerTool : BaseConstraintToolMode
{
public override string Description => Stage == 1 ? "#tool.hint.linker.stage1" : "#tool.hint.linker.stage0";
public override string PrimaryAction => Stage == 1 ? "#tool.hint.linker.finish" : "#tool.hint.linker.source";
public override string ReloadAction => "#tool.hint.linker.remove";
protected override IEnumerable<GameObject> FindConstraints( GameObject linked, GameObject target )
{
foreach ( var link in linked.GetComponentsInChildren<ManualLink>( true ) )
if ( linked == target || link.Body?.Root == target )
yield return link.GameObject;
}
protected override void CreateConstraint( SelectionPoint point1, SelectionPoint point2 )
{
var go1 = new GameObject( point1.GameObject, false, "link" );
var go2 = new GameObject( point2.GameObject, false, "link" );
var link1 = go1.AddComponent<ManualLink>();
var link2 = go2.AddComponent<ManualLink>();
link1.Body = go2;
link2.Body = go1;
go2.NetworkSpawn();
go1.NetworkSpawn();
Track( go1, go2 );
var undo = Player.Undo.Create();
undo.Name = "Link";
undo.Add( go1 );
}
}
[Icon( "➖" )]
[Title( "#tool.name.slider" )]
[ClassName( "slider" )]
[Group( "#tool.group.constraints" )]
public sealed class SliderTool : BaseConstraintToolMode
{
public override string Description => Stage == 1 ? "#tool.hint.slider.stage1" : "#tool.hint.slider.stage0";
public override string PrimaryAction => Stage == 1 ? "#tool.hint.slider.finish" : "#tool.hint.slider.source";
public override string SecondaryAction => Stage == 1 ? "#tool.hint.slider.secondary.stage1" : "#tool.hint.slider.secondary";
public override string ReloadAction => "#tool.hint.slider.remove";
protected override IEnumerable<GameObject> FindConstraints( GameObject linked, GameObject target )
{
foreach ( var joint in linked.GetComponentsInChildren<SliderJoint>( true ) )
if ( linked == target || joint.Body?.Root == target )
yield return joint.GameObject;
}
protected override SelectionPoint? GetSecondaryPoint( SelectionPoint select )
{
return TraceFromRay( select.WorldTransform().ForwardRay, 4096, select.GameObject );
}
protected override void CreateConstraint( SelectionPoint point1, SelectionPoint point2 )
{
if ( point1.GameObject == point2.GameObject )
return;
var axis = Rotation.LookAt( Vector3.Direction( point1.WorldPosition(), point2.WorldPosition() ) );
var go1 = new GameObject( false, "slider" );
go1.Parent = point1.GameObject;
go1.LocalTransform = point1.LocalTransform;
go1.WorldRotation = axis;
var go2 = new GameObject( false, "slider" );
go2.Parent = point2.GameObject;
go2.LocalTransform = point2.LocalTransform;
go2.WorldRotation = axis;
var cleanup = go1.AddComponent<ConstraintCleanup>();
cleanup.Attachment = go2;
var len = point1.WorldPosition().Distance( point2.WorldPosition() );
var joint = go1.AddComponent<SliderJoint>();
joint.Body = go2;
joint.MinLength = 0;
joint.MaxLength = len;
joint.EnableCollision = true;
var lineRenderer = go1.AddComponent<LineRenderer>();
lineRenderer.Points = [go1, go2];
lineRenderer.Width = 0.5f;
lineRenderer.Color = Color.Black;
lineRenderer.Lighting = true;
lineRenderer.CastShadows = true;
go2.NetworkSpawn();
go1.NetworkSpawn();
Track( go1, go2 );
var undo = Player.Undo.Create();
undo.Name = "Slider";
undo.Add( go1 );
undo.Add( go2 );
}
}
public abstract partial class ToolMode
{
[Rpc.Broadcast]
public virtual void ShootEffects( SelectionPoint target )
{
if ( !Toolgun.IsValid() ) return;
var player = Toolgun.Owner;
if ( !player.IsValid() ) return;
if ( !target.IsValid() )
{
Log.Warning( "ShootEffects: Unknown object" );
return;
}
Toolgun.SpinCoil();
var muzzle = Toolgun.MuzzleTransform;
if ( Toolgun.SuccessImpactEffect is GameObject impactPrefab )
{
var wt = target.WorldTransform();
wt.Rotation = wt.Rotation * new Angles( 90, 0, 0 );
var impact = impactPrefab.Clone( wt, null, false );
impact.Enabled = true;
}
if ( Toolgun.SuccessBeamEffect is GameObject beamEffect )
{
var wt = target.WorldTransform();
var go = beamEffect.Clone( new Transform( muzzle.WorldTransform.Position ), null, false );
foreach ( var beam in go.GetComponentsInChildren<BeamEffect>( true ) )
{
beam.TargetPosition = wt.Position;
}
go.Enabled = true;
}
Toolgun.ViewModel?.GetComponentInChildren<SkinnedModelRenderer>().Set( "b_attack", true );
}
public virtual void ShootFailEffects( SelectionPoint target )
{
}
}
using Sandbox;
public sealed class Music : Component
{
[Property] public SoundPointComponent SoundPoint { get; set; }
[Property] public float FadeInDuration { get; set; } = 3f;
[Property] public float FadeOutDuration { get; set; } = 3f;
[Property] public bool AutoFadeIn { get; set; } = true;
private float mutedVolume = 0f;
private float maxVolume = 1f;
public bool IsFadingIn { get; private set; } = false;
public bool IsFadingOut { get; private set; } = false;
public bool IsFinished { get; private set; } = false;
protected override void OnAwake()
{
SoundPoint.Volume = mutedVolume;
if ( AutoFadeIn )
{
FadeIn();
}
}
public void FadeIn()
{
IsFinished = false;
IsFadingOut = false;
IsFadingIn = true;
}
public void FadeOut()
{
IsFinished = false;
IsFadingIn = false;
IsFadingOut = true;
}
protected override void OnFixedUpdate()
{
if ( IsFadingIn )
{
if ( SoundPoint.Volume >= maxVolume )
{
IsFadingIn = false;
IsFinished = true;
return;
}
SoundPoint.Volume = SoundPoint.Volume.LerpTo( maxVolume, Time.Delta / FadeInDuration );
}
else if ( IsFadingOut )
{
if ( SoundPoint.Volume <= mutedVolume )
{
IsFadingOut = false;
IsFinished = true;
return;
}
SoundPoint.Volume = SoundPoint.Volume.LerpTo( mutedVolume, Time.Delta / FadeOutDuration );
}
}
}
public sealed class KillBox : Component, Component.ITriggerListener
{
[Property] public GameManager GameManager { get; set; }
public void OnTriggerEnter( Collider other )
{
if ( !Networking.IsHost ) return;
if ( !other.GameObject.Tags.Has( "player" ) ) return;
var player = other.GameObject.Root.GetComponent<PlayerController>();
if ( player == null ) return;
if ( GameManager.IsValid() )
{
GameManager.PlayerEliminated( player );
}
}
}
using System;
/// <summary>
/// Cinematic camera that takes over during the post-game results phase. Lives on the
/// main camera GameObject alongside <see cref="SpectatorMode"/>. Activates on every
/// client whenever <see cref="VictoryManager.IsShowingResults"/> is true and a Winner is set.
///
/// Behavior: starts in front of the winner (looking at their face), sways gently
/// side-to-side, and dollies in over the results duration for a hero-shot.
/// </summary>
public sealed class WinnerFocusCam : Component
{
// Dolly distance from winner in units; lerps Start -> End across the results window.
private const float DistanceStart = 140f;
private const float DistanceEnd = 110f;
// Height is relative to the lookAt point (winner chest level): 0 = straight-on,
// negative = camera below winner looking up. Lerps Start -> End across the results window.
private const float HeightStart = 10f;
private const float HeightEnd = -15f;
// A simple camera sweep left/right of the winner.
private const float SwayAmplitude = 15f; // degrees off-center each direction
private const float SwayPeriod = 8f; // seconds for one full left-right-back cycle
private float _baseYaw;
private float _startTime;
private bool _hasCapturedYaw;
protected override void OnUpdate()
{
VictoryManager victory = VictoryManager.Current;
GameObject winner = (victory != null && victory.IsShowingResults) ? victory.Winner : null;
if ( !winner.IsValid() )
{
_hasCapturedYaw = false;
return;
}
CameraComponent camera = Scene.Camera;
if ( !camera.IsValid() ) return;
if ( !_hasCapturedYaw )
{
_hasCapturedYaw = true;
// PlayerController is disabled on freeze — pass true to GetComponent so we can
// still find it. Renderer is the SkinnedModelRenderer whose GameObject rotation
// is the visual facing.
PlayerController pc = winner.GetComponent<PlayerController>( true );
Rotation rendererRotation = (pc?.Renderer.IsValid() == true) ? pc.Renderer.WorldRotation : winner.WorldRotation;
_baseYaw = rendererRotation.Yaw() + 180f;
_startTime = Time.Now;
}
float elapsed = Time.Now - _startTime;
float swayPhase = (elapsed / SwayPeriod) * MathF.PI * 2f;
float yaw = _baseYaw + MathF.Sin( swayPhase ) * SwayAmplitude;
// Dolly-in: lerp distance + height across the results window using the synced timer.
float remaining = (float)victory.ResultsTimer;
float t = Math.Clamp( 1f - (remaining / VictoryManager.ResultsDuration), 0f, 1f );
float distance = MathX.Lerp( DistanceStart, DistanceEnd, t );
float height = MathX.Lerp( HeightStart, HeightEnd, t );
Rotation rotation = new Angles( 0f, yaw, 0f ).ToRotation();
Vector3 lookAt = winner.WorldPosition + Vector3.Up * 60f;
Vector3 camPos = lookAt + rotation.Forward * -distance + Vector3.Up * height;
camera.WorldPosition = camPos;
camera.WorldRotation = Rotation.LookAt( (lookAt - camPos).Normal );
}
}
using System;
using System.Collections.Generic;
using Sandbox;
/// <summary>
/// Handles the post-game victory sequence: declaring the winner, freezing them on a
/// gold podium, raining confetti, disintegrating the arena, and finally swapping back
/// to the lobby scene. <see cref="GameManager"/> kicks this off via <see cref="BeginVictory"/>
/// once only one player remains.
/// </summary>
public sealed class VictoryManager : Component
{
[Property] TileManager TileManager { get; set; }
[Property] public GameObject ConfettiPrefab { get; set; }
[Property] public SoundEvent ConfettiSound { get; set; }
[Property] public SoundEvent VictoryMusic { get; set; }
[Property] public SceneFile SceneToLoadFinish { get; set; }
/// <summary>Total length of the victory phase, from winner declared to scene swap.</summary>
public const float ResultsDuration = 7f;
/// <summary>How long the outward tile-drop wave takes inside <see cref="ResultsDuration"/>.</summary>
public const float DisintegrationDuration = 5f;
// Seconds the screen takes to fade to black at the tail of the results window.
private const float ResultsFadeDuration = 2f;
// Seconds the victory music ramps down for at the tail of the results window so the scene swap doesn't cut it off abruptly.
private const float VictoryMusicFadeDuration = 3f;
[Sync] public bool IsShowingResults { get; private set; } = false;
[Sync] public GameObject Winner { get; private set; }
[Sync] public TimeUntil ResultsTimer { get; private set; }
public static VictoryManager Current { get; private set; }
// Host-only guard so we only kick off the scene change once.
private bool _hasFinishedResults = false;
// Per-client: ensures the closing fade-out fires once during the results window.
private bool _hasTriggeredResultsFade = false;
// Per-client: starts the LerpTo on _victoryMusicHandle.Volume once the timer enters the window.
private bool _hasTriggeredMusicFade = false;
private SoundHandle _victoryMusicHandle;
// Host-only: tiles queued to drop during results, sorted outward from the podium.
private readonly List<(Tile tile, TimeUntil at)> _disintegrationSchedule = new();
// Confetti bursts queued during results. Populated locally on every client when the
// host's BroadcastBeginConfetti RPC arrives, then ticked locally to spawn prefab clones.
// `initialVelocity` is per-burst world-space bias (inward toward the winner + upward)
// applied to the spawned ParticleEffect so confetti arcs up and over the player.
private readonly List<(TimeUntil at, Vector3 pos, Vector3 initialVelocity)> _confettiBurstSchedule = new();
// Per-client: keeps the winner in a celebratory pose during results. Renderer animation
// params are local-only (not network-synced), so each client sets the special_idle_states
// enum on its own SkinnedModelRenderer.
private const int WinnerIdleState = 1; // citizen animgraph special_idle_states: 0=normal, 1=avatar_menu
private static readonly Color[] ConfettiColors =
{
Color.Parse( "#FF5757" ) ?? Color.Red,
Color.Parse( "#FFD93D" ) ?? Color.Yellow,
Color.Parse( "#6BCB77" ) ?? Color.Green,
Color.Parse( "#4D96FF" ) ?? Color.Blue,
Color.Parse( "#FF6FFF" ) ?? Color.Magenta,
Color.White,
};
protected override void OnEnabled()
{
Current = this;
}
protected override void OnDisabled()
{
// Stop the victory music handle so it doesn't carry over into the next scene
if ( _victoryMusicHandle.IsValid() )
{
_victoryMusicHandle.Stop();
_victoryMusicHandle = null;
}
if ( Current == this )
Current = null;
}
protected override void OnFixedUpdate()
{
// Per-client: hold the winner in their victory pose regardless of host status.
TickWinnerPose();
// Per-client: drive the confetti schedule locally (populated by BroadcastBeginConfetti).
TickConfettiBursts();
// Per-client: trigger the closing screen fade once the results timer enters the fade window.
if ( IsShowingResults && !_hasTriggeredResultsFade && (float)ResultsTimer <= ResultsFadeDuration )
{
_hasTriggeredResultsFade = true;
ScreenFade.FadeOut( ResultsFadeDuration );
}
// Per-client: ramp the victory music down over the final stretch so the scene swap is more smooth
// Mirrors the LerpTo pattern in Music.FadeOut — trigger once on entering the window, then
// lerp every tick until the handle drops out / scene swaps.
if ( IsShowingResults && !_hasTriggeredMusicFade && (float)ResultsTimer <= VictoryMusicFadeDuration )
{
_hasTriggeredMusicFade = true;
}
if ( _hasTriggeredMusicFade && _victoryMusicHandle.IsValid() )
{
_victoryMusicHandle.Volume = _victoryMusicHandle.Volume.LerpTo( 0f, Time.Delta / VictoryMusicFadeDuration );
}
if ( !Networking.IsHost ) return;
if ( !IsShowingResults ) return;
TickDisintegration();
if ( !_hasFinishedResults && ResultsTimer <= 0f )
{
_hasFinishedResults = true;
FinishGame();
}
}
/// <summary>
/// Host-only. Enter the post-game results phase: declare the winner, freeze the game,
/// set up the podium tile, and start the timer that eventually swaps back to the lobby scene.
/// <paramref name="winner"/> may be null (e.g. last two players were eliminated simultaneously).
/// </summary>
public void BeginVictory( PlayerController winner )
{
if ( !Networking.IsHost ) return;
if ( IsShowingResults ) return;
GameObject winnerGameObject = winner.IsValid() ? winner.GameObject : null;
GameObject podiumGameObject = null;
if ( winnerGameObject.IsValid() )
{
podiumGameObject = SetupPodium( winner );
// Center the winner on the podium tile so a stray last step can't carry them off the edge.
// Teleport must run while the rigidbody is still active so the position
// change propagates through physics/network sync before the freeze lands.
if ( podiumGameObject.IsValid() )
BroadcastTeleportWinner( winnerGameObject, podiumGameObject.WorldPosition );
BroadcastFreezeWinner( winnerGameObject );
}
ScheduleDisintegration( podiumGameObject );
BroadcastResultsBegin( winnerGameObject );
// Session-wide leaderboard: fanned out so every client increments
// their own local Leaderboard copy. Bots use PlayerReadyState.LeaderboardId
// (a synthetic id) so wins/crowns are tracked per-bot and don't smear onto the host.
string winnerLeaderboardId = winner?.GetComponent<PlayerReadyState>()?.LeaderboardId;
if ( !string.IsNullOrEmpty( winnerLeaderboardId ) )
{
BroadcastRecordWin( winnerLeaderboardId );
}
// Confetti: fan out via RPC so every client populates its own local burst schedule.
// [Sync] doesn't propagate reliably on this scene-singleton (verified), but
// [Rpc.Broadcast] bodies do run on clients (same mechanism BroadcastResultsBegin uses).
if ( winnerGameObject.IsValid() && podiumGameObject.IsValid() )
{
Vector3 winnerForward = winnerGameObject.WorldRotation.Forward.WithZ( 0f );
if ( winnerForward.LengthSquared > 0.001f )
{
BroadcastBeginConfetti( podiumGameObject.WorldPosition, winnerForward.Normal );
}
}
string winnerName = winner.GetPlayerName();
Log.Info( $"{winnerName} won! Showing results for {ResultsDuration}s." );
}
// Find the tile the winner is standing on and convert it into a golden podium. If they
// were mid-air, spawn a fresh podium tile above the arena center and teleport them onto it.
// Returns the podium tile's prefab root so callers can exclude it from the disintegration wave.
private GameObject SetupPodium( PlayerController winner )
{
Vector3 winnerPos = winner.WorldPosition;
// Start well above the player so we're clearly outside their body capsule, trace down
// past their feet. Ignore both the player root and the separate ColliderObject — the
// "Colliders" child isn't tagged "player", so WithoutTags alone won't exclude it.
var trace = Scene.Trace.Ray( winnerPos + Vector3.Up * 200f, winnerPos + Vector3.Down * 60f )
.WithoutTags( "player" )
.IgnoreGameObjectHierarchy( winner.GameObject );
if ( winner.ColliderObject.IsValid() )
trace = trace.IgnoreGameObjectHierarchy( winner.ColliderObject );
SceneTraceResult result = trace.Run();
GameObject podiumGameObject = null;
if ( result.Hit && result.GameObject.IsValid() )
{
// result.GameObject is the TileModelCollider; one level up is the tile prefab root,
// which contains the Tile component on a sibling child. Don't use .Root — that walks
// all the way up to the scene-level TileManager and would tint the whole arena.
GameObject tileRoot = result.GameObject.Parent;
Tile existingTile = tileRoot?.GetComponentInChildren<Tile>();
if ( existingTile != null )
{
podiumGameObject = tileRoot;
}
}
if ( podiumGameObject == null )
{
// Mid-air winner: spawn a podium tile above the arena center. The winner is teleported
// onto it by BeginVictory, not here, so the snap happens after the freeze.
podiumGameObject = SpawnPodiumGameObject();
if ( podiumGameObject == null )
{
Log.Warning( "[Results] Could not produce a podium tile for mid-air winner." );
return null;
}
}
BroadcastConvertToPodium( podiumGameObject );
return podiumGameObject;
}
// Host-only. Build an outward-from-podium drop schedule for every non-podium tile, spread
// across DisintegrationDuration. Tile.BreakTile is host-authoritative and flips a [Sync]
// _falling flag, so clients animate the cascade for free.
private void ScheduleDisintegration( GameObject podiumGameObject )
{
_disintegrationSchedule.Clear();
if ( TileManager == null ) return;
Vector3 podiumPos = podiumGameObject.IsValid()
? podiumGameObject.WorldPosition
: TileManager.WorldPosition;
var candidates = new List<(Tile tile, float distance)>();
foreach ( Tile tile in TileManager.GameObject.GetComponentsInChildren<Tile>() )
{
if ( !tile.IsValid() ) continue;
// Tile lives on a child of the prefab root; comparing parents skips the podium tile.
if ( podiumGameObject.IsValid() && tile.GameObject.Parent == podiumGameObject ) continue;
// Use horizontal distance only so every layer ripples outward together,
// instead of cascading top-layer-first to bottom-layer-last.
float horizontalDistance = (tile.WorldPosition - podiumPos).WithZ( 0f ).Length;
candidates.Add( (tile, horizontalDistance) );
}
candidates.Sort( ( a, b ) => a.distance.CompareTo( b.distance ) );
for ( int i = 0; i < candidates.Count; i++ )
{
float t = candidates.Count > 1 ? (float)i / (candidates.Count - 1) : 0f;
TimeUntil dropAt = t * DisintegrationDuration;
_disintegrationSchedule.Add( (candidates[i].tile, dropAt) );
}
}
// Runs on every client. Holds the winner in the citizen animgraph's avatar_menu pose
// (hands-on-hips victory stance) for the duration of the results window.
private void TickWinnerPose()
{
if ( !IsShowingResults || !Winner.IsValid() ) return;
PlayerController pc = Winner.GetComponent<PlayerController>( true );
if ( pc == null || !pc.Renderer.IsValid() ) return;
pc.Renderer.Set( "special_idle_states", WinnerIdleState );
}
// Fanned out from the host so every client (including host) populates its own local
// confetti schedule from the same pattern. Plain RPC — [Sync] doesn't propagate on this
// scene-singleton (verified). Mirror of the BroadcastResultsBegin pattern.
[Rpc.Broadcast]
private void BroadcastBeginConfetti( Vector3 podiumPos, Vector3 winnerForward )
{
_confettiBurstSchedule.Clear();
if ( winnerForward.LengthSquared < 0.001f ) return;
winnerForward = winnerForward.Normal;
// (delay seconds, yaw offset from "directly behind" in degrees, radius, height)
// Spawn well behind the winner and at or below podium level so each burst starts
// near the bottom of the WinnerFocusCam frame, then arcs up into view.
var pattern = new (float delay, float yaw, float radius, float height)[]
{
( 0.00f, 0f, 110f, -5f ), // dead behind
( 0.60f, -55f, 120f, 0f ), // behind-left
( 1.20f, 55f, 120f, 0f ), // behind-right
( 2.00f, 0f, 95f, 10f ), // closer, slightly higher (over-the-top pop)
( 2.80f, -90f, 135f, -10f ), // hard left flank, low
( 2.80f, 90f, 135f, -10f ), // hard right flank, low
( 3.80f, 0f, 110f, -5f ), // final center pop
};
foreach ( var (delay, yaw, radius, height) in pattern )
{
Vector3 offsetDir = Rotation.FromYaw( yaw ) * (-winnerForward);
Vector3 pos = podiumPos + offsetDir * radius + Vector3.Up * height;
// Push each particle horizontally toward the winner (so bursts behind/around
// them arc inward) and upward (so they rise into the camera frame before
// drifting back down like paper). World-space — the prefab has LocalSpace=0.
Vector3 inwardHorizontal = (podiumPos - pos).WithZ( 0f );
Vector3 inwardDir = inwardHorizontal.LengthSquared > 0.001f ? inwardHorizontal.Normal : Vector3.Zero;
Vector3 initialVelocity = inwardDir * 260f + Vector3.Up * 240f;
_confettiBurstSchedule.Add( (delay, pos, initialVelocity) );
}
}
private void TickConfettiBursts()
{
if ( _confettiBurstSchedule.Count == 0 ) return;
for ( int i = _confettiBurstSchedule.Count - 1; i >= 0; i-- )
{
var entry = _confettiBurstSchedule[i];
if ( entry.at <= 0f )
{
SpawnConfettiLocally( entry.pos, entry.initialVelocity );
_confettiBurstSchedule.RemoveAt( i );
}
}
}
private void TickDisintegration()
{
if ( _disintegrationSchedule.Count == 0 ) return;
for ( int i = _disintegrationSchedule.Count - 1; i >= 0; i-- )
{
var entry = _disintegrationSchedule[i];
if ( !entry.tile.IsValid() )
{
_disintegrationSchedule.RemoveAt( i );
continue;
}
if ( entry.at <= 0f )
{
entry.tile.BreakTile();
_disintegrationSchedule.RemoveAt( i );
}
}
}
private GameObject SpawnPodiumGameObject()
{
if ( TileManager == null || !TileManager.TilePrefab.IsValid() ) return null;
// Raise the podium above the top layer so it doesn't z-fight with the existing center tile.
Vector3 spawnPos = TileManager.WorldPosition + Vector3.Up * 96f;
GameObject tileGameObject = TileManager.TilePrefab.Clone( new CloneConfig
{
Parent = TileManager.GameObject,
StartEnabled = true,
Transform = new Transform( spawnPos )
} );
tileGameObject.Name = "Tile_Podium";
tileGameObject.NetworkSpawn();
return tileGameObject;
}
// Fanned out so every client locally disables the regular Tile behavior on this GameObject
// and swaps in a PodiumTile component (which tints it gold + resets any in-progress wobble).
[Rpc.Broadcast]
private void BroadcastConvertToPodium( GameObject tileGameObject )
{
if ( !tileGameObject.IsValid() ) return;
// Tile lives on a child of the prefab root, so search downward.
Tile tile = tileGameObject.GetComponentInChildren<Tile>();
if ( tile != null )
{
tile.SetTriggerEnabled( false );
tile.Enabled = false;
}
if ( tileGameObject.GetComponent<PodiumTile>() == null )
{
tileGameObject.AddComponent<PodiumTile>();
}
}
// Fanned out so every client sets the winner's local PlayerController flags. Matches the
// pattern used by PlayerManager.EnablePlayersInput.
[Rpc.Broadcast]
private void BroadcastFreezeWinner( GameObject winnerGameObject )
{
if ( !winnerGameObject.IsValid() ) return;
PlayerController pc = winnerGameObject.GetComponent<PlayerController>();
if ( pc == null ) return;
pc.UseInputControls = false;
pc.UseCameraControls = false;
// Clear any held input — otherwise the last WishVelocity (e.g. W still pressed)
// keeps driving the controller forward after input is disabled.
pc.WishVelocity = Vector3.Zero;
// Disable the controller and freeze rigidbody motion so the winner-hop tick can drive
// the transform directly without the move modes or physics overriding our position.
pc.Enabled = false;
Rigidbody rb = winnerGameObject.GetComponent<Rigidbody>();
if ( rb != null ) rb.MotionEnabled = false;
// PlayerController.OnUpdate is what pumps animation params each frame; once we
// disable it, the last "sprinting" values stick and the winner runs in place.
// Zero them so the citizen animgraph falls back to idle.
SkinnedModelRenderer renderer = pc.Renderer;
if ( renderer.IsValid() )
{
renderer.Set( "move_groundspeed", 0f );
renderer.Set( "move_x", 0f );
renderer.Set( "move_y", 0f );
renderer.Set( "move_z", 0f );
renderer.Set( "move_direction", 0f );
renderer.Set( "b_grounded", true );
}
}
// Only the owning client actually moves the transform — it owns the player's authority.
[Rpc.Broadcast]
private void BroadcastTeleportWinner( GameObject winnerGameObject, Vector3 position )
{
if ( !winnerGameObject.IsValid() ) return;
// Lock input on every client so a held WASD / camera input can't carry the
// player off the podium during the one-frame gap before BroadcastFreezeWinner.
PlayerController pc = winnerGameObject.GetComponent<PlayerController>();
if ( pc != null )
{
pc.UseInputControls = false;
pc.UseCameraControls = false;
pc.WishVelocity = Vector3.Zero;
}
if ( !winnerGameObject.Network.IsOwner ) return;
winnerGameObject.WorldPosition = position;
}
private void SpawnConfettiLocally( Vector3 spawnPos, Vector3 initialVelocity )
{
if ( !ConfettiPrefab.IsValid() ) return;
// One clone per color so each burst contains a mix of colored particles. The clone's
// ParticleSphereEmitter is single-shot (Loop=false, DestroyOnEnd=true) so each cleans itself up.
foreach ( Color color in ConfettiColors )
{
GameObject clone = ConfettiPrefab.Clone( new CloneConfig
{
StartEnabled = true,
Transform = new Transform( spawnPos, Rotation.Identity )
} );
ParticleEffect effect = clone.GetComponent<ParticleEffect>();
if ( effect != null )
{
effect.Tint = color;
effect.InitialVelocity = initialVelocity;
}
}
if ( ConfettiSound == null ) return;
SoundHandle handle = Sound.Play( ConfettiSound, spawnPos );
if ( !handle.IsValid() ) return;
handle.Volume = 0.2f;
}
// Fanned out from the host so every client (including host) sets the same local results
// state and starts its own timer. Plain RPC instead of [Sync] because [Sync] props on this
// scene-level component don't propagate to non-host clients in this project's setup.
[Rpc.Broadcast]
private void BroadcastResultsBegin( GameObject winnerGameObject )
{
IsShowingResults = true;
Winner = winnerGameObject;
ResultsTimer = ResultsDuration;
// Fade out any in-scene music (the game track) so the victory cue can take over
// cleanly. Music components live on dedicated GameObjects in the scene.
foreach ( Music music in Scene.GetAllComponents<Music>() )
{
music.FadeOut();
}
if ( VictoryMusic != null )
{
_victoryMusicHandle = Sound.Play( VictoryMusic );
}
}
private void FinishGame()
{
if ( !Networking.IsHost ) return;
if ( SceneToLoadFinish is null )
{
Log.Error( "VictoryManager: SceneToLoadFinish is not assigned, can't return to lobby." );
return;
}
BroadcastLoadLobbyScene( SceneToLoadFinish.ResourcePath );
}
// Per-client Scene.Load to reload the lobby.
// Game.ChangeScene was tried first but wouldn't suppress the loading screen.
[Rpc.Broadcast]
private void BroadcastLoadLobbyScene( string resourcePath )
{
SceneLoadOptions loadOptions = new();
if ( !loadOptions.SetScene( resourcePath ) ) return;
loadOptions.ShowLoadingScreen = false;
Game.ActiveScene.Load( loadOptions );
}
[Rpc.Broadcast]
private void BroadcastRecordWin( string id )
{
Leaderboard.RecordWin( id );
if ( Connection.Local != null && id == Leaderboard.PlayerId( Connection.Local.SteamId ) )
{
PlayerStats.RecordWin();
}
foreach ( PlayerReadyState state in Scene.GetAllComponents<PlayerReadyState>() )
{
state.RefreshCrown();
}
}
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Breakout" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "breakout" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "facepunch" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "facepunch.breakout" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "27" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-08T09:04:56.5456514Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.111.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.111.0")]using System;
using Sandbox;
namespace Breakout;
/// <summary>
/// Owns purely-cosmetic game juice: the center banner, the damage flash, camera shake, brick
/// debris, wall-hit sparks, confetti and the board-clear sting. BreakoutGame delegates here and
/// re-exposes the banner/damage state for the HUD.
/// </summary>
[Title( "Game Feedback" ), Category( "Breakout" ), Icon( "auto_awesome" )]
public sealed class GameFeedback : Component
{
[Property] public GameObject DebrisPrefab { get; set; }
[Property] public GameObject WallHitPrefab { get; set; }
[Property] public GameObject ConfettiPrefab { get; set; }
[Property] public float LifeLostShake { get; set; } = 0.65f;
/// <summary>
/// Big center banner text, like "LEVEL 1" or "BOARD CLEAR!".
/// </summary>
public string BannerText { get; private set; }
/// <summary>
/// Smaller line shown beneath the banner (may be null).
/// </summary>
public string BannerSub { get; private set; }
/// <summary>
/// Ticks up each time a new banner is shown, so the HUD can restart its animation.
/// </summary>
public int BannerSeq { get; private set; }
/// <summary>
/// True while the banner should currently be visible.
/// </summary>
public bool BannerActive => !string.IsNullOrEmpty( BannerText ) && bannerSince < bannerHold;
/// <summary>
/// Ticks up each time the player takes damage, so the HUD can restart the red flash.
/// </summary>
public int DamageSeq { get; private set; }
/// <summary>
/// True while the red damage flash should currently be showing.
/// </summary>
public bool DamageActive => DamageSeq > 0 && damageSince < DamageFlashHold;
private RealTimeSince bannerSince;
private float bannerHold;
private RealTimeSince damageSince;
private const float DamageFlashHold = 0.05f;
/// <summary>
/// Shows the center banner with optional sub-text for hold seconds.
/// </summary>
public void ShowBanner( string text, string sub = null, float hold = 1.1f )
{
BannerText = text;
BannerSub = sub;
bannerSince = 0f;
bannerHold = hold;
BannerSeq++;
}
/// <summary>
/// Kicks off the red screen flash and a small camera shake when the player loses a life.
/// </summary>
public void TriggerDamageFeedback()
{
damageSince = 0f;
DamageSeq++;
ShakeCamera( LifeLostShake );
}
/// <summary>
/// Shakes the camera. Bigger amount means a stronger, longer shake.
/// </summary>
public void ShakeCamera( float amount )
{
if ( amount <= 0f )
return;
Scene.Camera.AddShake( amount * 20, amount * 10f, 0.5f );
}
/// <summary>
/// Spawns the shatter particles for a broken brick, tinted to match it.
/// </summary>
public void SpawnDebris( Block block )
{
if ( !block.IsValid() || DebrisPrefab is null )
return;
var go = DebrisPrefab.Clone( new CloneConfig( new Transform( block.WorldPosition ), startEnabled: false ) );
foreach ( var fx in go.Components.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
fx.Tint = block.HealthyTint;
go.Enabled = true;
}
/// <summary>
/// Spawns a spark burst facing inward where the ball hit a wall or the paddle.
/// </summary>
public void SpawnWallHit( Vector3 worldPos, Vector3 inwardNormal )
{
if ( WallHitPrefab is null )
return;
var n = inwardNormal.Normal;
var up = MathF.Abs( n.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
var rot = Rotation.LookAt( n, up );
WallHitPrefab.Clone( new CloneConfig( new Transform( worldPos, rot ), name: "wall_hit" ) );
}
/// <summary>
/// Spawns the celebratory confetti burst used on a board clear.
/// </summary>
public void SpawnConfetti( Vector3 worldPos ) => ConfettiPrefab?.Clone( worldPos );
/// <summary>
/// Plays the rising three-note chime used when a board is cleared.
/// </summary>
public void PlayClearSting()
{
float[] pitches = { 1.15f, 1.5f, 1.9f };
foreach ( var pitch in pitches )
{
var note = Sound.Play( "brick_break_01" );
if ( note is not null )
note.Pitch = pitch;
}
}
}
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Mini Motors" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "machines" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "facepunch" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "facepunch.machines" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-12T15:33:27.5390347Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.116.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.116.0")]using Machines.Race;
namespace Machines.Components;
/// <summary>
/// A single camera mode; <see cref="CameraManager"/> picks the highest-priority active one and blends.
/// </summary>
public abstract class CameraBehaviour : Component
{
/// <summary>
/// Whether this behaviour is applicable right now.
/// </summary>
public abstract bool WantsControl { get; }
/// <summary>
/// Tie-breaker when several behaviours want control. Higher wins.
/// </summary>
public virtual int Priority => 0;
/// <summary>
/// Seconds the director eases into this behaviour. 0 = instant.
/// </summary>
[Property, Group( "Blend" )]
public float BlendInTime { get; set; } = 0.5f;
/// <summary>
/// True while this behaviour is the one driving the camera.
/// </summary>
public bool IsActive { get; private set; }
internal void SetActive( bool active ) => IsActive = active;
/// <summary>
/// Called when this becomes active; seed smoothing from <paramref name="from"/> to avoid popping.
/// </summary>
public virtual void OnActivated( CameraPose from ) { }
/// <summary>
/// Compute this frame's desired pose; <paramref name="current"/> is the live applied pose.
/// </summary>
public abstract CameraPose Evaluate( CameraPose current );
/// <summary>
/// Angled top-down pose behind <paramref name="groundCenter"/> along <paramref name="heading"/>, pitched down to look at it.
/// </summary>
protected static CameraPose TopDown( Vector3 groundCenter, Vector3 heading, float pitch, float height, float fieldOfView )
{
var pitchRad = MathX.DegreeToRadian( pitch );
var horizontalOffset = height / MathF.Tan( pitchRad );
var position = groundCenter - heading * horizontalOffset + Vector3.Up * height;
var rotation = Rotation.LookAt( (groundCenter - position).Normal, Vector3.Up );
return new CameraPose( position, rotation, fieldOfView );
}
/// <summary>
/// Flat tangent of the racing line nearest <paramref name="point"/>, or <paramref name="fallback"/> if none.
/// </summary>
protected static Vector3 RacingLineTangent( Vector3 point, Vector3 fallback )
{
var line = RacingPath.Current?.Optimal;
if ( line is null || !line.IsValid )
return fallback;
var dist = line.GetDistanceAtPosition( point );
var tangent = line.GetTangentAtDistance( dist ).WithZ( 0f );
return tangent.LengthSquared > 0.001f ? tangent.Normal : fallback;
}
}
using Machines.GameModes;
using Machines.Player;
namespace Machines.Components;
/// <summary>
/// Spectator camera for late joiners
/// </summary>
public sealed class SpectatorCamera : CameraBehaviour
{
[Property, Group( "Framing" )]
public float PitchAngle { get; set; } = 55f;
[Property, Group( "Framing" )]
public float Height { get; set; } = 500f;
[Property, Group( "Framing" )]
public bool FixedAngle { get; set; } = false;
[Property, Group( "Framing" ), Range( 0f, 360f ), ShowIf( "FixedAngle", true )]
public float Angle { get; set; } = 90f;
[Property, Group( "Framing" )]
public float FieldOfView { get; set; } = 60f;
[Property, Group( "Smoothing" )]
public float LerpSpeed { get; set; } = 4f;
[Property, Group( "Smoothing" )]
public float HeadingLerpSpeed { get; set; } = 5f;
public override int Priority => 20;
/// <summary>
/// Display name of the player currently being spectated (used by the HUD).
/// </summary>
public string TargetName => _target.IsValid() ? _target.DisplayName : "";
private Car _target;
private int _targetIndex;
private Vector3 _currentPosition;
private Vector3 _currentHeading;
private bool _initialized;
public override bool WantsControl
{
get
{
var mode = BaseGameMode.Current;
if ( !mode.IsValid() || mode.State != GameModeState.Playing )
return false;
return !Car.Local.IsValid();
}
}
public override void OnActivated( CameraPose from )
{
_initialized = false;
_targetIndex = 0;
PickNextTarget( 0 );
}
public override CameraPose Evaluate( CameraPose current )
{
HandleTargetSwitching();
if ( !_target.IsValid() )
PickNextTarget( 0 );
if ( !_target.IsValid() )
return current;
return FollowTarget();
}
private void HandleTargetSwitching()
{
if ( Input.Pressed( "MenuRight" ) )
PickNextTarget( 1 );
else if ( Input.Pressed( "MenuLeft" ) )
PickNextTarget( -1 );
}
private void PickNextTarget( int direction )
{
var cars = GetSpectateTargets();
if ( cars.Count == 0 )
{
_target = null;
return;
}
_targetIndex = (_targetIndex + direction + cars.Count) % cars.Count;
_target = cars[_targetIndex];
}
/// <summary>
/// All valid cars to spectate, sorted by slot index for stable ordering.
/// </summary>
private List<Car> GetSpectateTargets()
{
var cars = new List<Car>();
foreach ( var car in Scene.GetAllComponents<Car>() )
{
if ( car.IsValid() && car.Slot >= 0 )
cars.Add( car );
}
cars.Sort( ( a, b ) => a.Slot.CompareTo( b.Slot ) );
return cars;
}
private CameraPose FollowTarget()
{
var targetPos = _target.WorldPosition;
Vector3 heading;
if ( FixedAngle )
{
var rad = MathX.DegreeToRadian( Angle );
heading = new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
}
else
{
heading = RacingLineTangent( targetPos, _target.WorldRotation.Forward.WithZ( 0f ) );
}
if ( !_initialized )
{
_currentPosition = targetPos;
_currentHeading = heading;
_initialized = true;
}
var dt = Time.Delta;
_currentPosition = Vector3.Lerp( _currentPosition, targetPos, dt * LerpSpeed );
if ( FixedAngle )
_currentHeading = heading;
else
_currentHeading = Vector3.Lerp( _currentHeading, heading, dt * HeadingLerpSpeed ).Normal;
if ( _currentHeading.LengthSquared < 0.001f )
_currentHeading = Vector3.Forward;
return TopDown( _currentPosition, _currentHeading, PitchAngle, Height, FieldOfView );
}
}
namespace Machines.Systems;
/// <summary>A music track: the sound plus display metadata.</summary>
public struct MusicTrack
{
[Property] public SoundEvent Sound { get; set; }
[Property] public string Title { get; set; }
[Property] public string Artist { get; set; }
}
namespace Machines.Race;
/// <summary>
/// Stateless post-processing helpers for baked racing lines used by <see cref="RacingPath"/>.
/// </summary>
public static class RacingLineSmoothing
{
/// <summary>
/// Chaikin corner-cutting: subdivide at 25%/75% per iteration, propagating bridge flags.
/// </summary>
public static List<Vector3> Smooth( List<Vector3> points, ref List<bool> bridgeFlags, int iterations )
{
if ( points.Count < 3 )
return points;
var result = new List<Vector3>( points );
var flags = new List<bool>( bridgeFlags );
for ( int iter = 0; iter < iterations; iter++ )
{
var smoothed = new List<Vector3>( result.Count * 2 );
var smoothedFlags = new List<bool>( result.Count * 2 );
for ( int i = 0; i < result.Count; i++ )
{
var curr = result[i];
var next = result[(i + 1) % result.Count];
var isBridge = flags[i] || flags[(i + 1) % result.Count];
// 25% and 75% cut points
smoothed.Add( Vector3.Lerp( curr, next, 0.25f ) );
smoothedFlags.Add( isBridge );
smoothed.Add( Vector3.Lerp( curr, next, 0.75f ) );
smoothedFlags.Add( isBridge );
}
result = smoothed;
flags = smoothedFlags;
}
bridgeFlags = flags;
return result;
}
/// <summary>
/// Build a laterally offset line, snapping to navmesh (bridge points excepted).
/// </summary>
public static RacingLine BuildOffset( Scene scene, RacingLine optimal, IReadOnlyList<bool> isBridgePoint, float offset, bool useNavMesh = true )
{
var points = optimal.Points;
var offsetPoints = new List<Vector3>( points.Count );
for ( int i = 0; i < points.Count; i++ )
{
var prev = points[(i - 1 + points.Count) % points.Count];
var curr = points[i];
var next = points[(i + 1) % points.Count];
// Average incoming/outgoing direction
var dir = ((curr - prev).Normal + (next - curr).Normal).Normal;
// Right-side perpendicular
var perp = Vector3.Cross( dir, Vector3.Up ).Normal;
var offsetPos = curr + perp * offset;
// Bridge points have no navmesh to snap to; offset geometrically only.
if ( !useNavMesh || (i < isBridgePoint.Count && isBridgePoint[i]) )
{
offsetPoints.Add( offsetPos );
continue;
}
// Snap to navmesh
var snapped = scene.NavMesh.GetClosestPoint( offsetPos );
if ( snapped.HasValue )
{
// Snap moved too far (>50% of offset): likely clipped a wall, use reduced offset
var snapDist = (snapped.Value - offsetPos).Length;
if ( snapDist > MathF.Abs( offset ) * 0.5f )
{
// 30% offset fallback
var reducedPos = curr + perp * (offset * 0.3f);
var reducedSnap = scene.NavMesh.GetClosestPoint( reducedPos );
offsetPoints.Add( reducedSnap ?? curr );
}
else
{
offsetPoints.Add( snapped.Value );
}
}
else
{
// No navmesh hit, fall back to optimal line point
offsetPoints.Add( curr );
}
}
var line = new RacingLine { Points = offsetPoints };
line.RebuildDistances();
return line;
}
/// <summary>
/// Convert index-based sections to distance-based <see cref="PathSegmentInfo"/> using baked cumulative distances.
/// </summary>
public static List<PathSegmentInfo> BuildDistanceSegments( RacingLine line, IReadOnlyList<SplinePathBaker.Section> indexed )
{
var result = new List<PathSegmentInfo>( indexed.Count );
foreach ( var seg in indexed )
{
if ( seg.StartIndex >= line.CumulativeDistances.Count || seg.EndIndex <= seg.StartIndex )
continue;
var startDist = line.CumulativeDistances[Math.Min( seg.StartIndex, line.CumulativeDistances.Count - 1 )];
// EndIndex is exclusive; use EndIndex-1 for the last point's distance.
var endIdx = Math.Min( seg.EndIndex - 1, line.CumulativeDistances.Count - 1 );
var endDist = line.CumulativeDistances[endIdx];
// Last point on a loop: extend end to TotalLength.
if ( seg.EndIndex >= line.Points.Count )
endDist = line.TotalLength;
result.Add( new PathSegmentInfo
{
StartDistance = startDist,
EndDistance = endDist,
Type = seg.Type,
IsGap = seg.IsGap,
SourceLabel = seg.Label
} );
}
return result;
}
}
namespace Machines.Race;
/// <summary>
/// Debug gizmo drawing for <see cref="RacingPath"/>, toggled via <c>race_debug_*</c> convars.
/// </summary>
public sealed partial class RacingPath
{
[ConVar( "race_debug_paths" )]
public static bool DebugPaths { get; set; } = false;
[ConVar( "race_debug_segments" )]
public static bool DebugSegments { get; set; } = false;
[ConVar( "race_debug_hints" )]
public static bool DebugHints { get; set; } = false;
[ConVar( "race_debug_cameras" )]
public static bool DebugCameras { get; set; } = false;
[ConVar( "race_debug_shortcuts" )]
public static bool DebugShortcuts { get; set; } = false;
protected override void DrawGizmos()
{
if ( !DebugPaths && !DebugSegments && !DebugHints && !DebugCameras && !DebugShortcuts )
return;
Gizmo.Transform = global::Transform.Zero;
if ( DebugPaths )
{
DrawLine( Optimal, Color.Green );
DrawLine( Left, new Color( 0.3f, 0.6f, 1f ) );
DrawLine( Right, new Color( 1f, 0.4f, 0.2f ) );
}
if ( DebugSegments )
{
DrawSegments();
}
if ( DebugHints )
{
DrawHints();
}
if ( DebugCameras )
{
DrawCameraSpots();
}
if ( DebugShortcuts )
{
DrawShortcuts();
}
}
/// <summary>
/// Draw the optimal line color-coded by segment type, with boundary labels.
/// </summary>
private void DrawSegments()
{
if ( Optimal == null || !Optimal.IsValid || Optimal.Segments.Count == 0 )
return;
const int subdivisions = 6;
const float labelHeight = 40f;
const float dashLength = 30f;
for ( int i = 0; i < Optimal.Points.Count; i++ )
{
var dist = i < Optimal.CumulativeDistances.Count ? Optimal.CumulativeDistances[i] : 0f;
var seg = Optimal.GetSegmentAtDistance( dist );
var color = GetSegmentColor( seg );
Gizmo.Draw.Color = color;
Gizmo.Draw.LineThickness = seg?.IsGap == true ? 1f : 2f;
var prev = TrackSpline.GetPoint( Optimal.Points, i, 0f );
for ( int s = 1; s <= subdivisions; s++ )
{
var t = s / (float)subdivisions;
var curr = TrackSpline.GetPoint( Optimal.Points, i, t );
// For gaps, draw dashed: skip every other sub-segment
if ( seg?.IsGap == true )
{
var subDist = dist + (Optimal.CumulativeDistances[Math.Min( i + 1, Optimal.CumulativeDistances.Count - 1 )] - dist) * t;
var phase = (subDist % dashLength) / dashLength;
if ( phase < 0.5f )
Gizmo.Draw.Line( prev, curr );
}
else
{
Gizmo.Draw.Line( prev, curr );
}
prev = curr;
}
}
// Labels at segment boundaries
foreach ( var seg in Optimal.Segments )
{
var pos = Optimal.GetPointAtDistance( seg.StartDistance );
var label = seg.IsGap ? "GAP" : $"{seg.Type}";
if ( !string.IsNullOrEmpty( seg.SourceLabel ) && !seg.IsGap )
label += $" ({seg.SourceLabel})";
Gizmo.Draw.Color = GetSegmentColor( seg );
Gizmo.Draw.Text( label, new Transform( pos + Vector3.Up * labelHeight ) );
// Vertical marker at boundary
Gizmo.Draw.Line( pos, pos + Vector3.Up * labelHeight * 0.8f );
}
}
private static Color GetSegmentColor( PathSegmentInfo seg )
{
if ( seg == null )
return Color.Gray;
if ( seg.IsGap )
return new Color( 1f, 0.2f, 0.2f ); // Red
return seg.Type switch
{
SplineType.Road => new Color( 0.2f, 0.9f, 0.3f ), // Green
SplineType.Bridge => new Color( 0.2f, 0.85f, 0.9f ), // Cyan
SplineType.OffRoad => new Color( 0.9f, 0.75f, 0.2f ), // Yellow
SplineType.Tunnel => new Color( 0.7f, 0.3f, 0.9f ), // Purple
_ => Color.White
};
}
private void DrawLine( RacingLine line, Color color )
{
if ( line == null || !line.IsValid )
return;
Gizmo.Draw.Color = color;
const int subdivisions = 6;
for ( int i = 0; i < line.Points.Count; i++ )
{
var prev = TrackSpline.GetPoint( line.Points, i, 0f );
for ( int s = 1; s <= subdivisions; s++ )
{
var t = s / (float)subdivisions;
var curr = TrackSpline.GetPoint( line.Points, i, t );
Gizmo.Draw.Line( prev, curr );
prev = curr;
}
// Direction arrow every 10 waypoints
if ( i % 10 == 0 )
{
var pos = line.Points[i];
var nextIdx = (i + 1) % line.Points.Count;
var dir = (line.Points[nextIdx] - pos).Normal;
var arrowEnd = pos + dir * 20f;
Gizmo.Draw.Arrow( pos + Vector3.Up * 10f, arrowEnd + Vector3.Up * 10f, 4f, 2f );
}
}
}
/// <summary>
/// Draw path hints as color-coded vertical bars (green=grip, yellow=reduced, red=low/airborne).
/// </summary>
private void DrawHints()
{
if ( Optimal == null || !Optimal.IsValid || Optimal.Hints.Count == 0 )
return;
const float barHeight = 30f;
var step = RacingLine.CurvatureStep;
for ( int i = 0; i < Optimal.Hints.Count; i++ )
{
var hint = Optimal.Hints[i];
var d = i * step;
var pos = Optimal.GetPointAtDistance( d );
// Friction: green (1.0) -> yellow (0.5) -> red (0.0)
var color = hint.Friction > 0.7f
? Color.Lerp( new Color( 1f, 1f, 0f ), new Color( 0.2f, 1f, 0.3f ), (hint.Friction - 0.7f) / 0.3f )
: Color.Lerp( new Color( 1f, 0.1f, 0.1f ), new Color( 1f, 1f, 0f ), hint.Friction / 0.7f );
if ( hint.Flags.HasFlag( PathHintFlags.Airborne ) )
color = new Color( 0.5f, 0.5f, 1f ); // Blue for airborne
if ( hint.Flags.HasFlag( PathHintFlags.Corner ) )
color = new Color( 1f, 0.6f, 0.1f ); // Orange for corners
Gizmo.Draw.Color = color;
// Bar height scales with friction
var height = barHeight * MathF.Max( 0.2f, hint.Friction );
Gizmo.Draw.Line( pos, pos + Vector3.Up * height );
// Flag labels at notable points (skip every-sample to reduce noise)
if ( hint.Flags != PathHintFlags.None && i % 3 == 0 )
{
var label = "";
if ( hint.Flags.HasFlag( PathHintFlags.LowGrip ) ) label += "LOW ";
if ( hint.Flags.HasFlag( PathHintFlags.Airborne ) ) label += "AIR ";
if ( hint.Flags.HasFlag( PathHintFlags.Narrow ) ) label += "NAR ";
if ( hint.Flags.HasFlag( PathHintFlags.SlowZone ) ) label += "SLOW ";
Gizmo.Draw.Text( label.Trim(), new Transform( pos + Vector3.Up * (barHeight + 10f) ) );
}
}
}
/// <summary>
/// Draw baked camera spots as spheres with lines to the track point they watch.
/// </summary>
private void DrawCameraSpots()
{
if ( CameraSpots.Count == 0 )
return;
for ( int i = 0; i < CameraSpots.Count; i++ )
{
var spot = CameraSpots[i];
var trackPoint = Optimal.GetPointAtDistance( spot.Distance );
// Camera sphere
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.1f ); // Gold
Gizmo.Draw.LineSphere( spot.Position, 8f );
// Line to track point
Gizmo.Draw.Color = new Color( 1f, 0.85f, 0.1f, 0.4f );
Gizmo.Draw.Line( spot.Position, trackPoint );
// Label
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.Text( $"CAM {i}", new Transform( spot.Position + Vector3.Up * 12f ) );
}
}
/// <summary>
/// Draw baked shortcut paths with entry/exit markers.
/// </summary>
private void DrawShortcuts()
{
if ( Shortcuts.Count == 0 || Optimal == null || !Optimal.IsValid )
return;
for ( int s = 0; s < Shortcuts.Count; s++ )
{
var shortcut = Shortcuts[s];
var color = new Color( 1f, 0.2f, 1f ); // Magenta
// Shortcut path
Gizmo.Draw.Color = color;
Gizmo.Draw.LineThickness = 3f;
for ( int i = 0; i < shortcut.Points.Count - 1; i++ )
Gizmo.Draw.Line( shortcut.Points[i], shortcut.Points[i + 1] );
// Waypoint spheres
Gizmo.Draw.Color = color.WithAlpha( 0.6f );
foreach ( var pt in shortcut.Points )
Gizmo.Draw.LineSphere( pt, 4f );
// Entry marker
var entryPoint = Optimal.GetPointAtDistance( shortcut.EntryDistance );
Gizmo.Draw.Color = new Color( 0.2f, 1f, 0.2f );
Gizmo.Draw.LineSphere( shortcut.Points[0], 10f );
Gizmo.Draw.Line( shortcut.Points[0], entryPoint );
Gizmo.Draw.Text( $"SC{s} ENTRY", new Transform( shortcut.Points[0] + Vector3.Up * 20f ) );
// Exit marker
var exitPoint = Optimal.GetPointAtDistance( shortcut.ExitDistance );
Gizmo.Draw.Color = new Color( 1f, 0.3f, 0.2f );
Gizmo.Draw.LineSphere( shortcut.Points[^1], 10f );
Gizmo.Draw.Line( shortcut.Points[^1], exitPoint );
Gizmo.Draw.Text( $"SC{s} EXIT", new Transform( shortcut.Points[^1] + Vector3.Up * 20f ) );
// Direction arrows
Gizmo.Draw.Color = color;
for ( int i = 0; i < shortcut.Points.Count - 1; i += Math.Max( 1, shortcut.Points.Count / 5 ) )
{
var from = shortcut.Points[i];
var to = shortcut.Points[Math.Min( i + 1, shortcut.Points.Count - 1 )];
var dir = (to - from).Normal;
var mid = (from + to) * 0.5f;
Gizmo.Draw.Line( mid, mid + dir * 15f );
}
}
}
}
using Sandbox;
namespace BrickJam;
public sealed partial class MansionGame
{
/// <summary>How fast the music fades in/out (per second). Legacy faded on the server tick.</summary>
public float MusicVolumeChangeRate => 0.5f;
/// <summary>Target music volume - background level, the tracks are mastered loud.</summary>
public float MusicVolume => 0.15f;
private SoundHandle musicHandle;
private LevelType musicLevel = LevelType.None;
private float musicVolume;
/// <summary>
/// Client-side music orchestration (every client, host included - music is local audio). Scene-System
/// port of the legacy host-side <c>ProcessMusic</c>: drive the track from the replicated
/// <see cref="CurrentLevelType"/> and crossfade when the level changes.
/// </summary>
protected override void OnUpdate()
{
var track = Level.GetMusic( CurrentLevelType );
if ( CurrentLevelType != musicLevel )
{
// Level changed: fade the old track out, then swap once it's silent.
musicVolume = musicVolume.LerpTo( 0f, MusicVolumeChangeRate * Time.Delta );
if ( musicVolume <= 0.01f )
{
musicHandle?.Stop();
musicHandle = null;
musicLevel = CurrentLevelType;
musicVolume = 0f;
}
ApplyMusicVolume();
return;
}
// Same level: keep the track playing (restart if the asset isn't looped) and fade toward target.
if ( !string.IsNullOrEmpty( track ) )
{
if ( musicHandle is null || musicHandle.IsStopped )
{
musicHandle = Sound.Play( track );
if ( musicHandle is not null )
musicHandle.Volume = musicVolume; // start at the current (faded) level, not full blast
}
musicVolume = musicVolume.LerpTo( MusicVolume, MusicVolumeChangeRate * Time.Delta );
}
else
{
musicVolume = musicVolume.LerpTo( 0f, MusicVolumeChangeRate * Time.Delta );
if ( musicVolume <= 0.01f && musicHandle is not null )
{
musicHandle.Stop();
musicHandle = null;
}
}
ApplyMusicVolume();
}
private void ApplyMusicVolume()
{
if ( musicHandle is not null && !musicHandle.IsStopped )
musicHandle.Volume = musicVolume;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace GridAStar;
public struct AStarPathBuilder
{
public Grid Grid { get; private set; } = null;
public List<string> TagsToExclude { get; private set; } = new() { "occupied" };
public bool HasTagsToExlude => TagsToExclude.Count() > 0;
public bool HasOccupiedTagToExclude => HasTagsToExlude ? TagsToExclude.Contains( "occupied" ) : false;
public List<string> TagsToInclude { get; private set; } = new();
public bool HasTagsToInclude => TagsToInclude.Count() > 0;
public Dictionary<string, float> TagsToAvoid { get; private set; } = new();
public bool HasTagsToAvoid => TagsToAvoid.Count() > 0;
public bool AcceptsPartial { get; private set; } = false;
public float MaxCheckDistance { get; private set; } = float.PositiveInfinity;
public float MaxDropHeight { get; private set; } = GridSettings.DEFAULT_DROP_HEIGHT;
public Component PathCreator { get; private set; } = null;
public bool HasPathCreator => PathCreator != null;
public AStarPathBuilder() { }
public AStarPathBuilder( Grid grid ) : this()
{
Grid = grid;
}
public static AStarPathBuilder From( Grid grid ) => new AStarPathBuilder( grid );
public AStarPathBuilder WithTags( params string[] tags )
{
foreach ( var tag in tags )
{
if ( !TagsToInclude.Contains( tag ) )
TagsToInclude.Add( tag );
if ( TagsToExclude.Contains( tag ) )
TagsToExclude.Remove( tag );
}
return this;
}
public AStarPathBuilder WithoutTags( params string[] tags )
{
foreach ( var tag in tags )
{
if ( !TagsToExclude.Contains( tag ) )
TagsToExclude.Add( tag );
if ( TagsToInclude.Contains( tag ) )
TagsToInclude.Remove( tag );
}
return this;
}
/// <summary>
/// Which tags to avoid, when found it will add the malus to its total cost.
/// </summary>
public AStarPathBuilder AvoidTag( string tag, float malus )
{
malus = Math.Abs( malus );
if ( !TagsToAvoid.ContainsKey( tag ) )
TagsToAvoid.Add( tag, malus );
else
TagsToAvoid[tag] = malus;
return this;
}
public AStarPathBuilder WithMaxDistance( float maxDistance )
{
MaxCheckDistance = Math.Max( 0f, maxDistance );
return this;
}
public AStarPathBuilder WithMaxDropHeight( float maxDropHeight )
{
MaxDropHeight = Math.Min( Grid.MaxDropHeight, maxDropHeight );
return this;
}
public AStarPathBuilder WithPartialEnabled()
{
AcceptsPartial = true;
return this;
}
public AStarPathBuilder WithPathCreator( Component pathCreator )
{
PathCreator = pathCreator;
return this;
}
public AStarPath Run( Cell startingCell, Cell targetCell, bool reversed = false, bool withCellConnections = true )
{
if ( startingCell is null || targetCell is null || startingCell == targetCell ) return AStarPath.Empty();
return AStarPath.From( this, Grid.ComputePathInternal( this, startingCell, targetCell, CancellationToken.None, reversed, withCellConnections ) );
}
public AStarPath Run( Vector3 startingPosition, Cell targetCell, bool reversed = false, bool withCellConnections = true ) => Run( Grid.GetCell( startingPosition ), targetCell, reversed, withCellConnections );
public AStarPath Run( Cell startingCell, Vector3 targetPosition, bool reversed = false, bool withCellConnections = true ) => Run( startingCell, Grid.GetCell( targetPosition ), reversed, withCellConnections );
public AStarPath Run( Vector3 startingPosition, Vector3 targetPosition, bool reversed = false, bool withCellConnections = true ) => Run( Grid.GetCell( startingPosition ), Grid.GetCell( targetPosition ), reversed, withCellConnections );
internal AStarPath Run( Cell startingCell, Cell targetCell, CancellationToken token, bool reversed = false, bool withCellConnections = true )
{
if ( startingCell is null || targetCell is null || startingCell == targetCell ) return AStarPath.Empty();
return AStarPath.From( this, Grid.ComputePathInternal( this, startingCell, targetCell, token, reversed, withCellConnections ) );
}
public async Task<AStarPath> RunAsync( Cell startingCell, Cell targetCell, CancellationToken token, bool reversed = false, bool withCellConnections = true )
{
var builder = this;
return await GameTask.RunInThreadAsync( () => builder.Run( startingCell, targetCell, token, reversed, withCellConnections ) );
}
public async Task<AStarPath> RunAsync( Vector3 startingPosition, Cell targetCell, CancellationToken token, bool reversed = false, bool withCellConnections = true ) => await RunAsync( Grid.GetCell( startingPosition ), targetCell, token, reversed, withCellConnections );
public async Task<AStarPath> RunAsync( Cell startingCell, Vector3 targetPosition, CancellationToken token, bool reversed = false, bool withCellConnections = true ) => await RunAsync( startingCell, Grid.GetCell( targetPosition ), token, reversed, withCellConnections );
public async Task<AStarPath> RunAsync( Vector3 startingPosition, Vector3 targetPosition, CancellationToken token, bool reversed = false, bool withCellConnections = true ) => await RunAsync( Grid.GetCell( startingPosition ), Grid.GetCell( targetPosition ), token, reversed, withCellConnections );
public async Task<AStarPath> RunInParallel( Cell startingCell, Cell targetCell, CancellationTokenSource tokenSource, bool withCellConnections = true )
{
if ( startingCell is null || targetCell is null || startingCell == targetCell ) return AStarPath.Empty();
var fromTo = RunAsync( startingCell, targetCell, tokenSource.Token, false, withCellConnections );
var toFrom = RunAsync( targetCell, startingCell, tokenSource.Token, true, false ); // You can't reverse some cell connections, like dropping down
var pathResult = await GameTask.WhenAny( fromTo, toFrom ).Result;
// Cancel the other task that hasn't finished yet.
tokenSource.Cancel();
return pathResult;
}
public async Task<AStarPath> RunInParallel( Vector3 startingPosition, Cell targetCell, CancellationTokenSource tokenSource, bool withCellConnections = true ) => await RunInParallel( Grid.GetCell( startingPosition ), targetCell, tokenSource, withCellConnections );
public async Task<AStarPath> RunInParallel( Cell startingCell, Vector3 targetPosition, CancellationTokenSource tokenSource, bool withCellConnections = true ) => await RunInParallel( startingCell, Grid.GetCell( targetPosition ), tokenSource, withCellConnections );
public async Task<AStarPath> RunInParallel( Vector3 startingPosition, Vector3 targetPosition, CancellationTokenSource tokenSource, bool withCellConnections = true ) => await RunInParallel( Grid.GetCell( startingPosition ), Grid.GetCell( targetPosition ), tokenSource, withCellConnections );
}
using System;
using Sandbox;
namespace GridAStar;
/// <summary>
/// Like a Vector2, but with integers instead.
/// </summary>
public struct IntVector2 : IEquatable<IntVector2>
{
public int x { get; set; }
public int y { get; set; }
public int this[int index]
{
get
{
int result = index switch
{
0 => x,
1 => y,
_ => throw new IndexOutOfRangeException(),
};
return result;
}
set
{
switch ( index )
{
case 0:
x = value;
break;
case 1:
y = value;
break;
}
}
}
public IntVector2( int x, int y )
{
this.x = x;
this.y = y;
}
public IntVector2 WithX( int x ) => new IntVector2( x, this.y );
public IntVector2 WithY( int y ) => new IntVector2( this.x, y );
public Vector2 ToVector2() => new Vector2( x, y );
public float DistanceSquared( IntVector2 other ) => ToVector2().DistanceSquared( other.ToVector2() );
public override string ToString() => $"{x},{y}";
public override bool Equals( object obj ) => obj is IntVector2 other && Equals( other );
public bool Equals( IntVector2 other ) => x == other.x && y == other.y;
public override int GetHashCode() => HashCode.Combine( x, y );
public static bool operator ==( IntVector2 left, IntVector2 right ) => left.Equals( right );
public static bool operator !=( IntVector2 left, IntVector2 right ) => !(left == right);
public static IntVector2 operator +( IntVector2 a, IntVector2 b ) => new IntVector2( a.x + b.x, a.y + b.y );
public static IntVector2 operator -( IntVector2 a, IntVector2 b ) => new IntVector2( a.x - b.x, a.y - b.y );
}