Editor/client console commands for the Skafinity music system. Builds a local, non-networked test rig (player + UI panel), exposes commands to spawn/despawn/toggle the panel, control playback (play seed, next, prev, pause, shuffle, reroll, station), change theme and genre, save audio to file, and print status/explanation.
using System.Linq;
using Sandbox;
namespace Skafinity;
/// <summary>
/// Console commands for driving the player and the panel from inside the editor.
///
/// <para>They exist because the two things a host most needs to try are the two things it cannot
/// reach without writing code first. The board <b>ships no launcher</b> — visibility is
/// host-driven on purpose, so it imposes nothing on your HUD — which means a freshly-dropped
/// <see cref="SkafinityMusicPanel"/> renders nothing at all until you have bound
/// <see cref="SkafinityMusicPanel.IsOpen"/> to something. And <see cref="SkafinityTheme.Accent"/>
/// is a static a game sets at startup, so seeing what your colour looks like used to mean a
/// rebuild per guess. <c>skafinity_panel</c> and <c>skafinity_theme</c> are those two, live.</para>
///
/// <para>The rest are the seed: play one, step it, switch genre, reroll, and read back either the
/// player's state or what the composer actually decided.</para>
///
/// <para>s&box-only (outside <c>Code/Engine/</c>), and client-side — the player is client-only
/// (<c>DontExecuteOnServer</c>), so these are too.</para>
/// </summary>
public static class SkafinityCommands
{
/// <summary>Name of the GameObject <see cref="Spawn"/> builds. Also how <see cref="Despawn"/>
/// finds it again, so it only ever destroys its own rig and never a scene-authored player.</summary>
const string RigName = "Skafinity (console)";
// Every command needs the player, and "there isn't one" is the single most likely reason a
// command does nothing — so say so rather than failing silently.
static SkafinityPlayer Player()
{
var scene = Sandbox.Game.ActiveScene;
if ( scene == null ) { Log.Warning( "[Skafinity] no active scene." ); return null; }
var p = scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault();
if ( p == null )
Log.Warning( "[Skafinity] no SkafinityPlayer in the scene — run skafinity_spawn for a "
+ "throwaway one, or add the component to a GameObject yourself." );
return p;
}
/// <summary>Build a throwaway board (and a player, if the scene hasn't got one) on a runtime
/// GameObject, so the library can be tried in ANY scene without one being authored for it.
/// <c>skafinity_despawn</c> removes it.</summary>
/// <remarks>It never makes a SECOND of anything: a board already in the scene — a previous rig
/// or the game's own UI — is the one it hands back, and a player already in the scene is the
/// one the board drives. So this is safe to run in a game that has Skafinity wired up properly,
/// where it does nothing but tell you so.</remarks>
/// <remarks>Client-local and never saved: <c>NetworkMode.Never</c> so it is not replicated,
/// <c>GameObjectFlags.NotSaved</c> so it cannot end up committed in someone's scene file. It
/// carries its OWN <c>ScreenPanel</c> rather than hunting for the scene's, which is what makes
/// it work in a scene that has no UI root at all. Shape copied from rotaliate-client's
/// <c>LocalMusicSystem</c>, which builds the same three components for real.</remarks>
[ConCmd( "skafinity_spawn" )]
public static void Spawn()
{
var panel = BuildRig( out bool created );
if ( panel == null ) return;
Log.Info( created
? $"[Skafinity] rig ready — '{RigName}'. skafinity_panel opens it, skafinity_despawn removes it."
: $"[Skafinity] nothing spawned — this scene already has a board, on '{panel.GameObject?.Name}'. "
+ "skafinity_panel opens it." );
}
/// <summary>Destroy the rig <c>skafinity_spawn</c> built. Leaves a scene-authored player alone —
/// it only removes the GameObject it made.</summary>
[ConCmd( "skafinity_despawn" )]
public static void Despawn()
{
var scene = Sandbox.Game.ActiveScene;
if ( scene == null ) { Log.Warning( "[Skafinity] no active scene." ); return; }
var rig = FindRig( scene );
if ( rig == null ) { Log.Info( "[Skafinity] no console rig to remove." ); return; }
rig.Destroy();
Log.Info( $"[Skafinity] removed '{RigName}'." );
}
/// <summary>Open/close the settings board — the panel ships no launcher of its own, so this is
/// how you see it at all. Builds a rig first if the scene has no board (see
/// <see cref="Spawn"/>), so this one command works in an empty scene.</summary>
[ConCmd( "skafinity_panel" )]
public static void TogglePanel()
{
// Nothing to toggle rather than nothing to do: the point of the command is to see the board,
// and needing a scene authored first is the whole problem it exists to solve. BuildRig
// returns the scene's own board where there is one, so this never makes a second.
var panel = BuildRig( out bool created );
if ( panel == null ) return;
if ( created )
Log.Info( $"[Skafinity] no board in the scene — built '{RigName}'. skafinity_despawn removes it." );
panel.Toggle();
Log.Info( $"[Skafinity] board {( panel.IsOpen ? "OPEN" : "closed" )}." );
}
// The rig itself. Returns the board to drive — an existing one wherever there is one — or null
// with a reason logged. `created` says whether anything was actually built.
static SkafinityMusicPanel BuildRig( out bool created )
{
created = false;
var scene = Sandbox.Game.ActiveScene;
if ( scene == null ) { Log.Warning( "[Skafinity] no active scene." ); return null; }
// A play-mode thing. In the editor's edit scene there is no audio to hear and no reason to
// be adding objects to what someone is authoring, even unsaveable ones.
if ( scene.IsEditor )
{
Log.Warning( "[Skafinity] press play first — the rig is built into the running scene, not the edit scene." );
return null;
}
// A headless server has no audio device and no screen; the player is DontExecuteOnServer
// for the same reason, so building it there would produce an inert object and a puzzle.
if ( Application.IsDedicatedServer )
{
Log.Warning( "[Skafinity] not on a dedicated server — Skafinity is client-side (audio + UI)." );
return null;
}
// NEVER a second board. Any panel already in the scene is the one to drive, whether it is a
// previous rig or one the game authored — two boards would be two sets of controls over one
// player, and the second would be the game's own UI duplicated by a debug command.
var existing = scene.GetAllComponents<SkafinityMusicPanel>().FirstOrDefault();
if ( existing != null ) return existing;
created = true;
var go = new GameObject( true, RigName ) { Flags = GameObjectFlags.NotSaved };
go.NetworkMode = NetworkMode.Never; // strictly local: this is a test rig, not game state
// Its own UI root, so this works in a scene with no ScreenPanel of its own.
go.Components.Create<ScreenPanel>();
// Reuse a player the scene already has rather than starting a second soundtrack over it.
var player = scene.GetAllComponents<SkafinityPlayer>().FirstOrDefault()
?? go.Components.Create<SkafinityPlayer>();
var panel = go.Components.Create<SkafinityMusicPanel>();
panel.Player = player;
return panel;
}
// Only ever OUR GameObject: matched by name, so a scene-authored player is never a candidate.
static GameObject FindRig( Scene scene )
{
foreach ( var p in scene.GetAllComponents<SkafinityMusicPanel>() )
if ( p.GameObject != null && p.GameObject.Name == RigName )
return p.GameObject;
return null;
}
/// <summary>Retint the board from one colour: <c>skafinity_theme #ff8a3d</c>. Pass
/// <c>clear</c> (or <c>none</c> / <c>neutral</c>) to go back to the neutral gray/black default.
/// This is the whole of what a consuming game does — it sets
/// <see cref="SkafinityTheme.Accent"/> once — so what you see here is what you get by shipping
/// that one line.</summary>
[ConCmd( "skafinity_theme" )]
public static void SetTheme( string accent )
{
if ( string.IsNullOrWhiteSpace( accent ) || accent is "clear" or "none" or "neutral" )
{
SkafinityTheme.Accent = null;
Log.Info( "[Skafinity] theme cleared — neutral gray/black (the library default)." );
return;
}
var c = Color.Parse( accent );
if ( c == null )
{
Log.Warning( $"[Skafinity] couldn't parse '{accent}' as a colour — try a hex like #2f9450." );
return;
}
SkafinityTheme.Accent = c;
Log.Info( $"[Skafinity] accent = {accent}. In your game: SkafinityTheme.Accent = Color.Parse( \"{accent}\" );" );
}
/// <summary>Play a seed: <c>tag:n[:genre][:vibe]</c> (a bare <c>tag</c> is song 0). Pass
/// <c>default</c> to go back to the default tag and vibe.</summary>
[ConCmd( "skafinity_seed" )]
public static void PlaySeed( string seed )
{
var p = Player();
if ( p == null ) return;
if ( seed is "default" ) { p.SetTag( "" ); Log.Info( "[Skafinity] back to the default tag and vibe." ); return; }
p.PlaySeed( seed );
Log.Info( $"[Skafinity] playing {p.CurrentSeed}" );
}
/// <summary>Next song in the sequence.</summary>
[ConCmd( "skafinity_next" )]
public static void Next() { var p = Player(); if ( p == null ) return; p.NextSong(); Log.Info( $"[Skafinity] → {p.CurrentSeed}" ); }
/// <summary>Previous song — replays the exact earlier song, not a fresh one.</summary>
[ConCmd( "skafinity_prev" )]
public static void Prev() { var p = Player(); if ( p == null ) return; p.PrevSong(); Log.Info( $"[Skafinity] ← {p.CurrentSeed}" ); }
/// <summary>Switch genre by index. Run it with a junk index to print the roster.</summary>
[ConCmd( "skafinity_genre" )]
public static void SetGenre( int genre )
{
var p = Player();
if ( p == null ) return;
if ( genre < 0 || genre >= VibeCodec.GenreCount )
{
Log.Warning( $"[Skafinity] genre {genre} is out of range. {Roster()}" );
return;
}
p.SetGenre( genre );
Log.Info( $"[Skafinity] genre {genre} = {VibeCodec.Genres[genre]} — {p.CurrentSeed}" );
}
/// <summary>Reroll the vibe: every knob thrown somewhere new and PINNED there, keeping the genre
/// and your per-instrument volumes. <c>skafinity_station</c> is the other die — a different song
/// rather than a different taste.</summary>
[ConCmd( "skafinity_reroll" )]
public static void Reroll()
{
var p = Player();
if ( p == null ) return;
p.RerollVibe();
Log.Info( $"[Skafinity] rerolled — {p.CurrentSeed}" );
}
/// <summary>A fresh random station at song 0. Anything pinned stays pinned.</summary>
[ConCmd( "skafinity_station" )]
public static void Station()
{
var p = Player();
if ( p == null ) return;
p.RerollStation();
Log.Info( $"[Skafinity] new station — {p.StationSeed}" );
}
/// <summary>Flip shuffle: on, every next song is a whole new station rather than the next song of
/// this one.</summary>
[ConCmd( "skafinity_shuffle" )]
public static void ToggleShuffle()
{
var p = Player();
if ( p == null ) return;
p.SetShuffle( !p.Shuffle );
Log.Info( $"[Skafinity] shuffle {( p.Shuffle ? "on" : "off" )} — {p.StationSeed}" );
}
/// <summary>Pause or resume, keeping the place in the song.</summary>
[ConCmd( "skafinity_pause" )]
public static void TogglePause()
{
var p = Player();
if ( p == null ) return;
p.TogglePlay();
var at = p.Playhead();
Log.Info( $"[Skafinity] {( p.IsPaused ? "paused" : "playing" )} at {SkafinityBoard.Time( at.Time, at.Duration > 0 )}" );
}
/// <summary>Write the playing song to a .wav under the s&box data folder.</summary>
[ConCmd( "skafinity_save" )]
public static void Save()
{
var p = Player();
if ( p == null ) return;
var name = p.SaveCurrentToFile();
Log.Info( string.IsNullOrEmpty( name )
? "[Skafinity] couldn't save — nothing rendered yet?"
: $"[Skafinity] saved {name} to your s&box data folder." );
}
/// <summary>What the player is doing right now: the seed, the transport, and whether the
/// shared house mix actually loaded.</summary>
[ConCmd( "skafinity_status" )]
public static void Status()
{
var p = Player();
if ( p == null ) return;
int genre = p.EffectiveConfig()?.Genre ?? 0;
var at = p.Playhead();
Log.Info( "── skafinity_status ──" );
Log.Info( $" seed {p.CurrentSeed} (n {p.N}, genre {genre} = {VibeCodec.Genres[genre]})" );
Log.Info( $" station {p.StationSeed} (position {p.Position} on the line)" );
Log.Info( $" transport {( p.Enabled ? "on" : "MUTED" )}, vol {p.Volume:0.00}, "
+ $"{( p.IsPaused ? "paused" : p.IsPlaying ? "playing" : "not playing" )} "
+ $"{SkafinityBoard.Time( at.Time, at.Duration > 0 )} / {SkafinityBoard.Time( at.Duration, at.Duration > 0 )}"
+ $"{( p.IsBuffering ? ", BUFFERING" : p.IsGenerating ? ", generating ahead" : "" )}" );
Log.Info( $" rolling genre {( p.GenrePinned ? "pinned" : "per song" )}, "
+ $"vibe {( p.VibePinned ? "pinned" : "per song" )}" );
Log.Info( $" shuffle {( p.Shuffle ? "on — every next song is a new station" : "off — walking this station" )}" );
Log.Info( $" output {p.SampleRate} Hz, {p.RenderThreads} render thread(s)" );
// Zero here is the interesting case: the baseline mix is then the engine's compiled
// defaults, not the file the web toy reads, and nothing else would ever say so.
Log.Info( p.HouseConfigCount > 0
? $" housemix {p.HouseConfigCount} values from skafinity.config.json"
: " housemix NOT LOADED — skafinity.config.json isn't mounted, so the baseline mix is "
+ "the compiled defaults rather than the shared file. Check it shipped with the addon." );
Log.Info( $" theme {( SkafinityTheme.Accent == null ? "neutral (accent unset)" : SkafinityTheme.Accent.ToString() )}" );
Log.Info( $" {Roster()}" );
}
/// <summary>What the composer decided for the song playing: tempo, swing, key, changes,
/// voicing, groove, part and tune lengths, ending, and the form. This is the "why does this
/// seed sound wrong" tool — reading the decisions beats inferring them from the audio.
/// Re-plans the song, so expect a short hitch.</summary>
[ConCmd( "skafinity_explain" )]
public static void Explain()
{
var p = Player();
if ( p == null ) return;
Log.Info( $"── skafinity_explain {p.CurrentSeed} ──" );
Log.Info( p.ExplainCurrent() );
}
static string Roster() =>
"genres: " + string.Join( " ", VibeCodec.Genres.Select( ( g, i ) => $"{i}={g}" ) );
}