CharacterKit/HornEditor.razor

A Blazor-style Razor UI component for an in-editor horn editor. It renders controls to pick horn family, shape, placement, surface and colors, exposes sliders and color pickers, and updates a CharacterHornsData Value with live edits.

File Access
@using Sandbox
@using Sandbox.UI
@using System
@namespace CharacterEditor
@inherits Panel
<root>
 <div class="hornheading"><div class="optionheading">Horns</div><button class=@($"creatorbutton quiet {(Value.Linked?"chosen":"")}") onclick=@ToggleLinked tooltip="Linked edits mirror the same horn on both sides"><span class="material-icons">@(Value.Linked?"link":"link_off")</span>@(Value.Linked?"Linked pair":"Independent")</button></div>
 @if(!Value.Linked){<div class="hornrow"><button class=@($"creatorbutton {(Side==0?"chosen":"")}") onclick=@(()=>Side=0)>Left horn</button><button class=@($"creatorbutton {(Side==1?"chosen":"")}") onclick=@(()=>Side=1)>Right horn</button><button class="creatorbutton quiet" onclick=@CopyOther tooltip="Copy this horn to the other side">Copy across</button></div>}
 <div class="hornfamilies">@foreach(var family in new[]{"none"}.Concat(CharacterHornData.Families.Where(f=>f!="spike")).Append("spike")){<button class=@($"creatorbutton horn-{family} {(Current.Family==family?"chosen":"")}") onclick=@(()=>ChooseFamily(family))>@CharacterHornData.Label(family)</button>}</div>
 @if(Current.Family!="none")
 {
  <div class="optiondescription">@Description(Current.Family)</div>
  <div class="hornrow">@foreach(var section in new[]{"Shape","Placement","Surface"}){<button class=@($"creatorbutton {(Section==section?"chosen":"")}") onclick=@(()=>Section=section)>@section</button>}</div>
  @foreach(var control in Controls.Where(c=>c.Group==Section))
  {
   <div class="skinchannel"><div class="shapelabel"><span>@control.Label</span><span class="shapevalue">@MathF.Round(control.Get(Current)*100)%</span></div><SliderControl class=@($"shapeslider horn-{control.Id}") [email protected](Current) OnValueChanged=@((float x)=>Change(control,x)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
  }
  @if(Section=="Surface")
  {
   <div class="horncolors">
    <div class="horncolorheading"><span class="material-icons">palette</span><span>Horn colors</span></div>
    @foreach(var control in Controls.Where(c=>c.Group=="Color"))
    {
     <div class="skinchannel"><div class="shapelabel"><span>@control.Label</span><span class="shapevalue">@MathF.Round(control.Get(Current)*100)%</span></div><SliderControl class=@($"shapeslider horn-{control.Id}") [email protected](Current) OnValueChanged=@((float x)=>Change(control,x)) Min=@(0f) Max=@(1f) Step=@(.01f) ShowTextEntry=@false /></div>
    }
    <div class="optiondescription">More tip coverage brings its color toward the root. Low softness makes a sharp boundary; high softness makes a gradual fade.</div>
    <div class="hornrow">@foreach(var palette in Palettes){<button class="creatorbutton" onclick=@(()=>SetPalette(palette.Base,palette.Tip))>@palette.Name</button>}</div>
    <div class="hornrow"><button class=@($"creatorbutton {(ColorPart==0?"chosen":"")}") onclick=@(()=>ColorPart=0)>Root color</button><button class=@($"creatorbutton {(ColorPart==1?"chosen":"")}") onclick=@(()=>ColorPart=1)>Tip color</button></div>
    <HairColorPicker Value=@(ColorPart==0?Current.Color:Current.TipColor) OnValueChanged=@SetColor />
   </div>
  }
  <button class="creatorbutton quiet" onclick=@ResetShape>Reset this horn’s shape</button>
 }
</root>
@code {
 public CharacterHornsData Value { get; set; } = new();
 public Action OnChanged { get; set; }
 int Side,ColorPart;
 string Section="Shape";
 CharacterHornData Current => Value.Linked || Side==0 ? Value.Left : Value.Right;
 record Control(string Id,string Label,string Group,Func<CharacterHornData,float> Get,Action<CharacterHornData,float> Set);
 // Do not retain positional control records across hotload: inserting entries can
 // transplant old labels/IDs into the new controls while rebinding their delegates.
 static Control[] Controls => new Control[] {
  new("length","Length","Shape",d=>d.Length,(d,v)=>d.Length=v),new("thickness","Thickness","Shape",d=>d.Thickness,(d,v)=>d.Thickness=v),
  new("curve","Curvature","Shape",d=>d.Curve,(d,v)=>d.Curve=v),new("twist","Twist","Shape",d=>d.Twist,(d,v)=>d.Twist=v),
  new("tilt","Forward / backward tilt","Placement",d=>d.Tilt,(d,v)=>d.Tilt=v),new("splay","Inward / outward angle","Placement",d=>d.Splay,(d,v)=>d.Splay=v),
  new("position","Forehead / crown","Placement",d=>d.Placement,(d,v)=>d.Placement=v),new("spacing","Spacing","Placement",d=>d.Spacing,(d,v)=>d.Spacing=v),
  new("ridges","Growth rings","Surface",d=>d.Ridges,(d,v)=>d.Ridges=v),new("roughness","Matte finish","Surface",d=>d.Roughness,(d,v)=>d.Roughness=v),
  new("tip-coverage","Tip color coverage","Color",d=>d.TipCoverage,(d,v)=>d.TipCoverage=v),new("blend-softness","Color blend softness","Color",d=>d.BlendSoftness,(d,v)=>d.BlendSoftness=v)
 };
 record Palette(string Name,Color Base,Color Tip);
 static readonly Palette[] Palettes={new("Natural",new(.29f,.23f,.17f),new(.095f,.065f,.045f)),new("Ivory",new(.68f,.57f,.39f),new(.30f,.20f,.12f)),new("Obsidian",new(.12f,.115f,.105f),new(.035f,.03f,.025f)),new("Crimson",new(.36f,.10f,.07f),new(.07f,.025f,.02f))};
 static string Description(string family)=>family switch {"spike"=>"A short, gently curved point.","swept"=>"Long horns sweeping back from the crown.","recurved"=>"An S-shaped sweep that turns back toward the tip.","hooked"=>"A heavy forward hook.","ram"=>"A broad curl around the sides of the head.",_=>"An upright corkscrew with a tapering spiral."};
 void Changed(){if(Value.Linked)Value.Right=Value.Left.DeepCopy();OnChanged?.Invoke();StateHasChanged();}
 void ToggleLinked(){if(!Value.Linked){Value.Left=Current.DeepCopy();Value.Right=Value.Left.DeepCopy();}Value.Linked=!Value.Linked;Side=0;Changed();}
 void CopyOther(){if(Side==0)Value.Right=Value.Left.DeepCopy();else Value.Left=Value.Right.DeepCopy();Changed();}
 void ChooseFamily(string family){Current.Family=family;Changed();}
 void Change(Control c,float x){if(!float.IsFinite(x))return;c.Set(Current,Math.Clamp(x,0,1));Changed();}
 void SetColor(Color c){if(ColorPart==0)Current.Color=c;else Current.TipColor=c;Changed();}
 void SetPalette(Color root,Color tip){Current.Color=root;Current.TipColor=tip;Changed();}
 void ResetShape(){var d=Current;d.Length=d.Thickness=d.Curve=d.Twist=d.Tilt=d.Splay=d.Spacing=.5f;d.Placement=.25f;Changed();}
 protected override int BuildHash()=>HashCode.Combine(System.Text.Json.JsonSerializer.Serialize(Value),Side,Section,ColorPart,2);
}