Code/Studio/StudioPanel.razor

A Razor UI panel for the Character Editor in the Studio. It renders different views (editor, plaza, library/collection), handles UI state (search, favorites, import/export, delete confirmations), delegates save/import/export/delete actions to StudioSession, and manages an embedded escape menu.

File AccessHttp Calls
@using System
@using System.Text.Json
@using Sandbox
@using Sandbox.UI
@namespace CharacterEditor
@inherits PanelComponent

<root>
@if(Session is not null && Session.View!="hidden")
{
 @if(Session.View=="editor")
 {
  <CharacterCreatorPanel [email protected] @ref=Creator Saved=@Save Closed=@RequestClose SaveLabel=@(Session.IsNew?"Save character":"Save changes") />
  @if(!Session.IsNew){<button class="savecopy" onclick=@SaveCopy>Save as a copy</button>}
  @if(ConfirmClose){<div class="shade"><div class="dialog"><h2>Discard your edits?</h2><p>Your saved character will stay as it was.</p><div class="actions"><button onclick=@(()=>ConfirmClose=false)>Keep editing</button><button class="primary" onclick=@Discard>Discard edits</button></div></div></div>}
 }
 else if(Session.View=="plaza")
 {
  <div class="plazahead"><div><div class="eyebrow">CHARACTER EDITOR</div><h1>The gathering place</h1><p>Your characters, a little more alive.</p></div><button onclick=@(()=>Session.View="library")>Back to collection</button></div>
  <div class="residents">
   <div class="eyebrow">IN THE PLAZA · @Session.Plaza?.Residents.Count</div>
   @foreach(var resident in Session.Plaza?.Residents??new List<CharacterAvatar>())
   {<div class="resident"><button onclick=@(()=>Session.Plaza.Focus(resident.Preset.Id))>@resident.GameObject.Name</button><button onclick=@(()=>Session.Edit(resident.Preset,resident))>Edit</button></div>}
   @if(Session.Library.Items.Count==0){<p>Save your first character to bring this place to life.</p><button class="primary" onclick=@(()=>Session.Edit())>Create a character</button>}
   @if(Session.Plaza?.PageCount>1){<div class="actions"><button onclick=@(()=>Session.Plaza.ChangePage(-1))>Previous</button><span>@(Session.Plaza.Page+1) / @Session.Plaza.PageCount</span><button onclick=@(()=>Session.Plaza.ChangePage(1))>Next</button></div>}
  </div>
  <div class="plazatools"><button onclick=@(()=>Session.Plaza.Yaw-=25)>Turn left</button><button onclick=@(()=>Session.Plaza.Yaw+=25)>Turn right</button><button onclick=@(()=>Session.Plaza.Distance-=80)>Closer</button><button onclick=@(()=>Session.Plaza.Distance+=80)>Further</button><button onclick=@(()=>Session.Plaza.Overview())>Overview</button><button onclick=@(()=>Session.Plaza.Paused=!Session.Plaza.Paused)>@(Session.Plaza?.Paused==true?"Let them wander":"Pause wandering")</button></div>
 }
 else
 {
  <div class="collection">
   <div class="topbar"><div class="brand"><span class="brandmark">C</span><span>CHARACTER EDITOR</span></div><div class="actions"><button onclick=@(()=>ImportOpen=true)>Import preset</button><button class="primary" onclick=@(()=>Session.Edit())>+ Create character</button></div></div>
   <div class="intro"><div><div class="eyebrow">A PLACE FOR EVERY VERSION OF YOU</div><h1>Meet your characters.</h1><p>Shape a face. Find a personality. Build a little world of your own.</p></div>@if(Session.Plaza.IsValid()){<button class="plazalink" [email protected]><span>Visit the plaza</span><span class="arrow">↗</span></button>}</div>
   <div class="librarybar"><div class="actions"><button class=@(!Favorites?"chosen":"") onclick=@(()=>Favorites=false)>All characters · @Session.Library.Items.Count</button><button class=@(Favorites?"chosen":"") onclick=@(()=>Favorites=true)>Favorites</button></div><TextEntry class="search" Value=@Search OnTextEdited=@((string value)=>{Search=value;}) Placeholder="Find a character" /></div>
   <div class="cards">
    @foreach(var preset in Visible)
    {
     <div class="card"><button class="portrait" onclick=@(()=>Session.Edit(preset))><CharacterCreatorPreview [email protected] Portrait=@true /></button><div class="cardtitle"><h2>@preset.Name</h2><button class=@($"favorite {(preset.Favorite?"chosen":"")}") onclick=@(()=>Session.Favorite(preset))>@(preset.Favorite?"★":"☆")</button></div><div class="cardactions"><button onclick=@(()=>Session.Edit(preset))>Edit</button><button onclick=@(()=>Export(preset))>Export</button><button class="delete" onclick=@(()=>PendingDelete=preset)>Delete</button></div></div>
    }
    @if(!Visible.Any())
    {<div class="empty"><div class="emptyicon">✦</div><h2>@(Session.Library.Items.Count==0?"Your cast starts here.":"No characters found.")</h2><p>@(Session.Library.Items.Count==0?"Start with a familiar face, or make someone entirely new. Every saved character gets a place in your plaza.":"Try another search or switch back to all characters.")</p><button class="primary" onclick=@(()=>Session.Edit())>Create your first look</button></div>}
   </div>
   <div class="bottomline"><span>7 blendable faces · Body shaping · Your own wardrobe</span><span>Made to mix, match &amp; make your own</span></div>
  </div>
 }
 @if(!string.IsNullOrWhiteSpace(Session.Message)){<div class="toast" onclick=@(()=>Session.Message=null)>@Session.Message</div>}
 @if(Session.Library.Warnings.Count>0 && !DismissWarnings){<div class="warning" onclick=@(()=>DismissWarnings=true)>@string.Join(" · ",Session.Library.Warnings) · Click to dismiss</div>}
 @if(PendingDelete is not null){<div class="shade"><div class="dialog"><h2>Delete @PendingDelete.Name?</h2><p>This removes the saved character and its backup from your collection.</p><div class="actions"><button onclick=@(()=>PendingDelete=null)>Keep character</button><button class="primary" onclick=@Delete>Delete character</button></div></div></div>}
 @if(ImportOpen || ExportJson is not null)
 {<div class="shade"><div class="dialog transfer"><div class="eyebrow">PORTABLE PRESETS</div><h2>@(ImportOpen?"Bring a character along.":"Take this character with you.")</h2><p>@(ImportOpen?"Paste a Character Editor preset or raw Fate Pending appearance JSON. Import always creates a new copy.":"Copy this preset into another project using Character Kit. A copy is also saved in character_editor/exports in this game's data folder.")</p><TextEntry class="json" Multiline=@true Value=@(ImportOpen?ImportJson:ExportJson) OnTextEdited=@((string value)=>{ImportJson=value;}) /><div class="actions"><button onclick=@CloseTransfer>Close</button>@if(ImportOpen){<button class="primary" onclick=@Import>Import character</button>}else{<button class="primary" onclick=@(()=>Clipboard.SetText(ExportJson))>Copy preset</button>}</div></div></div>}
 @if(Session.EnableEscapeMenu && !Session.Embedded){<StudioEscapeMenu @ref=EscapeMenu MainMenu=@ReturnToMainMenu OpenLab=@OpenFaceLab EnableExperiments=@(Session.EnableFaceLab && Session.View!="editor") HasUnsavedEdits=@HasUnsavedEdits />}
}
</root>

