Editor/DialogueFxHandlers.cs

Editor bridge handlers that generate C# component source files for the game. One handler creates a LipsyncDialogue component that synthesizes speech, drives model mouth morphs from lip-sync visemes, and optionally mirrors lines to a HUD. The other creates a CameraFx component with static helpers that call engine camera effect APIs (shake, punch, tilt) and preset triggers. Both write code files and may place the generated component on a scene object.

File AccessExternal Download
using Editor;
using Sandbox;
using System;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
//  Dialogue & Camera FX pair — generate_lipsync_dialogue / create_camera_effects
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs, so it reuses the
//  shared helpers directly: ScaffoldHelpers.PrepareCodeFile / WriteCode,
//  ClaudeBridge.ParseVector3, and AiSystemsHelpers (Float/Int/Bool/Str/F/
//  EscString/PlaceOnTarget — internal to this assembly, defined in
//  AiSystemsHandlers.cs). Handler code here is UNSANDBOXED editor code.
//
//  The C# *strings these handlers generate* run in the SANDBOX (the game).
//  Both templates were live-verified on 2026-07-13 (default render written into
//  the live project, hotloaded, compile clean, TypeLibrary-load confirmed with
//  every public member present, then deleted). Whitelist findings proven by a
//  dedicated sandboxed compile probe the same day:
//    - CameraComponent.AddShake(amplitude, frequency, duration),
//      AddPunch(Vector3, amplitude, frequency=1, duration=0.3, fovAmplitude=0),
//      AddPunch(Angles, frequency=1, duration=0.3, fovAmplitude=0) and
//      AddTilt(Angles, duration, easeTime=0.2) ALL compile in game code, and the
//      return type is nameable as Sandbox.CameraEffectSystem.BaseEffect
//      (nested public: Stop()/IsDone/Epicenter/Radius/Duration/TimeAlive).
//    - SoundHandle.LipSync accessor (Enabled / Visemes / FrameNumber),
//      handle.IsPlaying / Finished / IsValid / Stop(fadeTime) compile in game code.
//    - SoundHandle.LipSync.Visemes is IReadOnlyList<float> — weights indexed in
//      the engine's viseme order. That order was read live from the engine's own
//      Sandbox.LipSync component (private string[] VisemeNames, 15 entries:
//      viseme_sil, viseme_PP, viseme_FF, viseme_TH, viseme_DD, viseme_KK,
//      viseme_CH, viseme_SS, viseme_NN, viseme_RR, viseme_AA, viseme_E,
//      viseme_I, viseme_O, viseme_U) and is baked into the generated component.
//    - Model.MorphCount / GetMorphName(int) / GetVisemeMorph(visemeName, morphIndex)
//      and SkinnedModelRenderer.Morphs.Set(name, weight) / Clear(name, fadeTime)
//      compile in game code. GetVisemeMorph returns REAL nonzero weights on
//      models with baked viseme data (verified against Citizen: e.g. viseme_AA →
//      openjawL=0.318, openjawR=0.308; viseme_PP → lowerLipsTowardAndPart=0.804),
//      so the mouth-drive is data-driven, not a hand-guessed morph table.
//  What the editor CANNOT verify: actual audio playback and whether the Visemes
//  list populates during it (runtime-only) — the generated component bakes in a
//  LogVisemes() helper + DebugLogVisemes property so a human playtest can confirm
//  in seconds. The tool notes say so honestly.
//
//  Registration lines + the _sceneMutatingCommands additions are wired by the
//  main agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge
//  conflict with sibling handler files.
// ═══════════════════════════════════════════════════════════════════════════

