A Razor UI panel for the character editor studio. Renders different views (editor, plaza, collection), handles UI actions like create, edit, delete, import/export, plaza controls and escape menu integration, and wires to StudioSession for persistence and scene navigation.
@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 & 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));
}