Skip to content

Commit dfd4f24

Browse files
andreakarashoclaude
andcommitted
Add widget skinning system with 9-slice support and RPG GUI demo
Introduce a parallel skin system that lets users apply custom textures to ClayUI widgets. When a skin image is set for a widget part, it emits an Image render command instead of a Rectangle, with optional 9-slice (9-patch) rendering for proper stretching of bordered textures. New types: NineSlice, SkinImage, StateImages, ClayUISkin, and per-widget skin structs (ButtonSkin, CheckboxSkin, SliderSkin, ToggleSkin, etc.). Extended ImageConfig/ImageRenderData with Slice and Tint fields. Updated 8 widgets (Button, Checkbox, Slider, Toggle, ProgressBar, Scrollbar, Panel, Window) plus RadioGroup with optional skin parameters. Zero overhead when no skin is set. Includes RPG GUI example tab using CC-BY 3.0 sprites by Lamoot with 9-slice buttons, checkbox/radio sprites, and parchment panel backgrounds. Added 9-slice rendering to RaylibRenderer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent edd7767 commit dfd4f24

13 files changed

Lines changed: 941 additions & 67 deletions

File tree

src/Clay.Example/Program.cs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@
4141
);
4242
Clay.Clay.TextEditSetClipboard(new RaylibClipboard());
4343

44+
// Load RPG skin assets
45+
RpgSkin.Load();
46+
4447
// ============ Application State ============
4548

4649
// Sidebar
4750
string[] pages = ["Overview", "Buttons", "Text Input", "Checkboxes & Toggles", "Sliders & Progress",
4851
"Radio Group", "Tree View", "Layout Helpers", "Text Styles", "Color Picker",
49-
"ListBox & Combo", "Scroll Areas", "Windows", "Popups & Context Menus", "Theming", "Disabled States"];
52+
"ListBox & Combo", "Scroll Areas", "Windows", "Popups & Context Menus", "Theming", "Disabled States", "RPG Skin"];
5053
int selectedPage = 0;
5154

5255
// Widget state
@@ -86,6 +89,15 @@
8689
float disabledDemoSlider = 0.6f;
8790
string disabledDemoText = "Can't edit this";
8891

92+
// RPG Skin demo state
93+
bool rpgSkinCheckbox = false;
94+
bool rpgSkinToggle = true;
95+
float rpgSkinSlider = 0.4f;
96+
float rpgSkinProgress = 0.65f;
97+
int rpgSkinRadio = 0;
98+
string[] rpgRadioOptions = ["Warrior", "Mage", "Ranger"];
99+
ClayUISkin? rpgSkin = null;
100+
89101
// Debug
90102
bool debugWindowOpen = false;
91103

@@ -167,6 +179,7 @@
167179
Raylib.EndDrawing();
168180
}
169181

182+
RpgSkin.Unload();
170183
Clay.Clay.Shutdown();
171184
Raylib.CloseWindow();
172185

@@ -312,6 +325,7 @@ void RenderContent()
312325
case 13: PagePopups(); break;
313326
case 14: PageTheming(); break;
314327
case 15: PageDisabledStates(); break;
328+
case 16: PageRpgSkin(); break;
315329
}
316330

317331
ClayUI.EndScrollArea();
@@ -769,6 +783,152 @@ void PageDisabledStates()
769783
ClayUI.Button("This works!");
770784
}
771785