// ═══════════════════════════════════════════════════════════════════════════
//  1. generate_lipsync_dialogue  (code-gen; scene-mutating [writes a file])
//     "NPCs speak their dialogue with moving mouths": a sealed Component that
//     plays a line list — per line it resolves the speaker GameObject by name,
//     speaks the text positionally via Sandbox.Speech.Synthesizer, drives the
//     speaker's SkinnedModelRenderer mouth morphs from Handle.LipSync.Visemes
//     through the model's own baked viseme->morph map, mirrors the line into a
//     create_dialogue_system HUD when one exists (loose TypeLibrary bind), and
//     advances when the handle stops. OnLineStarted/OnDialogueFinished + Skip().
// ═══════════════════════════════════════════════════════════════════════════
public class GenerateLipsyncDialogueHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "LipsyncDialogue", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var volume         = AiSystemsHelpers.Float( p, "volume", 1f );
			var positional     = AiSystemsHelpers.Bool(  p, "positional", true );
			var driveMouth     = AiSystemsHelpers.Bool(  p, "driveMouth", true );
			var morphScale     = AiSystemsHelpers.Float( p, "morphScale", 1f );
			var mouthSmooth    = AiSystemsHelpers.Float( p, "mouthSmoothSeconds", 0.05f );
			var lineGap        = AiSystemsHelpers.Float( p, "lineGapSeconds", 0.2f );
			var lineTimeout    = AiSystemsHelpers.Float( p, "lineTimeoutSeconds", 20f );
			var bindHud        = AiSystemsHelpers.Bool(  p, "bindHud", true );
			var autoStart      = AiSystemsHelpers.Bool(  p, "autoStart", false );
			var debugVisemes   = AiSystemsHelpers.Bool(  p, "debugLogVisemes", false );

			// ── Dialogue lines: explicit `lines` array wins, else a two-line demo.
			var lineInits = new StringBuilder();
			int lineCount = 0;
			if ( p.TryGetProperty( "lines", out var arr ) && arr.ValueKind == JsonValueKind.Array && arr.GetArrayLength() > 0 )
			{
				foreach ( var e in arr.EnumerateArray() )
				{
					var speaker = AiSystemsHelpers.Str( e, "speaker", "" );
					var text    = AiSystemsHelpers.Str( e, "text", "" );
					lineInits.Append( "\t\tnew DialogueLine { Speaker = \"" + AiSystemsHelpers.EscString( speaker )
						+ "\", Text = \"" + AiSystemsHelpers.EscString( text ) + "\" },\n" );
					lineCount++;
				}
			}
			else
			{
				lineInits.Append( "\t\tnew DialogueLine { Speaker = \"\", Text = \"Hello there.\" },\n" );
				lineInits.Append( "\t\tnew DialogueLine { Speaker = \"\", Text = \"My mouth moves while I talk.\" },\n" );
				lineCount = 2;
			}

			// ── Per-speaker voices: optional `voices` array, default empty (OS voice).
			var voiceInits = new StringBuilder();
			int voiceCount = 0;
			if ( p.TryGetProperty( "voices", out var varr ) && varr.ValueKind == JsonValueKind.Array )
			{
				foreach ( var e in varr.EnumerateArray() )
				{
					var speaker = AiSystemsHelpers.Str( e, "speaker", "" );
					var vName   = AiSystemsHelpers.Str( e, "voiceName", "" );
					var vGender = AiSystemsHelpers.Str( e, "voiceGender", "" );
					var vAge    = AiSystemsHelpers.Str( e, "voiceAge", "" );
					var rate    = AiSystemsHelpers.Int( e, "rate", 0 );
					voiceInits.Append( "\t\tnew SpeakerVoice { Speaker = \"" + AiSystemsHelpers.EscString( speaker )
						+ "\", VoiceName = \"" + AiSystemsHelpers.EscString( vName )
						+ "\", VoiceGender = \"" + AiSystemsHelpers.EscString( vGender )
						+ "\", VoiceAge = \"" + AiSystemsHelpers.EscString( vAge )
						+ "\", Rate = " + rate + " },\n" );
					voiceCount++;
				}
			}
			var voicesInit = voiceCount > 0 ? "new()\n\t{\n" + voiceInits + "\t}" : "new()";

			var code = LipsyncDialogueTemplate
				.Replace( "__CLASS__", className )
				.Replace( "__LINESINIT__", lineInits.ToString() )
				.Replace( "__VOICESINIT__", voicesInit )
				.Replace( "__VOLUME__", AiSystemsHelpers.F( volume ) )
				.Replace( "__POSITIONAL__", positional ? "true" : "false" )
				.Replace( "__DRIVEMOUTH__", driveMouth ? "true" : "false" )
				.Replace( "__MORPHSCALE__", AiSystemsHelpers.F( morphScale ) )
				.Replace( "__MOUTHSMOOTH__", AiSystemsHelpers.F( mouthSmooth ) )
				.Replace( "__LINEGAP__", AiSystemsHelpers.F( lineGap ) )
				.Replace( "__LINETIMEOUT__", AiSystemsHelpers.F( lineTimeout ) )
				.Replace( "__BINDHUD__", bindHud ? "true" : "false" )
				.Replace( "__AUTOSTART__", autoStart ? "true" : "false" )
				.Replace( "__DEBUGVISEMES__", debugVisemes ? "true" : "false" );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				lineCount,
				voiceCount,
				propertyNames = new[] { "Lines", "Voices", "Volume", "Positional", "DriveMouth", "MorphScale", "MouthSmoothSeconds", "LineGapSeconds", "LineTimeoutSeconds", "BindHud", "AutoStart", "DebugLogVisemes" },
				placedOn,
				placementNote = placeNote,
				note = "Start from game code with <class>.Begin() (or autoStart:true / a create_interactable that calls Begin). Per line it speaks the text " +
				       "via the OS synthesizer AT the speaker GameObject (line.Speaker = scene object name, empty = own GameObject), drives that speaker's " +
				       "SkinnedModelRenderer mouth morphs from the live viseme stream, and advances when the audio handle stops (Skip() cuts a line short). " +
				       "MOUTH DRIVE: data-driven — Handle.LipSync.Visemes (IReadOnlyList<float>, engine order baked in from Sandbox.LipSync.VisemeNames, read " +
				       "live 2026-07-13) x the model's own Model.GetVisemeMorph table (verified nonzero on Citizen; models WITHOUT baked viseme data log a " +
				       "warning and stay audio-only). HONESTY: the editor cannot playtest audio, so the live viseme stream is RUNTIME-UNVERIFIED — set " +
				       "DebugLogVisemes or call LogVisemes() in play mode to confirm weights flow; everything else (API surface, mapping data) verified live. " +
				       "HUD: bindHud loosely TypeLibrary-matches a create_dialogue_system component (List<string> Lines + Begin() + bool IsActive) and mirrors " +
				       "each line into it — the HUD's own typewriter/advance input stays active (its advance key ends only its display, not the audio). " +
				       "LOCAL-only (audio + morphs on the calling machine): call Begin() inside an [Rpc.Broadcast] for everyone. Voices are machine/OS-specific; " +
				       "TrySetVoice is best-effort. Events: OnLineStarted(dialogue, index, speaker), OnDialogueFinished(dialogue) — natural end only, not StopDialogue()."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"generate_lipsync_dialogue failed: {ex.Message}" } );
		}
	}

	// Verified template (default render live-compile-verified 2026-07-13). Tokens: __CLASS__ etc.
	// Plain verbatim string + Replace — no $@ brace-doubling risk in a template this size.
	private const string LipsyncDialogueTemplate =
