Code/InteractiveComputer/InteractiveComputerComponent.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text.Json;
using PaneOS.InteractiveComputer.Core;
using Sandbox;
namespace PaneOS.InteractiveComputer;
public sealed class InteractiveComputerComponent : Component
{
private static readonly Dictionary<string, ComputerState> statesByComputerId = new();
private static readonly Dictionary<GameObject, InteractiveComputerComponent> activeComputersByPlayer = new();
[Property] public string ComputerId { get; set; } = "computer-01";
[Property] public bool UseGameSettingsResolution { get; set; } = true;
[Property] public int ResolutionX { get; set; } = 1024;
[Property] public int ResolutionY { get; set; } = 768;
[Property] public bool StartsSleeping { get; set; }
[Property] public float RamGb { get; set; } = 2f;
[Property] public float CpuCoreGhz { get; set; } = 3.7f;
[Property] public int CpuCoreCount { get; set; } = 4;
[Property] public float HddStorageGb { get; set; } = 256f;
[Property] public float InternetSpeedGbps { get; set; } = 100f;
[Property] public float GpuCoreGhz { get; set; } = 1.54f;
[Property] public float GpuVramGb { get; set; } = 4f;
[Property] public bool SimulateCpuInputDelayWhenMaxed { get; set; } = true;
[Property] public bool ScreenSaverEnabled { get; set; } = true;
[Property] public float ScreenSaverDelaySeconds { get; set; } = 60f;
[Property] public Vector2 ScreenSaverLogoSize { get; set; } = new( 220f, 72f );
[Property] public Vector2 ScreenSaverVelocity { get; set; } = new( 160f, -120f );
[Property] public string ThemeName { get; set; } = "default";
[Property] public string ExitInteractionInputAction { get; set; } = "use";
[Property] public bool InstallAllAppsWhenListIsEmpty { get; set; } = true;
[Property, TextArea] public string InstalledAppIds { get; set; } = "";
[Property] public string ExplorerArchivePath { get; set; } = "";
[Property, TextArea] public string SavedStateJson { get; set; } = "";
public ComputerRuntime Runtime { get; private set; } = null!;
public bool IsPlayerInteracting { get; private set; }
public GameObject? InteractingPlayer { get; private set; }
private int pendingExitRefreshFrames;
public static InteractiveComputerComponent? GetActiveComputerForPlayer( GameObject? player )
{
if ( player is null )
return null;
return activeComputersByPlayer.GetValueOrDefault( player );
}
protected override void OnAwake()
{
var state = LoadState();
Runtime = new ComputerRuntime( this, state );
}
protected override void OnUpdate()
{
RefreshResolutionFromSettings();
Runtime?.TickScreenSaver( Time.Delta, IsPlayerInteracting );
Runtime?.TickSystem( Time.Delta );
if ( pendingExitRefreshFrames > 0 )
{
pendingExitRefreshFrames--;
Runtime?.MarkChanged();
}
if ( IsPlayerInteracting &&
!string.IsNullOrWhiteSpace( ExitInteractionInputAction ) &&
Input.Pressed( ExitInteractionInputAction ) )
EndInteraction();
}
public void BeginInteraction( GameObject? player )
{
if ( player is not null && activeComputersByPlayer.TryGetValue( player, out var activeComputer ) && activeComputer != this )
activeComputer.EndInteraction();
InteractingPlayer = player;
IsPlayerInteracting = true;
if ( player is not null )
activeComputersByPlayer[player] = this;
Runtime.DisableScreenSaverWhileInteracting();
Runtime.Wake();
Runtime.RefreshWindowAppSessions();
Runtime.MarkChanged();
}
public void EndInteraction()
{
if ( InteractingPlayer is not null &&
activeComputersByPlayer.TryGetValue( InteractingPlayer, out var activeComputer ) &&
activeComputer == this )
{
activeComputersByPlayer.Remove( InteractingPlayer );
}
IsPlayerInteracting = false;
InteractingPlayer = null;
Runtime.ResetScreenSaverIdle();
Runtime.RefreshWindowAppSessions();
Runtime.MarkChanged();
pendingExitRefreshFrames = 10;
}
public void ToggleInteraction( GameObject? player )
{
if ( IsPlayerInteracting )
EndInteraction();
else
BeginInteraction( player );
}
public string ExportStateJson()
{
return JsonSerializer.Serialize( Runtime.State, JsonOptions );
}
public bool ImportStateJson( string stateJson )
{
try
{
var state = JsonSerializer.Deserialize<ComputerState>( stateJson, JsonOptions );
if ( state is null )
return false;
statesByComputerId[ComputerId] = state;
Runtime = new ComputerRuntime( this, state );
SavedStateJson = JsonSerializer.Serialize( state, JsonOptions );
return true;
}
catch ( Exception ex )
{
Log.Warning( $"Failed to import computer state for {ComputerId}: {ex.Message}" );
return false;
}
}
internal void StoreState()
{
if ( Runtime is null )
return;
statesByComputerId[ComputerId] = Runtime.State;
SavedStateJson = ExportStateJson();
}
public string ReadArchiveTextFile( string virtualPath )
{
EnsureArchiveReady();
return PaneArchiveFileSystem.ReadTextFile( ResolveArchivePath(), ParseVirtualPath( virtualPath ) );
}
public void WriteArchiveTextFile( string virtualPath, string content )
{
EnsureArchiveReady();
PaneArchiveFileSystem.WriteTextFile( ResolveArchivePath(), ParseVirtualPath( virtualPath ), content );
Runtime?.RefreshTransientUi();
}
public IReadOnlyList<string> ListArchiveItems( string virtualPath )
{
EnsureArchiveReady();
return PaneArchiveFileSystem.GetItems( ResolveArchivePath(), ParseVirtualPath( virtualPath ) )
.Select( x => x.VirtualPath )
.ToArray();
}
public void CreateArchiveFolder( string parentVirtualPath, string folderName )
{
EnsureArchiveReady();
PaneArchiveFileSystem.CreateFolder( ResolveArchivePath(), ParseVirtualPath( parentVirtualPath ), folderName );
Runtime?.RefreshTransientUi();
}
public void CreateArchiveFile( string parentVirtualPath, string fileName, string extension, string content = "" )
{
EnsureArchiveReady();
PaneArchiveFileSystem.CreateFile( ResolveArchivePath(), ParseVirtualPath( parentVirtualPath ), fileName, extension, content );
Runtime?.RefreshTransientUi();
}
private ComputerState LoadState()
{
if ( statesByComputerId.TryGetValue( ComputerId, out var existingState ) )
return existingState;
ComputerState? state = null;
var loadedFromSavedState = false;
if ( !string.IsNullOrWhiteSpace( SavedStateJson ) )
{
try
{
state = JsonSerializer.Deserialize<ComputerState>( SavedStateJson, JsonOptions );
loadedFromSavedState = state is not null;
}
catch ( Exception ex )
{
Log.Warning( $"Failed to parse saved computer state for {ComputerId}: {ex.Message}" );
}
}
state ??= new ComputerState
{
IsSleeping = StartsSleeping
};
ApplyConfiguredResolution( state );
ApplyCreationScreenSaverSettings( state, loadedFromSavedState );
ApplyCreationAppList( state, loadedFromSavedState );
statesByComputerId[ComputerId] = state;
return state;
}
public void ResetStateFromCreationSettings()
{
var state = new ComputerState
{
IsSleeping = StartsSleeping
};
ApplyConfiguredResolution( state );
ApplyCreationScreenSaverSettings( state, false );
ApplyCreationAppList( state, false );
statesByComputerId[ComputerId] = state;
Runtime = new ComputerRuntime( this, state );
StoreState();
}
internal string ResolveSteamDisplayName()
{
var name = InteractingPlayer?.Name;
if ( string.IsNullOrWhiteSpace( name ) )
return "Player";
const string playerPrefix = "Player - ";
if ( name.StartsWith( playerPrefix, StringComparison.OrdinalIgnoreCase ) )
return name[playerPrefix.Length..];
return name;
}
internal string ResolvePersistentArchiveUserName( ComputerState state )
{
if ( !string.IsNullOrWhiteSpace( state.ArchiveUserName ) )
return state.ArchiveUserName;
var archiveUserNamePath = ResolveArchiveUserNamePath();
if ( ComputerSandboxStorage.FileExists( archiveUserNamePath ) )
{
var persistedValue = PaneArchiveFileSystem.NormalizeDisplayName( ComputerSandboxStorage.ReadAllText( archiveUserNamePath ).Trim() );
if ( !string.IsNullOrWhiteSpace( persistedValue ) )
{
state.ArchiveUserName = persistedValue;
StoreState();
return state.ArchiveUserName;
}
}
state.ArchiveUserName = ComputerArchiveUserPolicy.ResolveInitialUserName(
ResolveSteamDisplayName(),
ComputerSandboxStorage.GetLocalUserNameFallback() );
ComputerSandboxStorage.WriteAllText( archiveUserNamePath, state.ArchiveUserName );
StoreState();
return state.ArchiveUserName;
}
internal string ResolveArchivePath()
{
return ComputerSandboxStorage.ResolveArchiveStoragePath( ComputerId, ExplorerArchivePath );
}
internal string ResolveArchiveUserNamePath()
{
return ComputerSandboxStorage.ResolveArchiveUserNameStoragePath( ResolveArchivePath() );
}
private void ApplyCreationAppList( ComputerState state, bool loadedFromSavedState )
{
if ( loadedFromSavedState && state.InstalledApps.Count > 0 )
return;
var configuredIds = ParseInstalledAppIds();
var appIds = configuredIds.Count > 0
? configuredIds
: InstallAllAppsWhenListIsEmpty
? ComputerAppRegistry.Apps.Select( x => x.Id ).ToList()
: new List<string>();
if ( appIds.Count == 0 )
return;
var existingSettings = state.InstalledApps
.GroupBy( x => x.AppId )
.ToDictionary( x => x.Key, x => x.First().Settings );
state.InstalledApps = appIds
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Distinct()
.Select( x => new ComputerInstalledAppState
{
AppId = x,
Settings = existingSettings.TryGetValue( x, out var settings ) ? settings : new Dictionary<string, string>()
} )
.ToList();
state.OpenApps.RemoveAll( x => !state.InstalledApps.Any( app => app.AppId == x.AppId ) );
}
private void ApplyCreationScreenSaverSettings( ComputerState state, bool loadedFromSavedState )
{
ApplyHardwareSettings( state );
if ( loadedFromSavedState )
{
state.ScreenSaver.DelaySeconds = MathF.Max( 1f, state.ScreenSaver.DelaySeconds );
state.ScreenSaver.LogoWidth = MathF.Max( 1f, state.ScreenSaver.LogoWidth );
state.ScreenSaver.LogoHeight = MathF.Max( 1f, state.ScreenSaver.LogoHeight );
return;
}
state.ScreenSaver.Enabled = ScreenSaverEnabled;
state.ScreenSaver.DelaySeconds = MathF.Max( 1f, ScreenSaverDelaySeconds );
state.ScreenSaver.LogoWidth = MathF.Max( 1f, ScreenSaverLogoSize.x );
state.ScreenSaver.LogoHeight = MathF.Max( 1f, ScreenSaverLogoSize.y );
state.ScreenSaver.VelocityX = ScreenSaverVelocity.x == 0f ? 160f : ScreenSaverVelocity.x;
state.ScreenSaver.VelocityY = ScreenSaverVelocity.y == 0f ? -120f : ScreenSaverVelocity.y;
state.ScreenSaver.LogoX = MathF.Max( 0f, (state.ResolutionX - state.ScreenSaver.LogoWidth) * 0.5f );
state.ScreenSaver.LogoY = MathF.Max( 0f, (state.ResolutionY - state.ScreenSaver.LogoHeight) * 0.5f );
}
private void ApplyConfiguredResolution( ComputerState state )
{
var resolution = ResolveConfiguredResolution();
state.ResolutionX = resolution.X;
state.ResolutionY = resolution.Y;
}
private void ApplyHardwareSettings( ComputerState state )
{
state.Hardware.RamGb = MathF.Max( 0.25f, RamGb );
state.Hardware.CpuCoreGhz = MathF.Max( 0.1f, CpuCoreGhz );
state.Hardware.CpuCoreCount = Math.Max( 1, CpuCoreCount );
state.Hardware.HddStorageGb = MathF.Max( 1f, HddStorageGb );
state.Hardware.InternetSpeedGbps = MathF.Max( 0.1f, InternetSpeedGbps );
state.Hardware.GpuCoreGhz = MathF.Max( 0.1f, GpuCoreGhz );
state.Hardware.GpuVramGb = MathF.Max( 0.25f, GpuVramGb );
state.Hardware.SimulateCpuInputDelayWhenMaxed = SimulateCpuInputDelayWhenMaxed;
}
private List<string> ParseInstalledAppIds()
{
return InstalledAppIds
.Split( new[] { ',', ';', '\r', '\n', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.ToList();
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private void EnsureArchiveReady()
{
PaneArchiveFileSystem.EnsureArchive( ResolveArchivePath(), ResolvePersistentArchiveUserName( Runtime?.State ?? LoadState() ), Runtime?.Apps ?? ComputerAppRegistry.Apps );
}
private void RefreshResolutionFromSettings()
{
if ( Runtime is null )
return;
var resolution = ResolveConfiguredResolution();
if ( Runtime.State.ResolutionX == resolution.X && Runtime.State.ResolutionY == resolution.Y )
return;
Runtime.State.ResolutionX = resolution.X;
Runtime.State.ResolutionY = resolution.Y;
ScreenSaverSimulator.ClampLogo( Runtime.State );
Runtime.MarkChanged();
}
private (int X, int Y) ResolveConfiguredResolution()
{
return ComputerResolutionPolicy.ResolveResolution( UseGameSettingsResolution, ResolutionX, ResolutionY, Screen.Width, Screen.Height );
}
private static IReadOnlyList<string> ParseVirtualPath( string virtualPath )
{
return virtualPath
.Trim()
.TrimStart( '/' )
.Split( '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
}
}