786+
void PageRpgSkin()
787+
{
788+
// Lazily create the skin on first use
789+
rpgSkin ??= RpgSkin.CreateSkin();
790+
791+
ClayUI.Label("Custom skin using RPG GUI sprites (CC-BY 3.0, by Lamoot).");
792+
ClayUI.Label("Widgets below use image textures instead of solid-color rectangles.");
793+
ClayUI.Label("The same widgets, same API -- just with ClayUI.Skin set.");
794+
ClayUI.Space(12);
795+
796+
// Apply the RPG skin for the rest of this page
797+
var previousSkin = ClayUI.Skin;
798+
ClayUI.Skin = rpgSkin;
799+
800+
// Style overrides sized for the ornate RPG sprites
801+
var rpgButton = new ButtonStyle
802+
{
803+
Padding = Padding.Symmetric(52, 14),
804+
TextColor = Color.Rgba(230, 215, 180),
805+
FontSize = 16
806+
};
807+
var rpgCheckbox = new CheckboxStyle
808+
{
809+
BoxSize = 28,
810+
BoxCornerRadius = 0,
811+
TextColor = Color.Rgba(220, 210, 180),
812+
FontSize = 15
813+
};
814+
var rpgToggle = new ToggleStyle
815+
{
816+
TrackWidth = 60,
817+
TrackHeight = 30,
818+
KnobSize = 26,
819+
TextColor = Color.Rgba(220, 210, 180),
820+
FontSize = 15
821+
};
822+
var rpgSlider = new SliderStyle
823+
{
824+
TrackHeight = 20,
825+
TextColor = Color.Rgba(220, 210, 180),
826+
ValueTextColor = Color.Rgba(200, 190, 160),
827+
FontSize = 15
828+
};
829+
var rpgProgress = new ProgressBarStyle
830+
{
831+
Height = 20,
832+
CornerRadius = 0
833+
};
834+
var rpgPanel = new PanelStyle
835+
{
836+
TitleColor = Color.Rgba(80, 60, 30),
837+
TitleFontSize = 18,
838+
Padding = Padding.All(20),
839+
ChildGap = 8
840+
};
841+
var rpgLabel = new LabelStyle
842+
{
843+
TextColor = Color.Rgba(60, 45, 20),
844+
FontSize = 15
845+
};
846+
847+
// --- Buttons ---
848+
ClayUI.Label("Buttons:");
849+
ClayUI.Space(4);
850+
ClayUI.BeginHorizontal(gap: 16);
851+
ClayUI.Button("Attack", rpgButton);
852+
ClayUI.Button("Defend", rpgButton);
853+
ClayUI.Button("Magic", rpgButton);
854+
ClayUI.EndHorizontal();
855+
ClayUI.Space(4);
856+
ClayUI.Button("Inventory", rpgButton);
857+
858+
ClayUI.Space(16);
859+
ClayUI.Separator();
860+
ClayUI.Space(8);
861+
862+
// --- Checkboxes ---
863+
ClayUI.Label("Checkboxes:");
864+
ClayUI.Space(4);
865+
ClayUI.Checkbox("Show minimap", ref rpgSkinCheckbox, rpgCheckbox);
866+
bool tempCb = true;
867+
ClayUI.Checkbox("Enable sound", ref tempCb, rpgCheckbox);
868+
869+
ClayUI.Space(16);
870+
ClayUI.Separator();
871+
ClayUI.Space(8);
872+
873+
// --- Radio Group ---
874+
ClayUI.Label("Radio Group:");
875+
ClayUI.Space(4);
876+
var rpgRadio = new RadioGroupStyle
877+
{
878+
CircleSize = 24,
879+
DotSize = 14,
880+
TextColor = Color.Rgba(220, 210, 180),
881+
LabelColor = Color.Rgba(180, 170, 140),
882+
FontSize = 15
883+
};
884+
ClayUI.RadioGroup("Class", ref rpgSkinRadio, rpgRadioOptions, rpgRadio);
885+
886+
ClayUI.Space(16);
887+
ClayUI.Separator();
888+
ClayUI.Space(8);
889+
890+
// --- Toggle ---
891+
ClayUI.Label("Toggle:");
892+
ClayUI.Space(4);
893+
ClayUI.Toggle("Fullscreen", ref rpgSkinToggle, rpgToggle);
894+
895+
ClayUI.Space(16);
896+
ClayUI.Separator();
897+
ClayUI.Space(8);
898+
899+
// --- Slider ---
900+
ClayUI.Label("Slider:");
901+
ClayUI.Space(4);
902+
ClayUI.Slider("Volume", ref rpgSkinSlider, 0, 1, rpgSlider);
903+
904+
ClayUI.Space(16);
905+
ClayUI.Separator();
906+
ClayUI.Space(8);
907+
908+
// --- Progress Bar ---
909+
ClayUI.Label("Progress Bar:");
910+
ClayUI.Space(4);
911+
rpgSkinProgress += Raylib.GetFrameTime() * 0.05f;
912+
if (rpgSkinProgress > 1f) rpgSkinProgress = 0f;
913+
ClayUI.ProgressBar(rpgSkinProgress, style: rpgProgress);
914+
915+
ClayUI.Space(16);
916+
ClayUI.Separator();
917+
ClayUI.Space(8);
918+
919+
// --- Panel ---
920+
ClayUI.Label("Panel:");
921+
ClayUI.Space(4);
922+
ClayUI.BeginPanel("Quest Log", rpgPanel);
923+
ClayUI.Label("- Defeat the dragon", rpgLabel);
924+
ClayUI.Label("- Find the lost sword", rpgLabel);
925+
ClayUI.Label("- Return to the village", rpgLabel);
926+
ClayUI.EndPanel();
927+
928+
// Restore previous skin
929+
ClayUI.Skin = previousSkin;
930+
}
931+
772932
// ============ Demo Windows ============
773933