@"using Sandbox;
using System;
using System.Collections.Generic;

// Local dialogue performance: each line is spoken via the OS speech synthesizer
// (Sandbox.Speech.Synthesizer) at the speaker GameObject while the speaker's
// SkinnedModelRenderer mouth morphs are driven from the live viseme stream
// (Handle.LipSync.Visemes -> Model.GetVisemeMorph mapping baked into the model).
// LOCAL only — call Begin() inside an [Rpc.Broadcast] for everyone to see/hear.
public sealed class __CLASS__ : Component
{
	/// <summary>One spoken line. Speaker = scene GameObject name (case-insensitive); empty = this GameObject.</summary>
	public sealed class DialogueLine
	{
		public string Speaker { get; set; } = """";
		public string Text { get; set; } = """";
	}

	/// <summary>Per-speaker voice settings, matched to DialogueLine.Speaker by name (case-insensitive).</summary>
	public sealed class SpeakerVoice
	{
		public string Speaker { get; set; } = """";
		/// <summary>Exact installed OS voice name. Empty = use Gender/Age hints, or the OS default.</summary>
		public string VoiceName { get; set; } = """";
		public string VoiceGender { get; set; } = """";
		public string VoiceAge { get; set; } = """";
		/// <summary>Speaking rate offset: negative = slower, positive = faster.</summary>
		public int Rate { get; set; } = 0;
	}

	[Property] public List<DialogueLine> Lines { get; set; } = new()
	{
__LINESINIT__	};

	[Property] public List<SpeakerVoice> Voices { get; set; } = __VOICESINIT__;

	[Property] public float Volume { get; set; } = __VOLUME__;
	/// <summary>True: 3D audio parented to the speaker. False: flat 2D narrator voice.</summary>
	[Property] public bool Positional { get; set; } = __POSITIONAL__;
	/// <summary>Drive the speaker's SkinnedModelRenderer mouth morphs from the viseme stream.</summary>
	[Property] public bool DriveMouth { get; set; } = __DRIVEMOUTH__;
	/// <summary>Multiplier on viseme-derived morph weights (same idea as Sandbox.LipSync.MorphScale).</summary>
	[Property] public float MorphScale { get; set; } = __MORPHSCALE__;
	/// <summary>Seconds of exponential smoothing on mouth morphs (0 = raw viseme weights).</summary>
	[Property] public float MouthSmoothSeconds { get; set; } = __MOUTHSMOOTH__;
	/// <summary>Pause between a line ending and the next line starting.</summary>
	[Property] public float LineGapSeconds { get; set; } = __LINEGAP__;
	/// <summary>Safety: a line whose audio never starts is skipped after this many seconds.</summary>
	[Property] public float LineTimeoutSeconds { get; set; } = __LINETIMEOUT__;
	/// <summary>Mirror each line into a generated create_dialogue_system HUD if one exists in the scene (loose TypeLibrary bind).</summary>
	[Property] public bool BindHud { get; set; } = __BINDHUD__;
	[Property] public bool AutoStart { get; set; } = __AUTOSTART__;
	/// <summary>Log the live viseme stream ~4x/second while speaking (runtime diagnostics).</summary>
	[Property] public bool DebugLogVisemes { get; set; } = __DEBUGVISEMES__;

	/// <summary>Fires as each line starts speaking — (dialogue, lineIndex, speakerName).</summary>
	public static event Action<__CLASS__, int, string> OnLineStarted;
	/// <summary>Fires when the last line finishes (not on StopDialogue abort).</summary>
	public static event Action<__CLASS__> OnDialogueFinished;

	public bool IsActive { get; private set; }
	public int CurrentIndex { get; private set; } = -1;
	/// <summary>The current line's SoundHandle (null between lines / before Begin).</summary>
	public SoundHandle Handle { get; private set; }
	public bool IsSpeaking => Handle != null && Handle.IsValid && Handle.IsPlaying;
	/// <summary>True when a scene dialogue HUD was found and lines are mirrored into it.</summary>
	public bool HudBound => _hud != null && _hud.IsValid();

