Systems/Player/ScriptPlayer.cs
using Sandbox;
using System;
using System.IO;
using System.Linq;
using System.Threading;
using VNBase.UI;
using Script = VNScript.Script;
namespace VNBase;
/// <summary>
/// Responsible for handling visual novel base scripts.
/// </summary>
[Title( "VN Script Player" )]
[Category( "VNBase" )]
[Icon( "menu_book" )]
public sealed partial class ScriptPlayer : Component
{
/// <summary>
/// The currently active script.
/// </summary>
public Assets.Script? ActiveScript { get; private set; }
/// <summary>
/// The currently active script label.
/// </summary>
public Script.Label? ActiveLabel { get; private set; }
/// <summary>
/// If there is an active playing script.
/// </summary>
[Property]
public bool IsScriptActive { get; private set; }
/// <summary>
/// If not empty, will load the script asset at this path on initial component start.
/// </summary>
[Property, Group( "Script" ), FilePath( Extension = SupportedExtensions )]
public string? InitialScript { get; set; }
/// <summary>
/// The active <see cref="ScriptState"/>.
/// </summary>
/// <seealso cref="ScriptState"/>
[Property, Group( "Script" )]
public ScriptState State { get; } = new();
/// <summary>
/// Automatic mode moves through dialogues without choices automatically.
/// </summary>
[Property, Group( "Dialogue" )]
public bool IsAutomaticMode { get; set; }
/// <summary>
/// If automatic mode can be enabled.
/// </summary>
[Property, Group( "Dialogue" )]
public bool IsAutomaticModeAvailable { get; set; } = true;
[Property, RequireComponent, Group( "Components" )]
public VNHud? Hud { get; set; }
[Property]
public Settings Settings { get; } = new();
private Script? _activeDialogue;
private CancellationTokenSource? _cts;
/// <summary>
/// Value-separated by comma, list of supported script files by extension.
/// </summary>
private const string SupportedExtensions = "vnscript,json";
protected override void OnStart()
{
if ( !string.IsNullOrEmpty( InitialScript ) )
{
LoadScript( InitialScript );
}
if ( !Scene.GetAllComponents<VNHud>().Any() )
{
Log.Warning( "No VNHud Component found, ScriptPlayer will not be immediately visible!" );
}
}
private bool SkipActionPressed => Settings.SkipActions.Any( x => x.Pressed );
protected override void OnUpdate()
{
if ( ActiveScript is null || ActiveLabel is null )
{
return;
}
if ( !Settings.SkipActionEnabled )
{
return;
}
if ( SkipActionPressed )
{
AdvanceOrSkipDialogueEffect();
}
else if ( IsAutomaticMode )
{
if ( State is { IsDialogueFinished: true, Choices.Count: 0 } )
{
AdvanceText();
}
}
}
/// <summary>
/// Read and load the script at the provided path.
/// </summary>
/// <param name="path">Path to the script to load.</param>
// ReSharper disable once MemberCanBePrivate.Global
public void LoadScript( string path )
{
var extension = Path.GetExtension( path )?.ToLowerInvariant();
Assets.Script script;
if ( extension == ".json" )
{
script = new Assets.JsonScript( path );
}
else
{
script = new Assets.Script( path );
}
var dialogue = FileSystem.Mounted.ReadAllText( path );
if ( dialogue is null )
{
Log.Error( $"Unable to load script! Script file couldn't be found by path: {path}" );
return;
}
if ( !string.IsNullOrEmpty( dialogue ) )
{
LoadScript( script );
}
else
{
Log.Error( "Unable to load script! The script file is empty." );
}
}
/// <summary>
/// Load the provided Script object.
/// </summary>
/// <param name="script">Script to load.</param>
// ReSharper disable once MemberCanBePrivate.Global
public void LoadScript( Assets.Script script )
{
var scriptName = string.Empty;
if ( script.FromFile )
{
scriptName = Path.GetFileNameWithoutExtension( script.Path );
}
if ( LoggingEnabled )
{
Log.Info( $"Loading script: {scriptName}" );
}
if ( Settings.StopMusicPlaybackOnUnload )
{
// Stop any playing background music.
State.BackgroundMusic?.Stop();
}
ActiveScript = script;
_activeDialogue = ActiveScript.Parse();
script.OnLoad();
OnScriptLoad?.Invoke( script );
SetEnvironment( _activeDialogue );
SetLabel( _activeDialogue.InitialLabel );
IsScriptActive = true;
}
/// <summary>
/// Unloads the currently active script.
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public void UnloadScript()
{
if ( ActiveScript is null || ActiveLabel is null )
{
return;
}
// Safety check. Should hopefully not cause issues.
if ( ActiveScript.OnChoiceSelected is not null )
{
foreach ( var @delegate in ActiveScript.OnChoiceSelected.GetInvocationList() )
{
ActiveScript.OnChoiceSelected -= (Action<Script.Choice>)@delegate;
}
}
State.Clear();
ActiveScript.OnUnload();
OnScriptUnload?.Invoke( ActiveScript );
IsScriptActive = false;
var nextScript = ActiveScript.NextScript;
if ( nextScript is not null )
{
LoadScript( nextScript );
}
else
{
ActiveScript = null;
}
if ( LoggingEnabled )
{
Log.Info( $"Unloaded active script." );
}
}
/// <summary>
/// Skip the currently active text effect.
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public void SkipDialogueEffect()
{
if ( ActiveScript is null || ActiveLabel is null )
{
return;
}
if ( IsAutomaticMode )
{
return;
}
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
/// <summary>
/// If the dialogue isn't finished, skip the effect, otherwise just advance if we can.
/// </summary>
public void AdvanceOrSkipDialogueEffect()
{
if ( !State.IsDialogueFinished )
{
SkipDialogueEffect();
}
else if ( State.Choices.Count == 0 )
{
AdvanceText();
}
}
}