774934
void RenderDemoWindows()

src/Clay.Example/RaylibRenderer.cs

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,67 @@ private void RenderImage(BoundingBox box, ImageRenderData data)
241241
if (data.ImageData is not Texture texture)
242242
return;
243243

244-
var position = new System.Numerics.Vector2(box.X, box.Y);
244+
var tint = data.BackgroundColor.A > 0 ? ToRayColor(data.BackgroundColor) : Raylib.WHITE;
245+
246+
if (data.Slice.HasSlice)
247+
{
248+
RenderNineSlice(box, texture, data.Slice, tint);
249+
}
250+
else
251+
{
252+
// Stretch to fill the bounding box
253+
var source = new Rectangle(0, 0, texture.width, texture.height);
254+
var dest = new Rectangle(box.X, box.Y, box.Width, box.Height);
255+
Raylib.DrawTexturePro(texture, source, dest,
256+
new System.Numerics.Vector2(0, 0), 0, tint);
257+
}
258+
}
259+
260+
private static void RenderNineSlice(BoundingBox box, Texture texture, NineSlice slice, RayColor tint)
261+
{
262+
float srcW = texture.width;
263+
float srcH = texture.height;
264+
float sl = slice.Left, sr = slice.Right, st = slice.Top, sb = slice.Bottom;
265+
266+
// Destination insets — clamp so corners never exceed the dest size
267+
float dl = Math.Min(sl, box.Width * 0.5f);
268+
float dr = Math.Min(sr, box.Width * 0.5f);
269+
float dt = Math.Min(st, box.Height * 0.5f);
270+
float db = Math.Min(sb, box.Height * 0.5f);
271+
272+
float midSrcW = srcW - sl - sr;
273+
float midSrcH = srcH - st - sb;
274+
float midDstW = box.Width - dl - dr;
275+
float midDstH = box.Height - dt - db;
276+
277+
var origin = new System.Numerics.Vector2(0, 0);
278+
279+
// Helper to draw one patch
280+
void Patch(float sx, float sy, float sw, float sh, float dx, float dy, float dw, float dh)
281+
{
282+
if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) return;
283+
Raylib.DrawTexturePro(texture,
284+
new Rectangle(sx, sy, sw, sh),
285+
new Rectangle(dx, dy, dw, dh),
286+
origin, 0, tint);
287+
}
288+
289+
float x = box.X, y = box.Y;
290+
291+
// Top row
292+
Patch(0, 0, sl, st, x, y, dl, dt); // top-left
293+
Patch(sl, 0, midSrcW, st, x + dl, y, midDstW, dt); // top-center
294+
Patch(srcW - sr, 0, sr, st, x + dl + midDstW, y, dr, dt); // top-right
245295

246-
// Calculate scale to fit bounding box
247-
float scaleX = box.Width / texture.width;
248-
float scaleY = box.Height / texture.height;
249-
float scale = Math.Min(scaleX, scaleY);
296+
// Middle row
297+
Patch(0, st, sl, midSrcH, x, y + dt, dl, midDstH); // mid-left
298+
Patch(sl, st, midSrcW, midSrcH, x + dl, y + dt, midDstW, midDstH); // center
299+
Patch(srcW - sr, st, sr, midSrcH, x + dl + midDstW, y + dt, dr, midDstH); // mid-right
250300

251-
Raylib.DrawTextureEx(texture, position, 0, scale, Raylib.WHITE);
301+
// Bottom row
302+
Patch(0, srcH - sb, sl, sb, x, y + dt + midDstH, dl, db); // bot-left
303+
Patch(sl, srcH - sb, midSrcW, sb, x + dl, y + dt + midDstH, midDstW, db); // bot-center
304+
Patch(srcW - sr, srcH - sb, sr, sb, x + dl + midDstW, y + dt + midDstH, dr, db); // bot-right
252305
}
253306

254307
// Cached textures for HSV gradients

0 commit comments

Comments
 (0)