	// Engine viseme order — copied from Sandbox.LipSync.VisemeNames (read live via reflection 2026-07-13).
	// Handle.LipSync.Visemes is an IReadOnlyList<float> of weights in THIS order.
	private static readonly string[] VisemeNames =
	{
		""viseme_sil"", ""viseme_PP"", ""viseme_FF"", ""viseme_TH"", ""viseme_DD"",
		""viseme_KK"", ""viseme_CH"", ""viseme_SS"", ""viseme_NN"", ""viseme_RR"",
		""viseme_AA"", ""viseme_E"", ""viseme_I"", ""viseme_O"", ""viseme_U"",
	};

	private GameObject _speakerGo;
	private SkinnedModelRenderer _mouth;
	private Model _matrixModel;
	private float[,] _matrix;                   // [viseme, morph] weights from Model.GetVisemeMorph
	private string[] _morphNames;
	private readonly List<int> _activeMorphs = new();
	private float[] _current;                   // smoothed morph weights
	private Component _hud;
	private PropertyDescription _hudLines;
	private MethodDescription _hudBegin;
	private bool _lineWasPlaying;
	private bool _lineEnded;
	private TimeSince _sinceLineStarted;
	private TimeSince _sinceLineEnded;
	private RealTimeSince _sinceVisemeLog;

	protected override void OnStart()
	{
		if ( AutoStart ) Begin();
	}

	/// <summary>Start (or restart) the conversation from the first line.</summary>
	public void Begin()
	{
		if ( Lines == null || Lines.Count == 0 )
		{
			Log.Warning( ""__CLASS__: no lines to speak."" );
			return;
		}
		if ( BindHud ) TryBindHud();
		IsActive = true;
		PlayLine( 0 );
	}

	/// <summary>Cut the current line short and move to the next one immediately.</summary>
	public void Skip()
	{
		if ( !IsActive ) return;
		if ( Handle != null && Handle.IsValid && Handle.IsPlaying ) Handle.Stop( 0.05f );
		RelaxMouth();
		Advance();
	}

	/// <summary>Abort the conversation (no OnDialogueFinished).</summary>
	public void StopDialogue()
	{
		if ( Handle != null && Handle.IsValid && Handle.IsPlaying ) Handle.Stop( 0.1f );
		RelaxMouth();
		IsActive = false;
		CurrentIndex = -1;
	}

	protected override void OnUpdate()
	{
		if ( !IsActive ) return;

		if ( Handle != null && Handle.IsValid && Handle.IsPlaying )
		{
			_lineWasPlaying = true;
			DriveMouthFrame();
			return;
		}

		if ( _lineWasPlaying )
		{
			// The line's audio just ended.
			_lineWasPlaying = false;
			_lineEnded = true;
			_sinceLineEnded = 0f;
			RelaxMouth();
			return;
		}

		// Line audio ended (or synth failed) — wait out the gap, then advance.
		if ( _lineEnded )
		{
			if ( _sinceLineEnded >= LineGapSeconds ) Advance();
			return;
		}

		// Audio was created but never reached IsPlaying (synthesis still pending, or silently failed).
		if ( _sinceLineStarted > LineTimeoutSeconds )
		{
			Log.Warning( $""__CLASS__: line {CurrentIndex} audio never started — skipping."" );
			Advance();
		}
	}