@code {
	public StudioSession Session => Components.Get<StudioSession>();
	public CharacterCreatorPanel Creator { get; set; }
	public StudioEscapeMenu EscapeMenu { get; set; }

	string Search = "", ImportJson = "", ExportJson;
	bool Favorites, ImportOpen, ConfirmClose, DismissWarnings;
	CharacterPreset PendingDelete;
	IEnumerable<CharacterPreset> Visible => Session.Library.Items.Where(x => (!Favorites || x.Favorite) && x.Name.Contains(Search ?? "", StringComparison.OrdinalIgnoreCase)).OrderByDescending(x => x.Favorite).ThenBy(x => x.Name);

	protected override void OnStart()
	{
		Session?.EnsureLoaded();
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();
		if (Session?.EnableEscapeMenu == true && !Session.Embedded && Session.View != "hidden" && EscapeMenu is not null && Input.EscapePressed)
		{
			Input.EscapePressed = false;
			EscapeMenu.Toggle();
		}
	}

	bool HasUnsavedEdits() => Session.View == "editor" && Creator is not null && Session.Draft is not null && (Creator.DraftCharacterName != Session.Draft.Name || JsonSerializer.Serialize(Creator.DraftAppearance) != JsonSerializer.Serialize(Session.Draft.Appearance));
	void ReturnToMainMenu() => StudioSession.OpenScene("scenes/main_menu.scene");
	void OpenFaceLab() => StudioSession.OpenScene("scenes/face_lab.scene");
	string Save(string name, CharacterAppearanceData data) => Session.Save(name, data);
	void SaveCopy()
	{
		if (Creator?.AppearanceReady == true)
		{
			var error = Session.Save(Creator.DraftCharacterName, Creator.DraftAppearance, true);
			if (error is not null)
				Session.Message = error;
		}
	}

	void RequestClose()
	{
		if (HasUnsavedEdits())
			ConfirmClose = true;
		else
			Discard();
	}

	void Discard()
	{
		ConfirmClose = false;
		Session.CloseEditor();
		Creator = null;
	}

	void Delete()
	{
		Session.Delete(PendingDelete);
		PendingDelete = null;
	}

	void Import()
	{
		Session.Import(ImportJson);
		if (Session.Message.StartsWith("Imported "))
			CloseTransfer();
	}

	void Export(CharacterPreset preset)
	{
		try
		{
			ExportJson = preset.ToJson();
			FileSystem.Data.CreateDirectory("character_editor/exports");
			FileSystem.Data.WriteAllText($"character_editor/exports/{preset.Id}.json", ExportJson);
		}
		catch (Exception e)
		{
			Session.Message = e.Message;
		}
	}

	void CloseTransfer()
	{
		ImportOpen = false;
		ExportJson = null;
		ImportJson = "";
	}

	protected override int BuildHash() => HashCode.Combine(Session?.View, Session?.Revision, Session?.Library.Revision, Search, Favorites, HashCode.Combine(ImportOpen, ExportJson, PendingDelete, ConfirmClose, DismissWarnings, Session?.Message), Session?.Plaza?.Paused, HashCode.Combine(Session?.Plaza?.Page, Session?.Plaza?.Residents.Count, Session?.EnableEscapeMenu, Session?.EnableFaceLab, Session?.Embedded));
}