Editor/CinematicsHandlers.cs

Editor bridge handlers that generate C# and Razor/SCSS source files for two editor scaffolds: a CutsceneDirector component (camera cutscenes, optional letterbox) and a DialogueSystem component plus HUD. The file builds the code strings, writes files, and returns creation metadata to the editor bridge.

File Access
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.Tasks;

// =============================================================================
//  Cinematics & Dialogue pack (v1.20.0) -- two ENGINE-PROOF, no-asset scaffolds:
//
//    create_cutscene_director  hand-authored camera-shot cutscene player
//                              (NO .movie asset -- works on today's engine)
//    create_dialogue_system    typewriter NPC/story dialogue (Component + Razor HUD)
//
//  These are the HAND-AUTHORED cinematic path. The keyframed/timeline path is the
//  MovieMaker family in MovieMakerHandlers.cs (list_movies / add_movie_player /
//  play_movie / stop_movie) which wires a Sandbox.MovieMaker.MoviePlayer to a
//  .movie asset authored in the editor's Movie Maker dock. Use the cutscene
//  director when you want a cutscene with zero assets and full C# control; use
//  the MovieMaker family when you have (or want to author) a .movie clip.
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
//  reusing ClaudeBridge (TryResolveProjectPath, SanitizeIdentifier) and
//  ScaffoldHelpers (PrepareCodeFile / WriteCode). Handler code here is UNSANDBOXED
//  editor code; the C# *strings written to disk* are SANDBOXED game code.
//
//  LIVE-VERIFIED API surface used by the generated code (describe_type / search_types,
//  2026-07-08 on the shipping build):
//    - Scene.Camera : CameraComponent (read/WRITE) -- the main camera; set its
//      WorldPosition/WorldRotation to take it over (same GameObject transform the
//      CameraShake scaffold drives). OnPreRender is a general per-frame component
//      method (component-methods doc) called right before rendering, AFTER
//      controllers/animation position things -- the right hook to win the camera.
//    - Rotation.Slerp(a,b,amount,clamp) + Rotation.From(Angles) are static.
//    - Sandbox.Input.ClearActions() zeroes ALL action state for the frame (the
//      clean input-lock idiom); Input.Pressed(action) reads skip/advance;
//      Input.ReleaseActions() on finish. (describe_type "Input" resolves the XR
//      struct -- the real static class is Sandbox.Input; game code's bare `Input`
//      binds to it via `using Sandbox;`.)
//    - MathX has NO Ease/Smoothstep member on this SDK, so the generated code
//      hand-rolls smoothstep (t*t*(3-2t)); System.MathF/Math also compile.
//    - Rotation is quaternion-shaped and awful to hand-edit in the inspector, so
//      the shot list uses List<Angles> (pitch/yaw/roll) and converts at runtime.
//
//  Registration (MyEditorMenu.cs RegisterHandlers, Batch 45+):
//    Register( "create_cutscene_director", () => new CreateCutsceneDirectorHandler() );
//    Register( "create_dialogue_system",   () => new CreateDialogueSystemHandler() );
//  Both are scene-mutating (they write .cs/.razor to disk) -- add both names to
//  _sceneMutatingCommands.
// =============================================================================