	private void PlayLine( int index )
	{
		CurrentIndex = index;
		var line = Lines[index];
		var speakerName = line != null && line.Speaker != null ? line.Speaker : """";

		_speakerGo = ResolveSpeaker( speakerName );
		_mouth = _speakerGo != null
			? _speakerGo.Components.Get<SkinnedModelRenderer>( FindMode.EnabledInSelfAndDescendants )
			: null;
		PrepareMatrix();

		_lineWasPlaying = false;
		_lineEnded = false;
		_sinceLineStarted = 0f;

		ShowOnHud( speakerName, line != null ? line.Text : """" );
		Speak( speakerName, line != null ? line.Text : """" );
		if ( Handle == null ) { _lineEnded = true; _sinceLineEnded = 0f; }   // synth failed — treat as instantly ended

		OnLineStarted?.Invoke( this, index, speakerName );
	}

	private void Advance()
	{
		int next = CurrentIndex + 1;
		if ( Lines == null || next >= Lines.Count )
		{
			IsActive = false;
			CurrentIndex = -1;
			OnDialogueFinished?.Invoke( this );
			return;
		}
		PlayLine( next );
	}

	private void Speak( string speakerName, string text )
	{
		if ( Handle != null && Handle.IsValid && Handle.IsPlaying ) Handle.Stop( 0.05f );   // e.g. Begin() mid-conversation
		Handle = null;
		if ( string.IsNullOrWhiteSpace( text ) ) return;

		var synth = new Sandbox.Speech.Synthesizer();
		int rate = 0;
		var voice = FindVoice( speakerName );
		if ( voice != null )
		{
			rate = voice.Rate;
			if ( !string.IsNullOrWhiteSpace( voice.VoiceName ) )
				synth.TrySetVoice( voice.VoiceName );
			else if ( !string.IsNullOrWhiteSpace( voice.VoiceGender ) && !string.IsNullOrWhiteSpace( voice.VoiceAge ) )
				synth.TrySetVoice( voice.VoiceGender, voice.VoiceAge );
		}

		var handle = synth.WithText( text ).WithRate( rate ).Play();
		if ( handle == null ) return;

		handle.Volume = Volume;
		if ( Positional && _speakerGo != null && _speakerGo.IsValid() )
		{
			handle.Position = _speakerGo.WorldPosition;
			handle.SetParent( _speakerGo );
		}
		else
		{
			handle.ListenLocal = true;
		}

		handle.LipSync.Enabled = true;   // viseme extraction for the mouth drive
		Handle = handle;
	}

	private SpeakerVoice FindVoice( string speakerName )
	{
		if ( Voices == null ) return null;
		foreach ( var v in Voices )
		{
			if ( v == null ) continue;
			if ( string.Equals( v.Speaker ?? """", speakerName ?? """", StringComparison.OrdinalIgnoreCase ) ) return v;
		}
		return null;
	}

	private GameObject ResolveSpeaker( string speakerName )
	{
		if ( string.IsNullOrWhiteSpace( speakerName ) ) return GameObject;
		var found = FindByNameRecursive( Scene, speakerName );
		if ( found == null )
		{
			Log.Warning( $""__CLASS__: speaker GameObject '{speakerName}' not found — speaking from this GameObject."" );
			return GameObject;
		}
		return found;
	}

	private static GameObject FindByNameRecursive( GameObject root, string name )
	{
		if ( root == null ) return null;
		foreach ( var child in root.Children )
		{
			if ( child == null ) continue;
			if ( string.Equals( child.Name, name, StringComparison.OrdinalIgnoreCase ) ) return child;
			var found = FindByNameRecursive( child, name );
			if ( found != null ) return found;
		}
		return null;
	}

	// ── Mouth drive ──────────────────────────────────────────────────────

	/// <summary>Build (or reuse) the viseme->morph weight matrix baked into the speaker's model.</summary>
	private void PrepareMatrix()
	{
		if ( _mouth == null || !_mouth.IsValid() || _mouth.Model == null ) { _matrix = null; _matrixModel = null; return; }
		if ( _mouth.Model == _matrixModel && _matrix != null ) return;

		var model = _mouth.Model;
		int morphCount = model.MorphCount;
		_matrix = new float[VisemeNames.Length, morphCount];
		_morphNames = new string[morphCount];
		_activeMorphs.Clear();
		for ( int m = 0; m < morphCount; m++ )
		{
			_morphNames[m] = model.GetMorphName( m );
			bool any = false;
			for ( int v = 0; v < VisemeNames.Length; v++ )
			{
				var w = model.GetVisemeMorph( VisemeNames[v], m );
				_matrix[v, m] = w;
				if ( w > 0.001f ) any = true;
			}
			if ( any ) _activeMorphs.Add( m );
		}
		_current = new float[morphCount];
		_matrixModel = model;

		if ( _activeMorphs.Count == 0 )
			Log.Warning( $""__CLASS__: model '{model.Name}' has no baked viseme->morph data — mouth won't move (audio still plays). Citizen has it; custom models may not."" );
	}

	private void DriveMouthFrame()
	{
		if ( DebugLogVisemes && _sinceVisemeLog > 0.25f ) LogVisemes();
		if ( !DriveMouth || _mouth == null || !_mouth.IsValid() || _matrix == null ) return;

		var visemes = Handle.LipSync.Visemes;
		if ( visemes == null || visemes.Count == 0 ) return;

		int vc = System.Math.Min( visemes.Count, VisemeNames.Length );
		float alpha = MouthSmoothSeconds <= 0f ? 1f : MathX.Clamp( Time.Delta / MouthSmoothSeconds, 0f, 1f );

		foreach ( var m in _activeMorphs )
		{
			float target = 0f;
			for ( int v = 0; v < vc; v++ )
				target += visemes[v] * _matrix[v, m];
			target = MathX.Clamp( target * MorphScale, 0f, 1f );
			_current[m] = MathX.Lerp( _current[m], target, alpha );
			_mouth.Morphs.Set( _morphNames[m], _current[m] );
		}
	}

	/// <summary>Release our morph overrides with a short fade (engine blends back to animation).</summary>
	private void RelaxMouth()
	{
		if ( _mouth == null || !_mouth.IsValid() || _morphNames == null ) return;
		foreach ( var m in _activeMorphs )
		{
			_mouth.Morphs.Clear( _morphNames[m], 0.15f );
			if ( _current != null ) _current[m] = 0f;
		}
	}

	/// <summary>Debug: log the live viseme stream state (call while a line plays, or set DebugLogVisemes).</summary>
	public void LogVisemes()
	{
		_sinceVisemeLog = 0f;
		if ( Handle == null || !Handle.IsValid ) { Log.Info( ""__CLASS__: no live handle."" ); return; }
		var ls = Handle.LipSync;
		var visemes = ls.Visemes;
		if ( visemes == null ) { Log.Info( $""__CLASS__: LipSync.Enabled={ls.Enabled} frame={ls.FrameNumber} Visemes=null"" ); return; }
		var parts = """";
		int vc = System.Math.Min( visemes.Count, VisemeNames.Length );
		for ( int v = 0; v < vc; v++ )
			if ( visemes[v] > 0.01f ) parts += $""{VisemeNames[v]}={visemes[v]:0.00} "";
		Log.Info( $""__CLASS__: frame={ls.FrameNumber} count={visemes.Count} {(parts.Length > 0 ? parts : ""(all zero)"")}"" );
	}

	// ── Loose HUD bind (create_dialogue_system contract) ─────────────────

	/// <summary>
	/// Capability-match a generated create_dialogue_system component anywhere in the scene:
	/// a List&lt;string&gt; property named Lines + a parameterless Begin method + an IsActive bool.
	/// Bound loosely via TypeLibrary so neither system references the other.
	/// </summary>
	private void TryBindHud()
	{
		_hud = null; _hudLines = null; _hudBegin = null;
		if ( Scene == null ) return;

		foreach ( var c in Scene.GetAllComponents<Component>() )
		{
			if ( c == null || c == this || !c.IsValid() ) continue;
			var td = TypeLibrary.GetType( c.GetType() );
			if ( td == null ) continue;
			var linesProp = td.GetProperty( ""Lines"" );
			if ( linesProp == null || linesProp.PropertyType != typeof( List<string> ) ) continue;
			var begin = td.GetMethod( ""Begin"" );
			if ( begin == null ) continue;
			var isActive = td.GetProperty( ""IsActive"" );
			if ( isActive == null || isActive.PropertyType != typeof( bool ) ) continue;

			_hud = c;
			_hudLines = linesProp;
			_hudBegin = begin;
			return;
		}
	}

	private void ShowOnHud( string speakerName, string text )
	{
		if ( _hud == null || !_hud.IsValid() || _hudLines == null || _hudBegin == null ) return;
		try
		{
			// Mutate the HUD's existing Lines list in place (read-only bind — no SetValue needed).
			if ( _hudLines.GetValue( _hud ) is List<string> hudLines )
			{
				hudLines.Clear();
				hudLines.Add( string.IsNullOrWhiteSpace( speakerName ) ? text : $""{speakerName}: {text}"" );
				_hudBegin.Invoke( _hud, null );
			}
		}
		catch ( Exception ex )
		{
			Log.Warning( $""__CLASS__: HUD mirror failed ({ex.Message}) — continuing audio-only."" );
			_hud = null;
		}
	}

	protected override void OnDestroy()
	{
		if ( Handle != null && Handle.IsValid && Handle.IsPlaying ) Handle.Stop( 0f );
	}
}
";
}

