Editor-side handlers that generate and write C# game components for camera shake, light flicker, and floating combat text, and optionally place them onto scene GameObjects. Also includes a helper to attach the generated types to scene objects.
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
// =============================================================================
// Game Feel pack (v1.19.0) -- three "juice" scaffolds (code-gen; scene-mutating):
//
// create_camera_shake trauma-based Perlin camera shake component
// add_flicker_light flicker/pulse animator for an existing light
// create_floating_combat_text rising/fading world-space damage popups
//
// Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
// so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
// SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
// WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code.
//
// The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code and must
// obey the s&box sandbox rules:
// - MathX preferred; System.Math/MathF also compile on the current SDK.
// Array.Clone() is still whitelist-blocked (not used here).
// - only sandbox-proven APIs: Component, [Property], List<T>, TimeSince,
// Game.Random.Float (compile-verified in create_weighted_loot_table),
// Sandbox.Utility.Noise.Perlin (fully qualified to dodge a using),
// new GameObject(...) for runtime spawns.
// - all three generated components are LOCAL/visual-only -- no [Sync], no
// RPCs. Multiplayer note lands in the nextSteps (wrap the calls in an
// [Rpc.Broadcast] so every client sees the juice).
//
// Register(...) lines + the _sceneMutatingCommands additions live in
// MyEditorMenu.cs (Batch 44) to keep the files decoupled.
// =============================================================================
// -----------------------------------------------------------------------------
// create_camera_shake -- trauma-based camera shake (the corpus-standard model:
// shake magnitude = Trauma^2, Perlin-driven offsets, decays over time).
//
// Applied in OnPreRender AFTER controllers have positioned the camera. The
// un-apply guard (compare against what we last WROTE) makes it correct on both
// a static camera (no accumulation) and a controller-driven one (no fighting).
// -----------------------------------------------------------------------------
public class CreateCameraShakeHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "CameraShake", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float maxOffset = p.TryGetProperty( "maxOffset", out var ov ) && ov.TryGetSingle( out var of ) ? of : 6f;
float maxAngle = p.TryGetProperty( "maxAngle", out var av ) && av.TryGetSingle( out var af ) ? af : 4f;
float frequency = p.TryGetProperty( "frequency", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 10f;
float decay = p.TryGetProperty( "decayPerSecond", out var dv ) && dv.TryGetSingle( out var df ) ? df : 1.5f;
// Defensive clamps so a silly value can't emit a nauseating component.
if ( maxOffset < 0f ) maxOffset = 0f;
if ( maxAngle < 0f ) maxAngle = 0f;
if ( frequency < 0.1f ) frequency = 0.1f;
if ( decay < 0.05f ) decay = 0.05f;
var code = BuildCode( className, maxOffset, maxAngle, frequency, decay, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
placedOn = GameFeelHelpers.PlaceOnTarget( tid.GetString(), className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
maxOffset,
maxAngle,
frequency,
decayPerSecond = decay,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to the CAMERA GameObject: add_component_with_properties (component=\"{className}\") after the hotload, or re-run with targetId.",
$"Fire a shake from any game code: {className}.Shake( 0.4f ) -- explosions ~0.6-1.0, hits ~0.2-0.4, footsteps ~0.05. Trauma stacks and clamps at 1.",
"LOCAL-only: call it inside an [Rpc.Broadcast] handler if every client should feel the shake.",
"Tune MaxOffset / MaxAngle / Frequency / DecayPerSecond with set_property, then verify in play mode: playtest with a capture step, or set_runtime_property Trauma=1 and take_screenshot."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_camera_shake failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float maxOffset, float maxAngle, float frequency, float decay, System.Globalization.CultureInfo ci )
{
string mo = maxOffset.ToString( ci ) + "f";
string ma = maxAngle.ToString( ci ) + "f";
string fq = frequency.ToString( ci ) + "f";
string dc = decay.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- trauma-based camera shake. Attach to the camera GameObject.
///
/// The standard game-feel model: an event adds Trauma (0..1), shake magnitude
/// is Trauma^2 (small hits barely register, big hits slam), offsets are smooth
/// Perlin noise (not white-noise jitter), and Trauma decays every frame.
///
/// Usage from anywhere: {className}.Shake( 0.5f );
/// LOCAL-only -- wrap the call in an [Rpc.Broadcast] if all clients should shake.
/// </summary>
public sealed class {className} : Component
{{
/// Current shake energy, 0..1. Add via Shake(); decays by DecayPerSecond.
[Property] public float Trauma {{ get; set; }}
/// Positional shake at full trauma, in world units.
[Property] public float MaxOffset {{ get; set; }} = {mo};
/// Rotational shake at full trauma, in degrees (pitch/yaw/roll).
[Property] public float MaxAngle {{ get; set; }} = {ma};
/// Noise speed -- higher = more violent rattle, lower = drunken sway.
[Property] public float Frequency {{ get; set; }} = {fq};
/// How much trauma drains per second.
[Property] public float DecayPerSecond {{ get; set; }} = {dc};
private static readonly List<{className}> _active = new List<{className}>();
private Vector3 _lastWrittenPos;
private Rotation _lastWrittenRot;
private Vector3 _appliedOffset;
private Rotation _appliedRot = Rotation.Identity;
private bool _hasApplied;
/// <summary>Add trauma to every active {className} (usually the one on the local camera).</summary>
public static void Shake( float trauma )
{{
foreach ( var s in _active )
s.Trauma = MathX.Clamp( s.Trauma + trauma, 0f, 1f );
}}
protected override void OnEnabled()
{{
_active.Add( this );
}}
protected override void OnDisabled()
{{
_active.Remove( this );
RemoveAppliedShake();
}}
protected override void OnPreRender()
{{
var go = GameObject;
// Recover the unshaken base. If a controller re-wrote the camera since our
// last write, ITS value is the new base and our old offset is already gone --
// only un-apply when the transform still equals exactly what we wrote.
var basePos = go.WorldPosition;
var baseRot = go.WorldRotation;
if ( _hasApplied && basePos == _lastWrittenPos ) basePos -= _appliedOffset;
if ( _hasApplied && baseRot == _lastWrittenRot ) baseRot = baseRot * _appliedRot.Inverse;
_hasApplied = false;
Trauma = MathX.Clamp( Trauma - DecayPerSecond * Time.Delta, 0f, 1f );
float shake = Trauma * Trauma;
if ( shake < 0.0005f )
{{
go.WorldPosition = basePos;
go.WorldRotation = baseRot;
return;
}}
// Smooth signed noise per axis (-1..1), decorrelated by row offset.
float t = Time.Now * Frequency;
float N( float row ) => (Sandbox.Utility.Noise.Perlin( t, row ) - 0.5f) * 2f;
_appliedOffset = new Vector3( N( 0f ), N( 17f ), N( 31f ) ) * (MaxOffset * shake);
_appliedRot = Rotation.From( N( 47f ) * MaxAngle * shake, N( 61f ) * MaxAngle * shake, N( 83f ) * MaxAngle * shake );
go.WorldPosition = basePos + _appliedOffset;
go.WorldRotation = baseRot * _appliedRot;
_lastWrittenPos = go.WorldPosition;
_lastWrittenRot = go.WorldRotation;
_hasApplied = true;
}}
private void RemoveAppliedShake()
{{
if ( !_hasApplied ) return;
var go = GameObject;
if ( go.WorldPosition == _lastWrittenPos ) go.WorldPosition -= _appliedOffset;
if ( go.WorldRotation == _lastWrittenRot ) go.WorldRotation = go.WorldRotation * _appliedRot.Inverse;
_hasApplied = false;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// add_flicker_light -- generate a light-flicker animator and (optionally) attach
// it to an existing light GameObject. Presets: Candle, Fluorescent, Faulty,
// Pulse, Lightning. Modulates Light.LightColor around a captured base color;
// restores the base on disable.
// -----------------------------------------------------------------------------
public class AddFlickerLightHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "FlickerLight", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
var style = p.TryGetProperty( "style", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )
? sv.GetString() : "Candle";
// Validate against the generated enum so a typo can't emit uncompilable code.
var validStyles = new[] { "Candle", "Fluorescent", "Faulty", "Pulse", "Lightning" };
var matched = validStyles.FirstOrDefault( s => s.Equals( style, StringComparison.OrdinalIgnoreCase ) );
if ( matched == null )
return Task.FromResult<object>( new { error = $"Unknown style '{style}'. Valid: {string.Join( ", ", validStyles )}" } );
style = matched;
float intensity = p.TryGetProperty( "intensity", out var iv ) && iv.TryGetSingle( out var iff ) ? iff : 0.5f;
float speed = p.TryGetProperty( "speed", out var spv ) && spv.TryGetSingle( out var spf ) ? spf : 1f;
intensity = intensity < 0f ? 0f : intensity > 1f ? 1f : intensity;
if ( speed < 0.05f ) speed = 0.05f;
var code = BuildCode( className, style, intensity, speed, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
object placedOn = null; string note = null;
// `lightId` is the ergonomic param name; `targetId` also accepted (sibling convention).
string target = null;
if ( p.TryGetProperty( "lightId", out var lid ) && lid.ValueKind == JsonValueKind.String ) target = lid.GetString();
else if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String ) target = tid.GetString();
if ( target != null )
placedOn = GameFeelHelpers.PlaceOnTarget( target, className, out note );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
style,
intensity,
speed,
placedOn,
note,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
placedOn != null
? $"{className} was attached to the target GameObject."
: $"Attach it to a GameObject that has a light component (PointLight / SpotLight / DirectionalLight): add_component_with_properties (component=\"{className}\") after the hotload, or re-run with lightId.",
"The animator modulates the light's LightColor around its starting color and restores it on disable -- tune Style / Intensity / Speed with set_property.",
"Verify in play mode: start_play, then take_screenshot twice ~a second apart and compare the light's brightness (or capture_view for a scene-only frame)."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"add_flicker_light failed: {ex.Message}" } );
}
}
static string BuildCode( string className, string style, float intensity, float speed, System.Globalization.CultureInfo ci )
{
string it = intensity.ToString( ci ) + "f";
string sp = speed.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- flickers the light on this GameObject. Attach next to a
/// PointLight / SpotLight / DirectionalLight; it modulates LightColor around
/// the color it found on enable and restores it on disable.
///
/// Styles: Candle (soft organic sway), Fluorescent (mostly steady, random
/// dips), Faulty (hard on/off cuts), Pulse (slow sine breathing), Lightning
/// (dim baseline, rare bright flashes).
/// </summary>
public sealed class {className} : Component
{{
public enum FlickerStyle {{ Candle, Fluorescent, Faulty, Pulse, Lightning }}
[Property] public FlickerStyle Style {{ get; set; }} = FlickerStyle.{style};
/// Flicker depth: 0 = steady, 1 = full blackouts / double-bright flashes.
[Property] public float Intensity {{ get; set; }} = {it};
/// Speed multiplier for the whole pattern.
[Property] public float Speed {{ get; set; }} = {sp};
private Light _light;
private Color _baseColor;
private float _seed;
private float _mult = 1f;
protected override void OnEnabled()
{{
_light = GetComponent<Light>();
if ( _light == null )
{{
Log.Warning( $""{className}: no Light component on {{GameObject.Name}} -- disabling."" );
Enabled = false;
return;
}}
_baseColor = _light.LightColor;
_seed = Game.Random.Float( 0f, 512f );
}}
protected override void OnDisabled()
{{
if ( _light != null ) _light.LightColor = _baseColor;
}}
protected override void OnUpdate()
{{
if ( _light == null ) return;
float t = (Time.Now + _seed) * Speed;
float n = Sandbox.Utility.Noise.Perlin( t * 6f, _seed ); // smooth 0..1
float target = Style switch
{{
FlickerStyle.Candle => MathX.Lerp( 1f - Intensity * 0.6f, 1f, n ),
FlickerStyle.Fluorescent => n > 0.75f ? 1f - Intensity : 1f,
FlickerStyle.Faulty => Sandbox.Utility.Noise.Perlin( t * 14f, _seed ) > 0.55f ? 1f : 1f - Intensity,
FlickerStyle.Pulse => MathX.Lerp( 1f - Intensity, 1f, 0.5f + 0.5f * MathF.Sin( t * 4f ) ),
FlickerStyle.Lightning => n > 0.92f ? 1f + Intensity * 2f : 1f - Intensity * 0.85f,
_ => 1f
}};
// Smooth toward the target so hard styles read as a light, not strobe noise.
_mult = MathX.Lerp( _mult, target, MathX.Clamp( Time.Delta * 24f, 0f, 1f ) );
_light.LightColor = _baseColor * _mult;
}}
}}
";
}
}
// -----------------------------------------------------------------------------
// create_floating_combat_text -- rising/fading world-space text popups
// (damage numbers, "+10 gold", pickup names). TextRenderer-based -- no Razor,
// no WorldPanel, works with zero UI setup. The generated class IS the popup
// behavior and carries a static Spawn() factory; nothing to place in the scene.
// -----------------------------------------------------------------------------
public class CreateFloatingCombatTextHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if ( !ScaffoldHelpers.PrepareCodeFile( p, "FloatingCombatText", out var fullPath, out var relPath, out var className, out var err ) )
return Task.FromResult<object>( err );
float riseSpeed = p.TryGetProperty( "riseSpeed", out var rv ) && rv.TryGetSingle( out var rf ) ? rf : 48f;
float lifetime = p.TryGetProperty( "lifetime", out var lv ) && lv.TryGetSingle( out var lf ) ? lf : 1.1f;
float fontSize = p.TryGetProperty( "fontSize", out var fv ) && fv.TryGetSingle( out var ff ) ? ff : 24f;
if ( riseSpeed < 0f ) riseSpeed = 0f;
if ( lifetime < 0.1f ) lifetime = 0.1f;
if ( fontSize < 1f ) fontSize = 1f;
var code = BuildCode( className, riseSpeed, lifetime, fontSize, ci );
ScaffoldHelpers.WriteCode( fullPath, code );
return Task.FromResult<object>( new
{
created = true,
path = relPath,
className,
riseSpeed,
lifetime,
fontSize,
nextSteps = new[]
{
$"trigger_hotload to compile {className} into the game assembly.",
$"Nothing to place -- spawn popups from any game code: {className}.Spawn( hitPosition + Vector3.Up * 32f, \"-25\", Color.Red ) (optional 4th arg scales the text).",
$"Pairs with create_health_system: call {className}.Spawn from the damage path so every hit prints its number.",
"LOCAL-only: spawn inside an [Rpc.Broadcast] handler if every client should see the popup.",
"Verify in play mode: execute a spawn (e.g. via invoke_method on a test component), then take_screenshot -- the text rises and fades over Lifetime seconds."
}
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"create_floating_combat_text failed: {ex.Message}" } );
}
}
static string BuildCode( string className, float riseSpeed, float lifetime, float fontSize, System.Globalization.CultureInfo ci )
{
string rs = riseSpeed.ToString( ci ) + "f";
string lt = lifetime.ToString( ci ) + "f";
string fs = fontSize.ToString( ci ) + "f";
return $@"using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// {className} -- a rising, fading world-space text popup (damage numbers,
/// ""+10 gold"", pickup names). TextRenderer-based: no Razor, no panels.
///
/// Spawn from anywhere:
/// {className}.Spawn( position, ""-25"", Color.Red );
/// {className}.Spawn( position, ""+10 gold"", Color.Yellow, 1.5f );
///
/// The popup billboards to the camera, rises, fades out, and destroys itself.
/// LOCAL-only -- spawn inside an [Rpc.Broadcast] if all clients should see it.
/// </summary>
public sealed class {className} : Component
{{
/// World units risen per second.
[Property] public float RiseSpeed {{ get; set; }} = {rs};
/// Seconds until fully faded and destroyed.
[Property] public float Lifetime {{ get; set; }} = {lt};
private TextRenderer _text;
private Color _startColor;
private TimeSince _age;
/// <summary>Spawn a popup at a world position. Returns the popup GameObject.</summary>
public static GameObject Spawn( Vector3 position, string text, Color color, float size = 1f )
{{
var go = new GameObject( true, ""FloatingText"" );
go.WorldPosition = position;
var tr = go.AddComponent<TextRenderer>();
tr.Text = text;
tr.Color = color;
tr.FontSize = {fs} * size;
go.AddComponent<{className}>();
return go;
}}
protected override void OnStart()
{{
_age = 0f;
_text = GetComponent<TextRenderer>();
if ( _text != null ) _startColor = _text.Color;
}}
protected override void OnUpdate()
{{
var go = GameObject;
go.WorldPosition += Vector3.Up * (RiseSpeed * Time.Delta);
// Billboard: face the same way the camera faces, mirrored toward it.
var cam = Scene?.Camera;
if ( cam != null )
go.WorldRotation = Rotation.LookAt( -cam.WorldRotation.Forward );
if ( _text != null )
_text.Color = _startColor.WithAlpha( _startColor.a * MathX.Clamp( 1f - _age / Lifetime, 0f, 1f ) );
if ( _age >= Lifetime )
go.Destroy();
}}
}}
";
}
}
/// <summary>
/// Shared placement helper for the game-feel handlers -- mirrors the standard
/// scaffold placement (create_weighted_loot_table / create_event_director).
/// </summary>
internal static class GameFeelHelpers
{
public static object PlaceOnTarget( string targetId, string className, out string note )
{
note = null;
var scene = SceneEditorSession.Active?.Scene;
if ( scene == null ) { note = "No active scene to place into."; return null; }
if ( !Guid.TryParse( targetId, out var guid ) ) { note = "Invalid targetId GUID."; return null; }
var go = scene.Directory.FindByGuid( guid );
if ( go == null ) { note = $"Target GameObject not found: {targetId}"; return null; }
var typeDesc = Game.TypeLibrary.GetType( className );
if ( typeDesc == null )
{
note = $"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.";
return null;
}
try { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }
catch ( Exception ex ) { note = $"Placement failed ({ex.Message})."; return null; }
}
}