// -----------------------------------------------------------------------------
// create_cutscene_director -- a sealed Component that plays a hand-authored
// camera cutscene from parallel shot lists. Takes over Scene.Camera in
// OnPreRender ONLY while playing, eased blend (smoothstep) + hold per shot,
// optional per-shot look-at, input lock (Input.ClearActions), skip action, a
// static Play()/Play(name) entry, a static OnCutsceneFinished event, and an
// optional razor_lint-safe letterbox overlay pair. LOCAL/visual-only.
// -----------------------------------------------------------------------------
public class CreateCutsceneDirectorHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "CutsceneDirector", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			// Directory is re-derived the same way PrepareCodeFile does, for the
			// optional letterbox sibling files.
			var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code";

			bool lockInput = !p.TryGetProperty( "lockInput", out var li ) || li.ValueKind != JsonValueKind.False; // default true
			bool letterbox = p.TryGetProperty( "letterbox", out var lb ) && lb.ValueKind == JsonValueKind.True;   // default false
			string skipAction = CinematicsHelpers.SanitizeAction(
				p.TryGetProperty( "skipAction", out var sa ) ? sa.GetString() : null, "jump" );

			var code = BuildCutsceneCode( className, skipAction, lockInput );
			ScaffoldHelpers.WriteCode( fullPath, code );

			// Optional letterbox overlay (Razor PanelComponent + SCSS), razor_lint-safe.
			object letterboxFiles = null;
			if ( letterbox )
			{
				var lbClass = className + "Letterbox";
				var razorRel = $"{directory}/{lbClass}.razor";
				var scssRel  = $"{directory}/{lbClass}.razor.scss";

				if ( ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, lbClass + ".razor" ), out var razorPath, out var rErr )
				  && ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, lbClass + ".razor.scss" ), out var scssPath, out var sErr ) )
				{
					if ( !File.Exists( razorPath ) )
					{
						ScaffoldHelpers.WriteCode( razorPath, BuildLetterboxRazor( className, lbClass ) );
						ScaffoldHelpers.WriteCode( scssPath,  BuildLetterboxScss() );
						letterboxFiles = new { razor = razorRel, scss = scssRel, className = lbClass };
					}
					else
					{
						letterboxFiles = new { skipped = $"{razorRel} already exists -- letterbox not overwritten." };
					}
				}
			}

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				skipAction,
				lockInput,
				letterbox = letterboxFiles,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					$"Attach {className} to any GameObject (add_component_with_properties component=\"{className}\"). It drives Scene.Camera itself in OnPreRender -- it does NOT need to be on the camera.",
					"Author shots in the inspector: fill the parallel lists ShotPositions (Vector3), ShotAngles (pitch/yaw/roll), ShotHoldSeconds, ShotBlendSeconds. Optionally ShotLookAt (per-shot GameObject) overrides ShotAngles by aiming at that object.",
					$"Play from any game code: {className}.Play() (first director) or {className}.Play(\"name\") (matches CutsceneName). Subscribe {className}.OnCutsceneFinished, or gate logic on {className}.IsCutscenePlaying.",
					lockInput
						? "LockInput=true zeroes all input each frame via Input.ClearActions(); the SkipAction press is read first so it still ends the cutscene early."
						: "LockInput=false -- the player keeps control during the cutscene; press SkipAction to end early.",
					"Camera timing: OnPreRender runs after controllers/animation position the camera, so the takeover usually wins. If a player controller re-writes the camera in ITS OwnPreRender, disable that controller for the duration (re-enable on OnCutsceneFinished).",
					"LOCAL-only (each client runs its own view): trigger via an [Rpc.Broadcast] method so every client plays the cutscene.",
					letterbox
						? "Letterbox overlay generated: host the *Letterbox panel under a ScreenPanel (add_screen_panel) -- its bars show while IsCutscenePlaying."
						: "No letterbox (pass letterbox:true to also generate a black-bars overlay panel).",
					"Alternative: for keyframed/timeline cutscenes from a .movie asset, use the MovieMaker family (list_movies / add_movie_player / play_movie) instead."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_cutscene_director failed: {ex.Message}" } );
		}
	}

	static string BuildCutsceneCode( string className, string skipAction, bool lockInput )
	{
		string lockStr = lockInput ? "true" : "false";

		return $@"using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// {className} -- a hand-authored cutscene player. Needs NO .movie asset: you
/// author the shots directly in the inspector as parallel lists and this
/// component takes over the main camera to fly between them.
///
/// (For a keyframed/timeline cutscene authored in the editor's Movie Maker dock,
/// wire a Sandbox.MovieMaker.MoviePlayer instead -- that is the other path.)
///
/// Each shot i is: blend from the previous pose to (ShotPositions[i], ShotAngles[i])
/// over ShotBlendSeconds[i] (smoothstep-eased), then hold for ShotHoldSeconds[i].
/// If ShotLookAt[i] is set, the camera aims at that GameObject instead of using
/// ShotAngles[i]. Playback takes over Scene.Camera in OnPreRender while playing
/// and restores the camera exactly when it finishes.
///
/// Play from anywhere:  {className}.Play();  or  {className}.Play(""intro"");
/// LOCAL-only -- trigger inside an [Rpc.Broadcast] for all clients.
/// </summary>
public sealed class {className} : Component
{{
	// --- Shot list (parallel; one entry per shot, indexed by ShotPositions.Count) ---

	/// Camera world position for each shot.
	[Property] public List<Vector3> ShotPositions {{ get; set; }} = new List<Vector3>();

	/// Camera angles (pitch/yaw/roll) for each shot. Ignored for a shot whose
	/// ShotLookAt entry is set. Angles (not Rotation) because quaternions are
	/// painful to hand-edit in the inspector.
	[Property] public List<Angles> ShotAngles {{ get; set; }} = new List<Angles>();

	/// Seconds to hold on each shot after the blend completes.
	[Property] public List<float> ShotHoldSeconds {{ get; set; }} = new List<float>();

	/// Seconds to blend INTO each shot from the previous pose (0 = hard cut).
	[Property] public List<float> ShotBlendSeconds {{ get; set; }} = new List<float>();

	/// Optional per-shot look-at target -- when set, overrides ShotAngles for that shot.
	[Property] public List<GameObject> ShotLookAt {{ get; set; }} = new List<GameObject>();

	// --- Tunables ---

	/// Name used by the static {className}.Play(""name"") entry point.
	[Property] public string CutsceneName {{ get; set; }} = """";

	/// Freeze player input for the duration (via Input.ClearActions each frame).
	[Property] public bool LockInput {{ get; set; }} = {lockStr};

	/// Input action that ends the cutscene early. Empty = not skippable.
	[Property] public string SkipAction {{ get; set; }} = ""{skipAction}"";

	/// Fallback hold seconds when a shot has no ShotHoldSeconds entry.
	[Property] public float DefaultHoldSeconds {{ get; set; }} = 2f;

	/// Fallback blend seconds when a shot has no ShotBlendSeconds entry.
	[Property] public float DefaultBlendSeconds {{ get; set; }} = 1f;

	/// <summary>Fires (on every client that ran it) when a cutscene ends -- skipped or completed.</summary>
	public static event Action OnCutsceneFinished;

	/// <summary>True while ANY {className} is playing -- game code can gate on this (e.g. a HUD/letterbox).</summary>
	public static bool IsCutscenePlaying {{ get; private set; }}

	private static readonly List<{className}> _all = new List<{className}>();

	private bool _playing;
	private int _shotIndex;
	private TimeSince _sinceShot;
	private Vector3 _fromPos;
	private Rotation _fromRot = Rotation.Identity;

	// Pre-cutscene camera transform, restored exactly on finish.
	private Vector3 _restorePos;
	private Rotation _restoreRot = Rotation.Identity;
	private bool _hasRestore;

	protected override void OnEnabled()
	{{
		_all.Add( this );
	}}

	protected override void OnDisabled()
	{{
		_all.Remove( this );
		if ( _playing ) Finish();
	}}

	/// <summary>Play the first {className} in the scene.</summary>
	public static void Play()
	{{
		if ( _all.Count > 0 ) _all[0].StartCutscene();
		else Log.Warning( ""{className}.Play: no cutscene director in the scene."" );
	}}

	/// <summary>Play the {className} whose CutsceneName matches (case-insensitive).</summary>
	public static void Play( string name )
	{{
		foreach ( var d in _all )
		{{
			if ( string.Equals( d.CutsceneName, name, StringComparison.OrdinalIgnoreCase ) )
			{{
				d.StartCutscene();
				return;
			}}
		}}
		Log.Warning( ""{className}.Play: no cutscene director with that name."" );
	}}

	/// <summary>Begin playback on THIS instance from the first shot.</summary>
	public void StartCutscene()
	{{
		if ( ShotPositions == null || ShotPositions.Count == 0 )
		{{
			Log.Warning( ""{className}: no shots to play (fill ShotPositions)."" );
			return;
		}}

		var cam = Scene?.Camera;
		if ( cam == null )
		{{
			Log.Warning( ""{className}: no main camera (Scene.Camera) to take over."" );
			return;
		}}

		// Capture the pre-cutscene camera transform so we can restore it exactly.
		_restorePos = cam.WorldPosition;
		_restoreRot = cam.WorldRotation;
		_hasRestore = true;

		_fromPos = _restorePos;
		_fromRot = _restoreRot;
		_shotIndex = 0;
		_sinceShot = 0f;
		_playing = true;
		IsCutscenePlaying = true;
	}}

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

		// Read the skip BEFORE clearing input, so a locked cutscene is still skippable.
		bool skip = !string.IsNullOrEmpty( SkipAction ) && Input.Pressed( SkipAction );

		if ( LockInput ) Input.ClearActions();

		if ( skip )
		{{
			Finish();
			return;
		}}

		// Advance the timeline (blend, then hold). The guarded while-loop rolls
		// straight through any zero-length shots so a list of zeros can't wedge.
		int guard = 0;
		while ( _playing && (float)_sinceShot >= ShotDuration( _shotIndex ) && guard++ < 512 )
		{{
			_fromPos = ShotPos( _shotIndex );
			_fromRot = ShotRot( _shotIndex, _fromPos );
			_shotIndex++;
			_sinceShot = 0f;
			if ( _shotIndex >= ShotPositions.Count )
			{{
				Finish();
				return;
			}}
		}}
	}}

	protected override void OnPreRender()
	{{
		if ( !_playing ) return;

		var cam = Scene?.Camera;
		if ( cam == null ) return;

		ComputePose( out var pos, out var rot );
		cam.WorldPosition = pos;
		cam.WorldRotation = rot;
	}}

	private void Finish()
	{{
		_playing = false;
		IsCutscenePlaying = false;

		// Restore the exact pre-cutscene camera transform (the controller resumes
		// next frame; a static camera is left precisely as it was).
		if ( _hasRestore )
		{{
			var cam = Scene?.Camera;
			if ( cam != null )
			{{
				cam.WorldPosition = _restorePos;
				cam.WorldRotation = _restoreRot;
			}}
			_hasRestore = false;
		}}

		if ( LockInput ) Input.ReleaseActions();
		OnCutsceneFinished?.Invoke();
	}}

	// --- shot helpers (all parallel-list-length safe) ---

	private Vector3 ShotPos( int i )
		=> ( ShotPositions != null && i >= 0 && i < ShotPositions.Count ) ? ShotPositions[i] : _fromPos;

	private Rotation ShotRot( int i, Vector3 atPos )
	{{
		if ( ShotLookAt != null && i >= 0 && i < ShotLookAt.Count && ShotLookAt[i] != null )
		{{
			var dir = ShotLookAt[i].WorldPosition - atPos;
			if ( dir.Length > 0.01f ) return Rotation.LookAt( dir.Normal );
		}}
		if ( ShotAngles != null && i >= 0 && i < ShotAngles.Count )
			return Rotation.From( ShotAngles[i] );
		return _fromRot;
	}}

	private float ShotHold( int i )
		=> ( ShotHoldSeconds != null && i >= 0 && i < ShotHoldSeconds.Count ) ? MathF.Max( 0f, ShotHoldSeconds[i] ) : MathF.Max( 0f, DefaultHoldSeconds );

	private float ShotBlend( int i )
		=> ( ShotBlendSeconds != null && i >= 0 && i < ShotBlendSeconds.Count ) ? MathF.Max( 0f, ShotBlendSeconds[i] ) : MathF.Max( 0f, DefaultBlendSeconds );

	private float ShotDuration( int i ) => ShotBlend( i ) + ShotHold( i );

	private void ComputePose( out Vector3 pos, out Rotation rot )
	{{
		int i = _shotIndex;
		var targetPos = ShotPos( i );
		float blend = ShotBlend( i );
		float raw = blend > 0.0001f ? MathX.Clamp( (float)_sinceShot / blend, 0f, 1f ) : 1f;
		float e = Ease( raw );
		pos = Vector3.Lerp( _fromPos, targetPos, e, true );
		var targetRot = ShotRot( i, pos );
		rot = Rotation.Slerp( _fromRot, targetRot, e, true );
	}}

	// Smoothstep -- MathX has no Ease/Smoothstep member on this SDK.
	private static float Ease( float t )
	{{
		t = MathX.Clamp( t, 0f, 1f );
		return t * t * ( 3f - 2f * t );
	}}
}}
";
	}

	static string BuildLetterboxRazor( string directorClass, string lbClass )
	{
		return $@"@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent;

@* {lbClass} -- cinematic black bars shown while {directorClass}.IsCutscenePlaying.
   Host this panel under a ScreenPanel (add_screen_panel). The bars slide in via a
   CSS transition on the .active class. *@

<div class=""cutscene-letterbox @ActiveClass"">
	<div class=""bar top""></div>
	<div class=""bar bottom""></div>
</div>

@code {{
	private string ActiveClass => {directorClass}.IsCutscenePlaying ? ""active"" : """";

	protected override int BuildHash() => System.HashCode.Combine( {directorClass}.IsCutscenePlaying );
}}
";
	}

	static string BuildLetterboxScss()
	{
		return @".cutscene-letterbox {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	pointer-events: none;
}