// ═══════════════════════════════════════════════════════════════════════════
//  2. create_camera_effects  (code-gen; scene-mutating [writes a file])
//     Static conveniences over the SDK's NEW built-in camera effects
//     (CameraComponent.AddShake / AddPunch / AddTilt, all returning
//     Sandbox.CameraEffectSystem.BaseEffect — whitelist-verified in game code
//     2026-07-13): CameraFx.Shake/ShakeAt/Punch/PunchAngles/Tilt static API +
//     one-word preset triggers (HitPunch / ExplosionShake / LandingTilt) with
//     [Property] tunables. Resolves Scene.Camera, falls back to IsMainCamera
//     search, warns when the scene has no camera.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateCameraEffectsHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "CameraFx", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			string F( float v ) => AiSystemsHelpers.F( v );

			var hitAmp   = AiSystemsHelpers.Float( p, "hitPunchAmplitude", 8f );
			var hitFreq  = AiSystemsHelpers.Float( p, "hitPunchFrequency", 20f );
			var hitDur   = AiSystemsHelpers.Float( p, "hitPunchDuration", 0.25f );
			var hitFov   = AiSystemsHelpers.Float( p, "hitPunchFovAmplitude", 3f );
			var expAmp   = AiSystemsHelpers.Float( p, "explosionShakeAmplitude", 5f );
			var expFreq  = AiSystemsHelpers.Float( p, "explosionShakeFrequency", 25f );
			var expDur   = AiSystemsHelpers.Float( p, "explosionShakeDuration", 0.8f );
			var tiltDur  = AiSystemsHelpers.Float( p, "landingTiltDuration", 0.35f );
			var tiltEase = AiSystemsHelpers.Float( p, "landingTiltEase", 0.15f );

			// Optional Vector3 punch direction ({x,y,z} or "x,y,z"); default = camera-backward kick.
			var hitDirLit = "Vector3.Backward";
			if ( p.TryGetProperty( "hitPunchDirection", out var hd ) && hd.ValueKind != JsonValueKind.Null )
			{
				var v = ClaudeBridge.ParseVector3( hd );
				hitDirLit = $"new Vector3( {F( v.x )}, {F( v.y )}, {F( v.z )} )";
			}

			// Optional landing tilt as {x:pitch, y:yaw, z:roll} (or "p,y,r"); default 5° pitch + 2° roll dip.
			var tiltLit = "new Angles( 5f, 0f, 2f )";
			if ( p.TryGetProperty( "landingTiltAngles", out var ta ) && ta.ValueKind != JsonValueKind.Null )
			{
				var a = ClaudeBridge.ParseVector3( ta );
				tiltLit = $"new Angles( {F( a.x )}, {F( a.y )}, {F( a.z )} )";
			}

			var code = CameraFxTemplate
				.Replace( "__CLASS__", className )
				.Replace( "__HITDIR__", hitDirLit )
				.Replace( "__HITAMP__", F( hitAmp ) )
				.Replace( "__HITFREQ__", F( hitFreq ) )
				.Replace( "__HITDUR__", F( hitDur ) )
				.Replace( "__HITFOV__", F( hitFov ) )
				.Replace( "__EXPAMP__", F( expAmp ) )
				.Replace( "__EXPFREQ__", F( expFreq ) )
				.Replace( "__EXPDUR__", F( expDur ) )
				.Replace( "__TILTANGLES__", tiltLit )
				.Replace( "__TILTDUR__", F( tiltDur ) )
				.Replace( "__TILTEASE__", F( tiltEase ) );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string placeNote = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = AiSystemsHelpers.PlaceOnTarget( tid.GetString(), className, out placeNote );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				staticApi = new[]
				{
					"Shake(amplitude, frequency, duration)",
					"ShakeAt(position, radius, amplitude, frequency, duration)",
					"Punch(direction, amplitude, frequency, duration, fovAmplitude)",
					"PunchAngles(angles, frequency, duration, fovAmplitude)",
					"Tilt(angles, duration, easeTime)",
					"ResolveCamera()"
				},
				presetTriggers = new[] { "HitPunch()", "ExplosionShake()", "ExplosionShakeAt(position, radius)", "LandingTilt()" },
				propertyNames = new[] { "HitPunchDirection", "HitPunchAmplitude", "HitPunchFrequency", "HitPunchDuration", "HitPunchFovAmplitude", "ExplosionShakeAmplitude", "ExplosionShakeFrequency", "ExplosionShakeDuration", "LandingTiltAngles", "LandingTiltDuration", "LandingTiltEase" },
				placedOn,
				placementNote = placeNote,
				note = "Wraps the ENGINE'S built-in camera effects (CameraComponent.AddShake/AddPunch/AddTilt — fire-and-forget, self-expiring; " +
				       "whitelist-verified in sandboxed game code 2026-07-13). Call the statics from anywhere (<class>.Shake(4f, 25f, 0.8f)) — they resolve " +
				       "Scene.Camera, fall back to the IsMainCamera search, and warn+return null when the scene has no camera. Each returns the live " +
				       "Sandbox.CameraEffectSystem.BaseEffect (Stop()/IsDone; ShakeAt also sets Epicenter+Radius for distance falloff). The component itself " +
				       "just carries the [Property] preset tunables + one-word triggers (HitPunch/ExplosionShake/LandingTilt) — statics work with NO instance " +
				       "placed. RELATIONSHIP: create_camera_shake is the CONTINUOUS trauma model (AddTrauma accumulates, decays over time); these built-ins are " +
				       "ONE-SHOT engine effects — they compose safely, but don't stack both for the same event or hits feel doubled. HONESTY: compile + camera " +
				       "resolution are verified; the editor cannot judge FEEL — tune amplitudes in a human playtest. Effects are LOCAL visuals: trigger inside an " +
				       "[Rpc.Broadcast] handler if every client should feel it."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_camera_effects failed: {ex.Message}" } );
		}
	}

	// Verified template (default render live-compile-verified 2026-07-13). Tokens: __CLASS__ etc.
	private const string CameraFxTemplate =
