Supports in Godot 4.1+
with .Net module.
GD Panel Framework is a Godot 4
UI Management System designed to provide a flexible
, panel-based
, single-focus point
, Gamepad + Keyboard + Keyboard&Mouse friendly
UI programming experience.
This framework groups sets
of user interactions
into a UIPanel
, which includes a combination of the following:
Controls
, such asbutton
,label
, andcontainer
.Inputs
, which is a set of developer-defined input actions binds with this panel.
These user interactions
are panel-scoped
, which means they only stay active when the panel
is active; this simplifies the workflow for maintaining large amounts of discrete Controls
and Global Input Actions
and allows developers to focus on programming game logic (not collecting and toggling Controls
or adding more if
s into a global _Input
method).
For .Net CLI
dotnet add package GDPanelFramework
For Package Manager Console
NuGet\Install-Package GDPanelFramework
For csproj
PackageReference
<PackageReference Include="GDPanelFramework" Version="*" />
- Simple API Usage
- Framework Documentation
- Framework Concept
- The
UIPanel
- The
UIPanelArg
- Panel Container Management
- The
Panel Tweener
- Note when using
async/await
Styled API - Note when using this Framework
You can run RunMe_Example00.tscn in Godot Editor.
using Godot;
using GodotTask;
namespace GDPanelFramework.Examples;
/// <summary>
/// The bootstrap script that creates and opens the panel.
/// </summary>
public partial class Example00_Main : Node
{
/// <summary>
/// The packed panel.
/// </summary>
[Export] private PackedScene _panelPrefab;
/// <summary>
/// Executes the main logic after one frame since the game starts.
/// This is required by the GDPanelFramework for adding its panel root into the scene tree.
/// </summary>
public override void _Ready() =>
GDTask.NextFrame().ContinueWith(OnReady);
private void OnReady()
{
_panelPrefab
.CreatePanel<Example00_MyPanel>() // This extension method tells the framework to create or reuse an instance of this panel.
.OpenPanel( // This method tells the framework to opens the panel.
onPanelCloseCallback: // This delegate gets called when this panel gets closed when the panel itself calls ClosePanel().
() => GetTree().Quit() // Terminate the application when this panel gets closed.
);
}
}
using GDPanelFramework.Panels;
using Godot;
namespace GDPanelFramework.Examples;
/// <summary>
/// Attach this script to a Control to make it a "UIPanel".
/// </summary>
public partial class Example00_MyPanel : UIPanel
{
// These three fields are assigned in Godot Editor, through inspector.
[Export] private Label _text;
[Export] private Button _updateButton;
[Export] private Button _closeButton;
// Stores the click count.
private int _clickCount = 0;
/// <summary>
/// Called by the framework when this instance of panel is created,
/// an instance can only gets created once.
/// </summary>
protected override void _OnPanelInitialize()
{
_updateButton.Pressed += OnClick; // Calls OnClick then the _updateButton gets pressed.
_closeButton.Pressed += ClosePanel; // Close this panel when the _closeButton gets pressed.
}
/// <summary>
/// Registered to the <see cref="_updateButton"/>.
/// </summary>
private void OnClick()
{
_clickCount++;
_text.Text = $"Clicked {_clickCount} time(s).";
}
/// <summary>
/// Called by the framework when this instance of panel is opened.
/// The framework supports automatic panel caching
/// so you may reopen a panel after it's closed and cached.
/// </summary>
protected override void _OnPanelOpen()
{
_text.Text = "Hello World";
_updateButton.GrabFocus();
}
}
You can run RunMe_Example01.tscn in Godot Editor.
using Godot;
using GodotTask;
namespace GDPanelFramework.Examples;
/// <summary>
/// The bootstrap script that creates and opens the panel.
/// </summary>
public partial class Example01_Main : Node
{
/// <summary>
/// The packed panel.
/// </summary>
[Export] private PackedScene _panelPrefab;
/// <summary>
/// Executes the main logic after one frame since the game starts.
/// This is required by the GDPanelFramework for adding its panel root into the scene tree.
/// </summary>
public override void _Ready() =>
GDTask.NextFrame().ContinueWith(OnReady);
private void OnReady()
{
_panelPrefab
.CreatePanel<Example01_MyPanel>() // This extension method tells the framework to create or reuse an instance of this panel.
.OpenPanel( // This method tells the framework to opens the panel.
"Hello World!", // Passes the argument to the panel.
onPanelCloseCallback: // This delegate gets called when this panel gets closed when the panel itself calls ClosePanel().
result => // Prints the result and terminate the application when this panel gets closed.
{
GD.Print($"Clicked {result} time(s) before closed.");
GetTree().Quit();
}
);
}
}
using GDPanelFramework.Panels;
using Godot;
namespace GDPanelFramework.Examples;
/// <summary>
/// Attach this script to a Control to make it a "UIPanel".
/// </summary>
public partial class Example01_MyPanel : UIPanelArg<string, string>
{
// These three fields are assigned in Godot Editor, through inspector.
[Export] private Label _text;
[Export] private Button _updateButton;
[Export] private Button _closeButton;
// Stores the click count.
private int _clickCount = 0;
/// <summary>
/// Called by the framework when this instance of panel is created,
/// an instance can only gets created once.
/// </summary>
protected override void _OnPanelInitialize()
{
_updateButton.Pressed += OnClick; // Calls OnClick then the _updateButton gets pressed.
_closeButton.Pressed += () => ClosePanel(_clickCount.ToString()); // Close this panel when the _closeButton gets pressed.
}
/// <summary>
/// Registered to the <see cref="_updateButton"/>.
/// </summary>
private void OnClick()
{
_clickCount++;
_text.Text = $"Clicked {_clickCount} time(s).";
}
/// <summary>
/// Called by the framework when this instance of panel is opened.
/// The framework supports automatic panel caching
/// so you may reopen a panel after it's closed and cached.
/// </summary>
protected override void _OnPanelOpen(string openArg)
{
_text.Text = openArg;
_updateButton.GrabFocus();
}
}
In a typical GUI application such as Games, a panel/page-based control flow
is a common practice.
When opening a panel from the main logic
, developer may want the panel executes its own panel logic
and self closes
when finish, then continue the main logic
(such as file dialog or warning).
This design transfers the control flow from the main logic to the panel, and the panel returns the control flow back to the main logic when finish
simplifies the workflow for programming panels, it handles the requirement for managing ui focuses, and is crucial when designing game pad compatible games.
This framework implementing this practice by the Panel Stack based Control Management
, Async/Callback Styled API
, and Panel Input Binding
design.
UIPanel
is the fundamental component of the framework, it provides Panel Level Input Binding
, Child Control Access Management
features for simplfying programming workflow, it also supports configurable Panel Tweener
for animated opening/closing requirements.
-
The
Panel Level Input Binding
feature allows developers to register/deregister a set of input bindings for this panel, the registered inputs are sandboxed at the panel level so they don't get in the way when panel is inactive. -
The
Child Control Access Management
feature automatically disables/restores theFocusMode
andMouseFilter
property for every child control when the panel activates/deactivates, this prevents unwanted UI Navigation and Mouse Interaction toleaked behind
the current activated panel.
Call CreatePanel<TPanel>
to instatiate a panel from the supplied PackedScene, instead of the built-in PackedScene.Instantiate
, this API also handles necessary initialization and caching.
// In caller class.
[Export] private PackedScene _panelPrefab;
// In caller method.
var panelInstance =
_panelPrefab
.CreatePanel<TypeOfScriptAttachedToThePanel>();
There are three OpenPanel Methods for a UIPanel each of which is designed for a certain programming style.
In an async method, a async/await-styled
opening method returns a one-time awaitable
that allows the developer to await
for a panel close, in PanelArg
, awaiting this awaitable will also get the return value from the panel.
// When opening a panel, in async method.
await panelInstance.OpenPanelAsync();
GD.Print("The panel has closed!");
A callback-styled
opening method allows the developer to supply a delegate to get notified when the panel has closed, in PanelArg
, the return value will also pass to this delegate.
// When opening a panel.
panelInstance
.OpenPanel(
onPanelCloseCallback: // This lambda gets called when the panel is closed.
() => GD.Print("The panel has closed!")
);
A forget-styled
opening method only opens the panel, it is useful when the time of a panel closing is not a concern.
// When opening a panel.
panelInstance.OpenPanel();
Calling ClosePanel()
in a panel script will close the opened panel. This method is protected
by default, developer may expose this method by wrapping it around by a public one.
Please note that a panel must be opened before you can close it, and closing a panel that's not on top of the panel stack is considered an error and will crash the framework.
// Inside a panel script
protected override void _OnPanelOpen()
{
// Close a panel one frame after it opens.
GDTask.NextFrame().ContinueWith(ClosePanel);
}
All Godot Input Events are intercepted by the root/RootPanelViewport
and dispatched directly to the active panel. A set of inputs bound to the panel are automatically switched off or on
when the panel deactivates/activates
.
Calling RegisterInput
in a panel can bind a delegate to a specific input event, the registered delegates are freed automatically when the panel gets freed.
// In panel
RegisterInput( // Register a callback to the associated inputName
BuiltinInputNames.UIAccept, // The input name to associate with, this name should Correspond to the name in InputManager.
inputEvent => GD.Print(inputEvent.AsText()), // The delegate to associate to.
InputActionPhase.Pressed // The input state to focus on.
);
In certain cases where unbinding a delegate is required, call RemoveInput
with the corresponding registration.
Note that when working with input deregistration, to correctly deregisters a
lambda expression
, it is mandatory toassign the lambda expression to a variable
andpass that variable to the APIs
.
// Assign this lambda expression to a variable.
Action<InputEvent> myDelegate = inputEvent => GD.Print(inputEvent.AsText());
// Register this callback to the associated inputName.
RegisterInput(BuiltinInputNames.UIAccept, myDelegate);
// Remove this registration.
RemoveInput(BuiltinInputNames.UIAccept, myDelegate);
Alternatively, you may use the ToggleInput
API.
ToggleInput( // This API supports change input registration based on the first bool.
true, // set to false to deregister.
BuiltinInputNames.UIAccept,
inputEvent => GD.Print(inputEvent.AsText()) // This lambda expression is cached by the compiler.
);
For achieving certain purposes there are several other variations of input registration APIs.
Associate a delegate directly to the ui_cancel
input event, developer may configure the value in PanelManager.UICancelActionName
.
RegisterInputCancel(() => GD.Print("Canceled!"));
Action myDelegate = () => GD.Print("Canceled!");
RegisterInputCancel(myDelegate);
RemoveInputCancel(myDelegate);
ToggleInputCancel(true, () => GD.Print("Canceled!"));
UIPanel
comes with two extra input binding APIs: EnableCloseWithCancelKey
and DisableCloseWithCancelKey
, Calling EnableCloseWithCancelKey
allows the player to close the current panel with ui_cancel
(PanelManager.UICancelActionName
), and DisableCloseWithCancelKey
revert this behavior.
Associate a delegate to the composites of two inputs, similar to what Input.GetAxis
does.
RegisterInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update // Start, End
);
Action<float> myDelegate = value => GD.Print(value);
RegisterInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
RemoveInputAxis(
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
ToggleInputAxis(
true,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update
);
Associate a delegate to the composites of four input, similar to what Input.GetVector
does.
RegisterInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update // Start, End
);
Action<Vector2> myDelegate = value => GD.Print(value);
RegisterInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
RemoveInputVector(
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
myDelegate,
CompositeInputActionState.Update
);
ToggleInputVector(
true,
BuiltinInputNames.UIUp,
BuiltinInputNames.UIDown,
BuiltinInputNames.UILeft,
BuiltinInputNames.UIRight,
value => GD.Print(value),
CompositeInputActionState.Update
);
Godot provides a list of builtin UI input event names, developer may access these input names from the BuiltinInputNames
class.
The Panel Stack
is designed to maintain the order of the opened panels, when opening a panel, the framework peeks at the panel stack for the top panel, disables every control under it (their opening statuses are cached), and pushes this new instance to the stack. When closing the top panel, the framework pops it from the panel stack and reactivates all the control for the panel underneath it, it also sets the focus to the last selected item before this panel becomes inactive.
The example below shows the panel stack of the following sequence of operations:
timeline
No Panel
Open MainPanel : MainPanel (Active)
Open SettingPanel : SettingPanel (Active) : MainPanel (Deactivated)
Open SettingConfirmPanel : SettingConfirmPanel (Active) : SettingPanel (Deactivated): MainPanel (Deactivated)
Close SettingConfirmPanel : SettingPanel (Reactivated) : MainPanel (Deactivated)
Close SettingPanel : MainPanel (Reactivated)
Close MainPanel
In certain cases where a panel requires frequent opening and closing by design
(think about the inventory panel in some games), instantiating a panel and deleting it on close every time can be expensive. To resolve this performance issue, the framework does automatically panel caching
that you can configure on a per opening/closing basis
.
When creating a panel, by specifying the createPolicy
, you may choose to force the framework instantiate
a new instance of the panel (CreatePolicy.ForceCreate
) or let the framework reuse a cached instance (default)
if possible (CreatePolicy.TryReuse
), of course, if there is no existing cache, a new instance is created anyway.
// When creating a panel.
var panelInstance =
_panelPrefab
.CreatePanel<TPanel>(
createPolicy: CreatePolicy.ForceCreate // CreatePolicy.TryReuse
);
When opening a panel, by specifying the closePolicy
, you may choose to instruct the framework to delete this instance
(ClosePolicy.Delete
) after the transition completes or let the framework to cache this instance (default)
(ClosePolicy.Cache
), which you can reuse when the calling CreatePanel
on the same PackedScene
next time.
// When opening a panel.
panelInstance
.OpenPanel(
closePolicy: ClosePolicy.Delete // ClosePolicy.Cache
);
While working with UIPanel
, certain methods get called at a certain lifetime of a panel, a brief diagram of the panel can be summarised as follows.
---
title: The Summary of Event Methods throughout the lifetime of UIPanel
---
flowchart TD
id1["_OnPanelInitialize()"]
id2["_OnPanelOpen()"]
id3(["ClosePanel()"])
id4["_OnPanelClose()"]
id5["_OnPanelPredelete()"]
id6["_OnPanelNotification()"]
id0[["Framework Calls"]] -.-> id1
id1 -.->|Framework Calls|id2
subgraph Called Multiple Times before the Panel gets Freed
id2 --> id3
id3 -.->|Framework Calls|id4
id4 -.->|Framework Calls|id2
end
id6 -.->|Framework Calls|id5
id7[["Godot Calls"]] -.-> id6
- When calling
CreatePanel<TPanel>(PackedScene)
and causing a new instance of the creation, after the framework has done basic initializing, the_OnPanelInitialize
method of that instance gets invoked. This method gets called only once throughout the panel lifetime; that means, if theCreatePanel
has reused an instance of the panel, this method is not invoked again. - When calling any of the
OpenPanel
on a non-opened panel instance, after the framework has done preparations for opening this panel, the_OnPanelOpen
method gets invoked. For a closed panel that gets cached,_OnPanelOpen
will get re-invoked when the panel gets reopened. - When calling the
ClosePanel
, after the framework has done preparations for closing this panel, the_OnPanelClose
method gets invoked. For a panel that gets cached,_OnPanelClose
will get re-invoked when the panel gets reopened and closed. - A
UIPanel
delegates the_Notification
engine call to_OnPanelNotification
, and calls_OnPanelPredelete
when necessary.
When opening a new panel, the currently active panel becomes unavailable (such as buttons will no longer be clickable or focusable)
, you may also control whether the current panel should stay visible or hidden.
Setting the previousPanelVisual
to PreviousPanelVisual.Hidden
in OpenPanel
, will instruct the framework to hide the previous panel
using its PanelTweener
, otherwise the panel will stays visible (default)
(PreviousPanelVisual.Visible
).
// When opening a panel.
panelInstance
.OpenPanel( // Any panel opening method.
previousPanelVisual: PreviousPanelVisual.Hidden // PreviousPanelVisual.Visible
);
precautionsIt is a common practice for passing the argument to/receiving return value from a panel, UIPanelArg<TOpenArg, TCloseArg>
is here to achieve this requirement.
// MyArgumentPanel.cs
// Defines a panel that accepts an int as the opening argument, and string as the returning value.
public partial class MyArgumentPanel : UIPanelArg<int, string>
{
protected override void _OnPanelOpen(int openArg) // The opening argument passed from the caller.
{
GD.Print($"Opened with argument: {openArg}");
ClosePanel(openArg.ToString()); // The ClosePanel method requires a return value.
}
}
Different from the regular UIPanel
type, the OpenPanel
methods of a UIPanelArg
accepts an extra argument and passes it to the _OnPanelOpen(TOpenArg)
panel event method and its async/callback-styled overload has its way for obtaining the return value.
// In caller class.
[Export] private PackedScene _panelPrefab;
// In caller method.
var argPanelInstance = _panelPrefab.CreatePanel<MyArgumentPanel>();
// Async/Await-styled open method.
string returnValue = await argPanelInstance.OpenPanelAsync(10); // return value is "10".
// Callback/Delegate-styled open method.
argPanelInstance.OpenPanel(10, onPanelCloseCallback: value => GD.Print(value == "10")) // prints true when the panel closes.
The UIPanelArg
supports both passing an argument
and returning a value
, if one of the features is not needed, you may use the Empty
struct to serve as a placeholder.
// The definition for a panel that doesn't require an opening argument.
public partial class MyArgumentPanel : UIPanelArg<Empty, string>
{
protected override void _OnPanelOpen(Empty _)
{
ClosePanel("Hello World!");
}
}
// In caller method
argPanelInstance.OpenPanelAsync(Empty.Default);
// The definition for a panel that doesn't requires returning value.
public partial class MyArgumentPanel : UIPanelArg<int, Empty>
{
protected override void _OnPanelOpen(int openArg)
{
GD.Print($"Opened with argument: {openArg}");
ClosePanel(Empty.Default);
}
}
All panels in are instantiated under root/RootPanelViewport/PanelRoot
by default, developers may configure the container for the opening panel through a series of APIs.
Similar to the Panel Stack
, Panel Container Stack
is designed for managing the panel containers
, the developer may push a control to the panel container stack using PanelManager.PushPanelContainer
, and pop the topmost container by PanelManager.PopPanelContainer
. Similar to the restrictions of opening and closing panels, developers are only allowed to pop the topmost container before they are allowed to pop the other containers.
To prevent unexpected poping of containers, each PushPanelContainer
operation is authorized
by a Node, that is, you need to provide a key
when pushing a new container, and popping the container with the same key
.
// In class
[Export] private Control _myContainer;
// In method
// Every opened panel after this line will get instantiating/reparenting under _myContainer.
PanelManager.PushPanelContainer(this, _myContainer);
// Every opened panel after this line will get instantiating/reparenting under the default panel container.
PanelManager.PopPanelContainer(this);
Please note that, when working with customized panel containers, be careful when
spawning panels under a panel/custom container
that'sgetting deleted in the future
, while the framework is trying its best to handle deleted panels, it is possible todelete custom panel containers that have active panels live under
, such behavior will possibly crash the framework, developers are recommended toensure every panel under a custom container has closed
beforepopping/deleting that container
.
Developers may customize a panel's visual transition behavior when opening/closing
by accessing its PanelTweener
property, or modifying the PanelManager.DefaultPanelTweener
to set the default tweener for all panels globally.
There are two preconfigured Tweenrs provided with the framework.
- NonePanelTweener: This tweener simply hides and shows the panel instantly on open and close, it is also the default value of
PanelManager.DefaultPanelTweener
, you may access the global instance of this tweener fromNonPanelTweener.Instance
. - FadePanelTweener: This tweener performs fade transition for the panel opening and closing, after instantiating the tweener, you may configure the transition time by accessing its
FadeTime
property.
By inheriting the IPanelTweenr
interface, the developer may customize their transition effects.
/// <summary>
/// Defines the behavior for panel transitions.
/// </summary>
public interface IPanelTweener
{
/// <summary>
/// This sets the default visual appearance for a panel.
/// </summary>
/// <param name="panel">The target panel.</param>
void Init(Control panel);
/// <summary>
/// This async method manages the behavior when the panel is showing up.
/// </summary>
/// <param name="panel">The target panel.</param>
/// <param name="onFinish">Called by the method when the behavior is considered finished, or not be called at all if the behavior is interrupted</param>
void Show(Control panel, Action? onFinish);
/// <summary>
/// This async method manages the behavior when the panel is hiding out.
/// </summary>
/// <param name="panel">The target panel.</param>
/// <param name="onFinish">Called by the method when the behavior is considered finished, or not be called at all if the behavior is interrupted</param>
void Hide(Control panel, Action? onFinish);
}
Most asynchronous methods in the framework are written in callback/delegate
style, that is, have a Action onFinish
argument in their method signature.
To provide an async/await
styled programming experience, the AsyncInterop
utility class is used to convert a callback/delegate
styled API into an async/await
styled one.
The returned AsyncAwaitable
can be used with the await
keyword, similar to ValueTask, developers may only await for this value once.
public void CallbackStyledMethod(Action onFinish);
public AsyncAwaitable AsyncAwaitStyledMethodAsync()
{
return AsyncInterop.ToAsync(CallbackStyledMethod);
}
public void CallbackStyledMethodWithReturn(Action<int> onFinish);
public AsyncAwaitable<int> AsyncAwaitStyledMethodWithReturnAsync()
{
return AsyncInterop.ToAsync<int>(CallbackStyledMethodWithReturn);
}
While there are precautions taken, there are still cases where certain uses of APIs could inevitably crash the framework.
The following panel event methods are executed in under try ... catch block
, throwing exceptions in the overrides of these methods will not crash the framework.
_OnPanelInitialize
_OnPanelOpen
_OnPanelClose
_OnPanelPredelete
_OnPanelNotification
- Registered input events
The following usage WILL crash the framework:
- Creating a panel by specifying a type that's not equal to the type of the
Script
. - Opening a panel that's not initialized, which probably means the instance of this panel is not obtained through the
CreatePanel
API. - Opening a panel that's already opened.
- Closing a panel that's not the last opened panel.
- Providing an invalid
CompositeInputActionState
enum. - Authorising a
panel container popping
with anode
which is pushed by a differentnode
. - Reuse the
await
keyword on an AsyncAwaitable that has already awaited, or access any of its parameters. - Calling
GetResult()
on anAsyncAwaitable
that has not been completed yet.