.cutscene-letterbox .bar {
	position: absolute;
	left: 0;
	width: 100%;
	height: 0;
	background-color: black;
	transition: height 0.4s ease;
}

.cutscene-letterbox .bar.top {
	top: 0;
}

.cutscene-letterbox .bar.bottom {
	bottom: 0;
}

.cutscene-letterbox.active .bar {
	height: 12%;
}
";
	}
}

// -----------------------------------------------------------------------------
// create_dialogue_system -- a sealed Component holding the dialogue state +
// data, paired with a Razor PanelComponent HUD that renders the current line
// with a typewriter reveal. Advance action completes the reveal, then advances;
// static StartDialogue(string[]) / instance Begin() entries; static
// OnDialogueFinished + OnLineShown(index, speaker) events. LOCAL/visual-only.
// -----------------------------------------------------------------------------
public class CreateDialogueSystemHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var ci = System.Globalization.CultureInfo.InvariantCulture;

			if ( !ScaffoldHelpers.PrepareCodeFile( p, "DialogueSystem", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var directory = p.TryGetProperty( "directory", out var d ) && !string.IsNullOrWhiteSpace( d.GetString() ) ? d.GetString() : "Code";

			float charsPerSecond = p.TryGetProperty( "charsPerSecond", out var cps ) && cps.TryGetSingle( out var cpsf ) ? cpsf : 40f;
			if ( charsPerSecond < 1f ) charsPerSecond = 1f;
			string advanceAction = CinematicsHelpers.SanitizeAction(
				p.TryGetProperty( "advanceAction", out var aa ) ? aa.GetString() : null, "use" );

			var code = BuildDialogueCode( className, charsPerSecond, advanceAction, ci );
			ScaffoldHelpers.WriteCode( fullPath, code );

			// The paired HUD panel (razor + scss), razor_lint-safe by construction.
			var panelClass = className + "Panel";
			var razorRel = $"{directory}/{panelClass}.razor";
			var scssRel  = $"{directory}/{panelClass}.razor.scss";

			object panelFiles = null;
			if ( ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, panelClass + ".razor" ), out var razorPath, out var rErr )
			  && ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, panelClass + ".razor.scss" ), out var scssPath, out var sErr ) )
			{
				if ( File.Exists( razorPath ) )
				{
					panelFiles = new { skipped = $"{razorRel} already exists -- panel not overwritten." };
				}
				else
				{
					ScaffoldHelpers.WriteCode( razorPath, BuildDialogueRazor( className, panelClass ) );
					ScaffoldHelpers.WriteCode( scssPath,  BuildDialogueScss() );
					panelFiles = new { razor = razorRel, scss = scssRel, className = panelClass };
				}
			}

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				charsPerSecond,
				advanceAction,
				panel = panelFiles,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} and {panelClass} into the game assembly.",
					$"Attach {className} to a GameObject (add_component_with_properties component=\"{className}\"). Fill its Lines list with \"Speaker: text\" strings in the inspector.",
					$"Attach {panelClass} under a ScreenPanel so the HUD renders: add_screen_panel, then add_component_with_properties component=\"{panelClass}\". It binds to {className}.Current automatically -- no wiring.",
					$"Start dialogue from any game code: {className}.StartDialogue(new[]{{\"Guide: Welcome.\", \"Guide: Press {advanceAction} to continue.\"}}) or set Lines then call instance Begin().",
					$"Press the AdvanceAction ('{advanceAction}') to complete the typewriter reveal, then again to advance; the panel folds VisibleText into BuildHash so it re-renders as characters appear.",
					$"Hooks: subscribe {className}.OnLineShown(index, speaker) for lipsync/audio per line (pair with add_lipsync), and {className}.OnDialogueFinished for the end. Pairs with create_interactable to trigger dialogue on use.",
					"LOCAL-only (per-client HUD): call StartDialogue inside an [Rpc.Broadcast] if every client should see the conversation."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_dialogue_system failed: {ex.Message}" } );
		}
	}

	static string BuildDialogueCode( string className, float charsPerSecond, string advanceAction, System.Globalization.CultureInfo ci )
	{
		string cps = charsPerSecond.ToString( ci ) + "f";

		return $@"using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// {className} -- NPC/story dialogue state + data. Pairs with the generated
/// {className}Panel Razor HUD, which reads {className}.Current and renders the
/// current line with a typewriter reveal.
///
/// Lines use the ""Speaker: text"" convention (the part before the first colon is
/// the speaker; the rest is the line). Press the AdvanceAction once to finish
/// the reveal instantly, again to move to the next line; the last line ends the
/// conversation and fires OnDialogueFinished.
///
/// Start from anywhere:  {className}.StartDialogue( new[] {{ ""Bob: Hi."", ""Bob: Bye."" }} );
/// LOCAL-only -- call inside an [Rpc.Broadcast] for all clients.
/// </summary>
public sealed class {className} : Component
{{
	/// Dialogue lines, ""Speaker: text"" each. Editable in the inspector.
	[Property] public List<string> Lines {{ get; set; }} = new List<string>();

	/// Typewriter reveal speed (characters per second).
	[Property] public float CharsPerSecond {{ get; set; }} = {cps};

	/// Input action that advances the dialogue (completes the reveal, then next line).
	[Property] public string AdvanceAction {{ get; set; }} = ""{advanceAction}"";

	/// <summary>Fires when the last line is dismissed.</summary>
	public static event Action OnDialogueFinished;

	/// <summary>Fires as each line begins -- (lineIndex, speaker). Hook for lipsync/audio.</summary>
	public static event Action<int, string> OnLineShown;

	/// <summary>The most-recently-enabled instance -- what the HUD and StartDialogue use.</summary>
	public static {className} Current {{ get; private set; }}

	public bool IsActive {{ get; private set; }}
	public int CurrentIndex {{ get; private set; }}
	public string CurrentSpeaker {{ get; private set; }} = """";

	private string _currentText = """";
	private TimeSince _sinceLine;
	private bool _forceComplete;

	protected override void OnEnabled()
	{{
		Current = this;
	}}

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

	/// <summary>Set Lines and begin, using the active instance.</summary>
	public static void StartDialogue( string[] lines )
	{{
		if ( Current == null )
		{{
			Log.Warning( ""{className}.StartDialogue: no active {className} in the scene."" );
			return;
		}}
		Current.Lines = new List<string>( lines ?? new string[0] );
		Current.Begin();
	}}

	/// <summary>Begin this instance's dialogue from the first line.</summary>
	public void Begin()
	{{
		if ( Lines == null || Lines.Count == 0 )
		{{
			Log.Warning( ""{className}: no lines to show."" );
			return;
		}}
		IsActive = true;
		ShowLine( 0 );
	}}

	private void ShowLine( int index )
	{{
		CurrentIndex = index;
		ParseLine( Lines[index], out var speaker, out var text );
		CurrentSpeaker = speaker;
		_currentText = text;
		_sinceLine = 0f;
		_forceComplete = false;
		OnLineShown?.Invoke( index, speaker );
	}}

	protected override void OnUpdate()
	{{
		if ( !IsActive ) return;
		if ( string.IsNullOrEmpty( AdvanceAction ) ) return;
		if ( !Input.Pressed( AdvanceAction ) ) return;

		if ( !IsLineComplete )
		{{
			// Still typing -- first press snaps the whole line into view.
			_forceComplete = true;
			return;
		}}

		int next = CurrentIndex + 1;
		if ( next >= Lines.Count )
		{{
			End();
			return;
		}}
		ShowLine( next );
	}}

	private void End()
	{{
		IsActive = false;
		OnDialogueFinished?.Invoke();
	}}

	/// <summary>Characters revealed so far for the current line (the typewriter output).</summary>
	public string VisibleText
	{{
		get
		{{
			if ( !IsActive || string.IsNullOrEmpty( _currentText ) ) return """";
			if ( _forceComplete ) return _currentText;
			int n = (int)( (float)_sinceLine * CharsPerSecond );
			if ( n < 0 ) n = 0;
			if ( n >= _currentText.Length ) return _currentText;
			return _currentText.Substring( 0, n );
		}}
	}}

	/// <summary>True once the whole current line is visible.</summary>
	public bool IsLineComplete
		=> _forceComplete || string.IsNullOrEmpty( _currentText ) || ( (float)_sinceLine * CharsPerSecond ) >= _currentText.Length;

	private static void ParseLine( string raw, out string speaker, out string text )
	{{
		raw = raw ?? """";
		int idx = raw.IndexOf( ':' );
		if ( idx > 0 )
		{{
			speaker = raw.Substring( 0, idx ).Trim();
			text = raw.Substring( idx + 1 ).Trim();
		}}
		else
		{{
			speaker = """";
			text = raw.Trim();
		}}
	}}
}}
";
	}

	static string BuildDialogueRazor( string dialogueClass, string panelClass )
	{
		return $@"@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent;

@* {panelClass} -- typewriter dialogue HUD bound to {dialogueClass}.Current.
   Host under a ScreenPanel (add_screen_panel). Renders nothing when no dialogue
   is active. VisibleText is folded into BuildHash so the panel re-renders as the
   typewriter advances. *@

@if ( Dlg != null && Dlg.IsActive )
{{
	<div class=""dialogue-box"">
		<div class=""speaker"">@Dlg.CurrentSpeaker</div>
		<div class=""line"">@Dlg.VisibleText</div>
		<div class=""advance-hint"">@HintText</div>
	</div>
}}

@code {{
	private {dialogueClass} Dlg => {dialogueClass}.Current;

	private string HintText => ( Dlg != null && Dlg.IsLineComplete ) ? ""Continue"" : """";

	protected override int BuildHash()
	{{
		if ( Dlg == null ) return 0;
		return System.HashCode.Combine( Dlg.IsActive, Dlg.CurrentSpeaker, Dlg.VisibleText );
	}}
}}
";
	}

	static string BuildDialogueScss()
	{
		return @".dialogue-box {
	position: absolute;
	bottom: 60px;
	left: 50%;
	transform: translateX(-50%);
	display: flex;
	flex-direction: column;
	width: 640px;
	max-width: 80%;
	padding: 16px 20px;
	background-color: rgba(0,0,0,0.8);
	border-radius: 6px;
	border: 1px solid rgba(255,255,255,0.15);
}

.dialogue-box .speaker {
	font-size: 18px;
	font-weight: bold;
	color: #ffd873;
	margin-bottom: 6px;
}

.dialogue-box .line {
	font-size: 20px;
	color: white;
	white-space: pre-wrap;
}

.dialogue-box .advance-hint {
	align-self: flex-end;
	margin-top: 8px;
	font-size: 14px;
	color: rgba(255,255,255,0.5);
}
";
	}
}

/// <summary>
/// Shared helpers for the cinematics handlers.
/// </summary>
internal static class CinematicsHelpers
{
	/// <summary>
	/// Keep an input-action name to a safe token (letters/digits/underscore) so it
	/// can't break the generated string literal; fall back to a default if empty.
	/// </summary>
	public static string SanitizeAction( string s, string fallback )
	{
		if ( string.IsNullOrWhiteSpace( s ) ) return fallback;
		var sb = new StringBuilder( s.Length );
		foreach ( var ch in s )
		{
			if ( char.IsLetterOrDigit( ch ) || ch == '_' ) sb.Append( ch );
		}
		var cleaned = sb.ToString();
		return string.IsNullOrEmpty( cleaned ) ? fallback : cleaned;
	}
}