@"using Sandbox;
using System;

// Static conveniences over the engine's BUILT-IN camera effects (CameraComponent.AddShake /
// AddPunch / AddTilt — fire-and-forget, self-expiring). Complements create_camera_shake's
// continuous trauma model: use these for one-shot feel hits, the trauma component for
// sustained rumble; they compose safely (both feed the same CameraEffectSystem transform).
// LOCAL-only visuals — call from an [Rpc.Broadcast] handler if every client should feel it.
public sealed class __CLASS__ : Component
{
	// ── HitPunch preset (directional kick, e.g. taking damage) ────────────
	[Property] public Vector3 HitPunchDirection { get; set; } = __HITDIR__;
	[Property] public float HitPunchAmplitude { get; set; } = __HITAMP__;
	[Property] public float HitPunchFrequency { get; set; } = __HITFREQ__;
	[Property] public float HitPunchDuration { get; set; } = __HITDUR__;
	[Property] public float HitPunchFovAmplitude { get; set; } = __HITFOV__;

	// ── ExplosionShake preset (omnidirectional rumble) ────────────────────
	[Property] public float ExplosionShakeAmplitude { get; set; } = __EXPAMP__;
	[Property] public float ExplosionShakeFrequency { get; set; } = __EXPFREQ__;
	[Property] public float ExplosionShakeDuration { get; set; } = __EXPDUR__;

	// ── LandingTilt preset (brief pitch/roll dip, e.g. hard landing) ──────
	[Property] public Angles LandingTiltAngles { get; set; } = __TILTANGLES__;
	[Property] public float LandingTiltDuration { get; set; } = __TILTDUR__;
	[Property] public float LandingTiltEase { get; set; } = __TILTEASE__;

	/// <summary>The most recently enabled instance (used by the static resolvers).</summary>
	public static __CLASS__ Instance { get; private set; }

	protected override void OnEnabled() { Instance = this; }
	protected override void OnDisabled() { if ( Instance == this ) Instance = null; }

	/// <summary>Scene.Camera, else the IsMainCamera CameraComponent, else the first camera found. Null when the scene has none.</summary>
	public static CameraComponent ResolveCamera()
	{
		var scene = Instance != null && Instance.IsValid() ? Instance.Scene : Game.ActiveScene;
		if ( scene == null ) return null;

		var cam = scene.Camera;
		if ( cam != null && cam.IsValid() ) return cam;

		CameraComponent first = null;
		foreach ( var c in scene.GetAllComponents<CameraComponent>() )
		{
			if ( c == null || !c.IsValid() ) continue;
			if ( c.IsMainCamera ) return c;
			if ( first == null ) first = c;
		}
		return first;
	}

	/// <summary>Omnidirectional camera shake (engine built-in). Returns the live effect (Stop()/IsDone), or null when no camera.</summary>
	public static Sandbox.CameraEffectSystem.BaseEffect Shake( float amplitude = 1f, float frequency = 10f, float duration = 0.5f )
	{
		var cam = ResolveCamera();
		if ( cam == null ) { Log.Warning( ""__CLASS__.Shake: no camera in the scene."" ); return null; }
		return cam.AddShake( amplitude, frequency, duration );
	}

	/// <summary>Shake with a world epicenter + falloff radius — cameras farther from position feel less.</summary>
	public static Sandbox.CameraEffectSystem.BaseEffect ShakeAt( Vector3 position, float radius, float amplitude = 1f, float frequency = 10f, float duration = 0.5f )
	{
		var fx = Shake( amplitude, frequency, duration );
		if ( fx != null )
		{
			fx.Epicenter = position;
			fx.Radius = radius;
		}
		return fx;
	}

	/// <summary>Directional positional kick (engine built-in AddPunch, Vector3 overload).</summary>
	public static Sandbox.CameraEffectSystem.BaseEffect Punch( Vector3 direction, float amplitude, float frequency = 1f, float duration = 0.3f, float fovAmplitude = 0f )
	{
		var cam = ResolveCamera();
		if ( cam == null ) { Log.Warning( ""__CLASS__.Punch: no camera in the scene."" ); return null; }
		return cam.AddPunch( direction, amplitude, frequency, duration, fovAmplitude );
	}

	/// <summary>Rotational kick (engine built-in AddPunch, Angles overload).</summary>
	public static Sandbox.CameraEffectSystem.BaseEffect PunchAngles( Angles angles, float frequency = 1f, float duration = 0.3f, float fovAmplitude = 0f )
	{
		var cam = ResolveCamera();
		if ( cam == null ) { Log.Warning( ""__CLASS__.PunchAngles: no camera in the scene."" ); return null; }
		return cam.AddPunch( angles, frequency, duration, fovAmplitude );
	}

	/// <summary>Eased tilt toward an angle offset that releases over the duration (engine built-in AddTilt).</summary>
	public static Sandbox.CameraEffectSystem.BaseEffect Tilt( Angles angles, float duration = 0.4f, float easeTime = 0.2f )
	{
		var cam = ResolveCamera();
		if ( cam == null ) { Log.Warning( ""__CLASS__.Tilt: no camera in the scene."" ); return null; }
		return cam.AddTilt( angles, duration, easeTime );
	}

	// ── One-word preset triggers (use the [Property] tunables above) ──────

	public Sandbox.CameraEffectSystem.BaseEffect HitPunch()
		=> Punch( HitPunchDirection, HitPunchAmplitude, HitPunchFrequency, HitPunchDuration, HitPunchFovAmplitude );

	public Sandbox.CameraEffectSystem.BaseEffect ExplosionShake()
		=> Shake( ExplosionShakeAmplitude, ExplosionShakeFrequency, ExplosionShakeDuration );

	/// <summary>Explosion shake with falloff from a world position (great for grenades).</summary>
	public Sandbox.CameraEffectSystem.BaseEffect ExplosionShakeAt( Vector3 position, float radius = 512f )
		=> ShakeAt( position, radius, ExplosionShakeAmplitude, ExplosionShakeFrequency, ExplosionShakeDuration );

	public Sandbox.CameraEffectSystem.BaseEffect LandingTilt()
		=> Tilt( LandingTiltAngles, LandingTiltDuration, LandingTiltEase );
}
";
}