Compare commits

..

76 Commits

Author SHA1 Message Date
11423d86e5 Document GridSet, fix resource hot reload on every frame. 2025-11-15 17:20:22 +01:00
828ec4973f Add docs to UIInputContext. 2025-07-04 19:13:52 +02:00
bef27762ee Prepare UIElement and Style for animated style implementation. 2025-06-30 18:34:04 +02:00
8a3ed42bb7 Make Merge a method of Style. 2025-06-30 18:04:29 +02:00
52bbf5a9e1 Update TODO 2025-06-30 18:01:16 +02:00
49aa3f071f Update TODO 2025-06-29 23:35:40 +02:00
6c0e6707ee Use font.Size for totalHeight, update default style in TestGame, add Pressed virtual method to Button. 2025-06-29 23:35:08 +02:00
c6e1bf7f41 Add parsing for Size. 2025-06-29 23:27:16 +02:00
5d3a2c2222 Update TODO 2025-06-29 22:27:14 +02:00
e1e965796b Add proper inheritance for TextColor. 2025-06-29 22:24:54 +02:00
207c8a20a4 We got CSS at home 2025-06-29 22:21:13 +02:00
681496d812 Update TODO 2025-06-29 19:39:02 +02:00
6f3a945f34 Update TODO 2025-06-29 19:38:10 +02:00
c18adaeede Element styling, rename IsMousePressed to IsMouseButtonPressed in InputSystem, Button widget. 2025-06-29 19:22:14 +02:00
e75dcb7753 Update TODO 2025-06-29 17:40:22 +02:00
09c24e7123 Don't update MarginContainer if it already matches its parent size, update TestGame. 2025-06-29 17:39:52 +02:00
87e0a69dcf WIP: button widget 2025-06-29 17:29:47 +02:00
b810e1b882 Update TODO 2025-06-29 16:52:48 +02:00
26cb66dbe0 Font fallbacks. 2025-06-29 16:51:06 +02:00
b3c1db3145 Show fallback glyph if its not present in the font, add Japanese font to TestGame. 2025-06-29 16:21:34 +02:00
9bc9810c8f WIP: use kerning for text measuring. 2025-06-29 15:53:29 +02:00
0ec4e45c38 WIP: measure text in Font. 2025-06-29 15:10:37 +02:00
6b108ba56c Make Rect a core type, move Color out of Utils. 2025-06-29 14:29:52 +02:00
e0e8d6e9ff Move some FreeType operations to Font itself, add lazy loading for Glyph data, remove IDisposable from base Resource. 2025-06-29 14:28:39 +02:00
552e05d498 WIP: use Unicode for charmaps inside a font, try read all unicode symbols. 2025-06-29 14:09:11 +02:00
17196c9437 WIP: load fonts with FreeType. 2025-06-25 23:20:46 +02:00
4b2aa31b63 Update TODO 2025-06-25 21:46:30 +02:00
90fe38b017 Remove resizing of FillContainer in TestGame. 2025-06-25 21:41:24 +02:00
8a1e359c22 WIP: GridSet, add Vector2.Snapped extension method. 2025-06-25 19:50:03 +02:00
64d3dba42d Add MarginContainer, mark parent elements dirty when child gets marked dirty too. 2025-06-25 00:35:35 +02:00
5bf052db96 Rename Frame to FillContainer. 2025-06-24 23:48:31 +02:00
389a73cf24 Don't reassign Size in Container if the new size is identical to current size in RecalculateSizes. 2025-06-24 23:44:38 +02:00
d44341974f Add dirty UI element visualization to UISystem, fix Frame being constantly updated. 2025-06-24 23:29:26 +02:00
b2f3e1c351 Remove readonly keyword from FromHexString in Color (whoops!), add docs to Color. 2025-06-24 23:11:12 +02:00
255dea138b Make all predefined colors in Color readonly. 2025-06-24 23:04:17 +02:00
9fa6b45cea Add more colors to Color. 2025-06-24 22:58:52 +02:00
5871e8966b Mark UIElement as dirty in SetParent. 2025-06-24 22:50:45 +02:00
ed9f17e6c4 Add documentation to IElement, Anchor and UIElement. 2025-06-24 22:46:35 +02:00
4362e88eab Add more methods to retrieve resources in ResourceRef, and document existing ones. 2025-06-24 22:25:26 +02:00
58efd449a8 Set window state for Raylib, make Frame occupy full size of parent UIElement or window. 2025-06-24 22:11:38 +02:00
78b46cb38e Update Voile and Voile.OpenAL projects .NET versions to 9.0. 2025-06-24 20:09:01 +02:00
6c3576891e Update TODO 2025-06-24 19:46:52 +02:00
03668849bc Fix any remaining bugs with anchor positioning system, use LocalPosition for UIElement, and make containers use that for arrangement. 2025-06-24 19:45:18 +02:00
b228f04670 Update TODO 2025-06-24 14:51:30 +02:00
a5d2668c18 Move layouting to Render instead of Update, use Update for input. 2025-06-24 14:48:55 +02:00
9a3512702a Update TODO. 2025-06-24 01:49:03 +02:00
61ac079f2b Unify Containers and Widgets by creating a base UIElement, add more anchor types, make anchor calculations an extension of Anchor. 2025-06-22 23:28:30 +02:00
95ae2de7ac Apply an anchor offset for Anchor.TopRight too. 2025-06-22 15:56:05 +02:00
683656dee8 Initial implementation of UI anchors, update TestGame. 2025-06-21 22:23:19 +02:00
ae1b612524 Workaround: cache inputs in RaylibInputSystem and force rendering at 60 FPS for more consistent inputs. 2025-06-20 23:02:46 +02:00
7e86898e1a WIP: UI input handling. 2025-06-20 22:24:30 +02:00
a1f56f49fb Update TODO 2025-06-20 20:21:57 +02:00
84efb2a3d1 Add ContainsPoint helper method to Widget. 2025-06-20 20:21:45 +02:00
5b29ea012e Add documentation to containers. 2025-06-20 20:16:36 +02:00
f0c721bb0f Add ConfineToChildren to Container, containers will use MinimumRect as a size if a given size is smaller than minimum, update documentation for Container. 2025-06-20 20:08:17 +02:00
bc95fff4a3 Merge FlexContainer, WIP dynamic container resizing. 2025-06-20 19:46:42 +02:00
76dafe9996 WIP: dirty flag for UI. 2025-06-20 19:34:10 +02:00
a9a8113dd9 Add RemoveChild to IParentableElement, only resize Container if children overflow it. 2025-06-20 19:14:29 +02:00
3154b3fa10 Add debug rectangle size rendering, auto-resize containers to fit all children. 2025-06-20 18:57:36 +02:00
1b09d80f7a Update TODO 2025-06-19 15:33:54 +02:00
63448210e2 Update TODO 2025-06-19 15:32:29 +02:00
3460c124b8 Add GridContainer. 2025-06-19 15:13:25 +02:00
6affded730 Update arrangement logic for containers, remove position property from Rect, add Size property to IElement. 2025-06-19 15:08:23 +02:00
e499691714 Add container nesting. 2025-06-19 14:50:05 +02:00
806c9cc1d4 WIP: UI system, containers and widgets. 2025-06-19 14:30:20 +02:00
a450ed9819 Update TODO, remove audio system from TestGame. 2025-06-19 13:28:10 +02:00
15214c9e21 WIP: OpenAL audio system. 2025-06-06 22:40:16 +02:00
a806e3b764 Remove audio, will make it as separate packages instead. 2024-10-29 01:17:55 +01:00
e51d28ce89 WIP: SoLoud audio system, make Sound be in 16-bit PCM. 2024-10-28 23:13:14 +01:00
99624e152d Remove FMOD package references in Voile, include SoLoud.NET. 2024-10-28 20:51:35 +01:00
64d72cc053 InputSystem optimizations and changes. 2024-10-21 19:18:37 +02:00
07224d684d Update TODO, change .gitignore. 2024-10-21 18:36:51 +02:00
c8f0de6aab Update TODO 2024-10-21 18:09:37 +02:00
503473c6b3 Upgrade Raylib_cs to latest version, use safe version of LoadFontFromMemory in Raylib. 2024-10-21 18:08:04 +02:00
20036be50f Update TestGame project to build as a WinExe project. 2024-10-21 17:54:20 +02:00
fdcf29d6e0 Initial virtual file system implementation. 2024-10-21 01:26:22 +02:00
66 changed files with 3762 additions and 722 deletions

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ Voile.Fmod/runtimes/**/*.so*
# DocFX # DocFX
.cache .cache
/**/_site/ /**/_site/
*.dmp

50
TODO.md
View File

@@ -1,5 +1,12 @@
# Voile MVP # Voile MVP
## Bugfixes
- ActionJustPressed and KeyboardKeyJustPressed don't detect inputs consistently.
- **Solution**: This is a problem related to custom frame pacing for fixed timestep. Raylib polls input in BeginFrame, which means certain functions related to JustPressed* may work incorrectly when polled in a separate timestep. This can be fixed if the entire input system will be moved to SDL or with a custom render + input backend altogether.
- ~~Fix any remaining bugs with anchor positioning system.~~
- ~~Containers don't position their chilren correctly when using anchors and adding/removing them.~~
## Core ## Core
- ~~Add and implement interfaces for systems (ISystem, IUpdatableSystem, etc.)~~ - ~~Add and implement interfaces for systems (ISystem, IUpdatableSystem, etc.)~~
@@ -7,6 +14,7 @@
- ~~TextDataResource providing a convenient wrapper around TOML data files.~~ - ~~TextDataResource providing a convenient wrapper around TOML data files.~~
- ~~Add documentation for common classes.~~ - ~~Add documentation for common classes.~~
- ~~Hot reloading of resources.~~ - ~~Hot reloading of resources.~~
- Separate render and update thread.
## I/O ## I/O
@@ -14,13 +22,15 @@
- ~~Reimplement unloading.~~ - ~~Reimplement unloading.~~
- Finalize ResourceManager and ResourceLoader APIs for 1.0. - Finalize ResourceManager and ResourceLoader APIs for 1.0.
- Add async API for ResourceManager. - Add async API for ResourceManager.
- Virtual file system. - ~~Virtual file system.~~
- Custom PAK format using the VFS implementation.
- (stretch goal) Streamed resource loading. - (stretch goal) Streamed resource loading.
## Serialization ## Serialization
- Serialize attribute. - Serialize attribute.
- Add automatic serialization of resources through source generation and System.Text.Json. - Add automatic serialization of resources through source generation and System.Text.Json.
- Make sure this serialization system works well with CLR and NativeAOT.
- ~~Provide means for fetching key/value configuration (INI? TOML?)~~ - ~~Provide means for fetching key/value configuration (INI? TOML?)~~
- Expose some sort of ConfigFile class for safe key/value configuration fetching. - Expose some sort of ConfigFile class for safe key/value configuration fetching.
@@ -29,7 +39,7 @@
- ~~API for drawing textured quads~~ - ~~API for drawing textured quads~~
- ~~Camera API~~ - ~~Camera API~~
- Arbitrary mesh rendering API. - Arbitrary mesh rendering API.
- Create WebGPU renderer (StandardRenderer) - Create WebGPU renderer (StandardRenderSystem)
## Audio ## Audio
@@ -59,9 +69,37 @@
- Make action system use an InputMap resource instead. - Make action system use an InputMap resource instead.
- Gamepad support - Gamepad support
## Diagnostics
- Implement Profiler class.
- Generate a Speedscope report.
- Collect reports for systems
- Game
- Render
- Audio
- ResourceManager
- UI
- Particles
## UI ## UI
- Immediate mode API. - ~~Layout~~
- Styling. - ~~Containers~~
- Basic input elements (button, text field, toggle). - ~~VerticalContainer~~
- Containers (vertical and horizontal). - ~~HorizontalContainer~~
- ~~GridContainer~~
- ~~FlexContainer~~
- ~~Positioning (anchors)~~
- ~~Move layouting to Render instead of Update, use Update for input.~~
- Input propagation
- ~~Pass input to widgets.~~
- Add element focus logic, make them focusable with action inputs.
- Basic input elements (~~button~~, text field, toggle).
- Styling
- ~~Style sheet~~
- ~~Add style settings for UI panels (for buttons, labels, etc.).~~
- ~~Parse StyleSheet from TOML file.~~
- Animated styles
- (stretch goal) Style variables
- Find a way to reference external assets in the style (fonts, textures).
- Create a default style for widgets.

View File

@@ -0,0 +1,63 @@
[Button]
BackgroundColor = "#0f62fe"
TextColor = "#ffffff"
Padding = 16.0
# Creates an empty rule for Button.Normal.
# This will inherit style from Button, this is a temporary workaround.
[Button.Normal]
[Button.Hovered]
BackgroundColor = "#0353e9"
[Button.Pressed]
BackgroundColor = "#002d9c"
[Button.Danger]
TextColor = "#ffffff"
[Button.Danger.Normal]
BackgroundColor = "#da1e28"
[Button.Danger.Hovered]
BackgroundColor = "#ba1b23"
[Button.Danger.Pressed]
BackgroundColor = "#750e13"
[Button.Outline]
BackgroundColor = [0, 0, 0, 0]
BorderSize = 1.0
BorderColor = "#0f62fe"
[Button.Outline.Normal]
TextColor = "#0353e9"
[Button.Outline.Hovered]
BackgroundColor = "#0353e9"
[Button.Outline.Pressed]
BackgroundColor = "#002d9c"
BorderColor = [0, 0, 0, 0]
[Button.Link]
BackgroundColor = [0, 0, 0, 0]
TextColor = "#0f62fe"
Padding = 0.0
[Button.Link.Normal]
[Button.Link.Hovered]
BorderColor = "#0043ce"
TextColor = "#0043ce"
BorderSize = [0, 0, 0, 1]
[Button.Link.Pressed]
TextColor = "#161616"
BorderSize = [0, 0, 0, 1]
BorderColor = "#161616"
# Default background color for all Container derived classes.
[Container]
BackgroundColor = "#e0e0e0"

View File

@@ -17,4 +17,4 @@ LinearVelocityRandom = 0.5
ScaleBegin = 0.1 ScaleBegin = 0.1
ScaleEnd = 5.0 ScaleEnd = 5.0
ColorBegin = [255, 162, 0] ColorBegin = [255, 162, 0]
ColorEnd = [64, 64, 64, 0] ColorEnd = [0, 0, 0, 0]

Binary file not shown.

View File

@@ -0,0 +1,3 @@
[FontSet]
fonts = ["Inter-Regular.ttf", "NotoSansJP-Regular.ttf"]

Binary file not shown.

View File

@@ -5,17 +5,28 @@ using Voile.Systems.Particles;
using System.Numerics; using System.Numerics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Voile.Rendering; using Voile.Rendering;
using Voile.OpenAL;
using Voile.UI;
using Voile.UI.Widgets;
using Voile.UI.Containers;
public class TestGame : Game public class TestGame : Game
{ {
public override string Name => "Jump Adventures 2"; public override string Name => "Test Game";
public override string ResourceRoot => "Resources/"; public override string ResourceRoot => "Resources/";
public override void Initialize() public override void Initialize()
{ {
InitializeSystemsDefault(); InitializeSystemsDefault();
_uiSystem = new UISystem(Input);
// _uiSystem.RenderDebugRects = true;
ResourceManager.EnableFileWatching();
_particleSystem = new ParticleSystem(); _particleSystem = new ParticleSystem();
AddSystemToUpdate(_uiSystem);
AddSystemToUpdate(_particleSystem); AddSystemToUpdate(_particleSystem);
} }
@@ -27,62 +38,99 @@ public class TestGame : Game
protected override void LoadResources() protected override void LoadResources()
{ {
ResourceManager.AddResourceLoaderAssociation(new ParticleEmitterSettingsResourceLoader()); ResourceManager.AddResourceLoaderAssociation(new ParticleEmitterSettingsResourceLoader());
ResourceManager.AddResourceLoaderAssociation(new StyleSheetLoader());
if (!ResourceManager.TryLoad("fonts/Inter-Regular.ttf", out _font)) if (!ResourceManager.TryLoad("fonts/Inter-Regular.ttf", out _font))
{ {
} }
if (!ResourceManager.TryLoad("fonts/NotoSansJP-Regular.ttf", out _jpFont))
{
}
ResourceManager.TryLoad("icon.png", out _icon); ResourceManager.TryLoad("icon.png", out _icon);
ResourceManager.TryLoad("sounds/test_sound_mono.ogg", out _sound);
if (!ResourceManager.TryLoad("fire_effect.toml", out _fireEffect)) if (!ResourceManager.TryLoad("fire_effect.toml", out _fireEffect))
{ {
throw new Exception("Failed to load emitter settings!"); throw new Exception("Failed to load emitter settings!");
} }
if (ResourceManager.TryLoad("default.style.toml", out _styleSheet))
{
}
_defaultFontSet = new([_font, _jpFont]);
} }
protected override void Ready() protected override void Ready()
{ {
Input.AddInputMapping("reload", new InputAction[] { new KeyInputAction(KeyboardKey.R) }); Input.AddInputMapping("reload", new IInputAction[] { new KeyInputAction(KeyboardKey.R) });
_emitterId = _particleSystem.CreateEmitter(Renderer.WindowSize / 2, _fireEffect); // _emitterId = _particleSystem.CreateEmitter(Renderer.WindowSize / 2, _fireEffect);
_uiSystem.SetStyleSheet(_styleSheet);
var addButton = new Button("Default button", _defaultFontSet);
var removeButton = new Button("Danger button", _defaultFontSet);
removeButton.StyleVariant = "Danger";
var outlineButton = new Button("Outline button", _defaultFontSet);
outlineButton.StyleVariant = "Outline";
var linkButton = new Button("Link button", _defaultFontSet);
linkButton.StyleVariant = "Link";
var c = new HorizontalContainer()
{
StyleVariant = "Layer01",
ConfineToContents = true,
Anchor = Anchor.TopCenter
};
c.AddChild(addButton);
c.AddChild(removeButton);
c.AddChild(outlineButton);
c.AddChild(linkButton);
var vc = new VerticalContainer(0.0f);
vc.AddChild(c);
var f = new MarginContainer(new Size(0.0f));
f.AddChild(_container);
vc.AddChild(f);
_rootFill.AddChild(vc);
_uiSystem.AddElement(_rootFill);
} }
protected override void Update(double deltaTime) protected override void Update(double deltaTime)
{ {
if (Input.IsActionPressed("reload"))
{
// ResourceManager.Reload();
// _particleSystem!.RestartEmitter(_emitterId);
}
} }
protected override void Render(double deltaTime) protected override void Render(double deltaTime)
{ {
if (Input.IsActionPressed("reload"))
{
ResourceManager.Reload();
_particleSystem!.RestartEmitter(_emitterId);
}
if (Input.KeyboardKeyJustPressed(KeyboardKey.One))
{
_particleSystem.CreateEmitter(Input.GetMousePosition(), _fireEffect);
}
if (Input.IsMouseButtonDown(MouseButton.Left))
{
_particleSystem.SetEmitterPosition(_emitterId, Input.GetMousePosition());
}
Renderer.ClearBackground(Color.Black); Renderer.ClearBackground(Color.Black);
foreach (var emitter in _particleSystem!.Emitters)
{ // foreach (var emitter in _particleSystem!.Emitters)
DrawEmitter(emitter); // {
} // DrawEmitter(emitter);
// }
Renderer.ResetTransform(); Renderer.ResetTransform();
_uiSystem.Render(Renderer);
Renderer.DrawText(_font, $"Render: {RenderFrameTime.TotalMilliseconds:F1} ms", Color.White);
Renderer.SetTransform(new Vector2(0.0f, 16.0f), Vector2.Zero);
Renderer.DrawText(_font, $"Update: {UpdateTimeStep * 1000:F1} ms", Color.White);
} }
private void DrawEmitter(ParticleEmitter emitter) private void DrawEmitter(ParticleEmitter emitter)
@@ -105,8 +153,34 @@ public class TestGame : Game
} }
[NotNull] private ParticleSystem _particleSystem; [NotNull] private ParticleSystem _particleSystem;
[NotNull] private UISystem _uiSystem;
private int _emitterId; private int _emitterId;
private ResourceRef<ParticleEmitterSettingsResource> _fireEffect; private ResourceRef<ParticleEmitterSettingsResource> _fireEffect;
private ResourceRef<Font> _font; private ResourceRef<Font> _font;
private ResourceRef<Font> _jpFont;
private ResourceRef<StyleSheet> _styleSheet;
private FontSet _defaultFontSet;
private ResourceRef<Sound> _sound;
private ResourceRef<Texture2d> _icon; private ResourceRef<Texture2d> _icon;
private FlexContainer _container = new(minimumSize: new Rect(256.0f, 256.0f), new())
{
Anchor = Anchor.Center,
Direction = FlexDirection.Column,
Justify = JustifyContent.Start,
Align = AlignItems.Center,
Wrap = true,
Gap = 8.0f,
StyleVariant = "Layer02",
};
[NotNull] private Label _label;
private FillContainer _rootFill = new();
private HorizontalContainer _buttonContainer = new(16)
{
ConfineToContents = true,
};
} }

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<PublishAot>true</PublishAot> <PublishAot>true</PublishAot>
@@ -11,6 +11,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Voile/Voile.csproj" /> <ProjectReference Include="../Voile/Voile.csproj" />
<ProjectReference Include="../Voile.OpenAL/Voile.OpenAL.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,59 @@
using Voile.Audio;
using Silk.NET.OpenAL;
namespace Voile.OpenAL;
public class OpenALSystem : AudioSystem
{
public OpenALSystem()
{
_al = AL.GetApi();
_alc = ALContext.GetApi();
Init();
}
public override void PlaySound(Sound sound, float volume)
{
var buffer = CreateAlBuffer(sound.Buffer, sound.SampleRate, sound.Channel);
var source = CreateAlSource(buffer);
_al.SourcePlay(source);
}
public override void Update(double deltaTime)
{
throw new NotImplementedException();
}
private unsafe void Init()
{
_device = _alc.OpenDevice("");
_context = _alc.CreateContext(_device, null);
_alc.MakeContextCurrent(_context);
}
private uint CreateAlBuffer(ReadOnlyMemory<short> data, int sampleRate, SoundChannel channels)
{
var buffer = _al.GenBuffer();
var format = channels == SoundChannel.Mono ? BufferFormat.Mono16 : BufferFormat.Stereo16;
unsafe
{
_al.BufferData(buffer, format, data.Pin().Pointer, data.Length, sampleRate);
}
return buffer;
}
private uint CreateAlSource(uint buffer)
{
var source = _al.GenSource();
_al.SetSourceProperty(source, SourceInteger.Buffer, buffer);
return source;
}
private unsafe Device* _device;
private ALContext _alc;
private unsafe Context* _context;
private AL _al;
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Voile/Voile.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.OpenAL" Version="2.21.0" />
</ItemGroup>
</Project>

View File

@@ -13,19 +13,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Folder", "Solution
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestGame", "TestGame\TestGame.csproj", "{DBA85D7B-0A91-405B-9078-5463F49AE47E}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestGame", "TestGame\TestGame.csproj", "{DBA85D7B-0A91-405B-9078-5463F49AE47E}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Voile.OpenAL", "Voile.OpenAL\Voile.OpenAL.csproj", "{3ABB7D30-4B64-43AD-A14F-E532B12AFC60}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{393AA04F-A0DE-42F2-AAEC-6B2DCFB7A852}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{393AA04F-A0DE-42F2-AAEC-6B2DCFB7A852}.Debug|Any CPU.Build.0 = Debug|Any CPU
{393AA04F-A0DE-42F2-AAEC-6B2DCFB7A852}.Release|Any CPU.ActiveCfg = Release|Any CPU
{393AA04F-A0DE-42F2-AAEC-6B2DCFB7A852}.Release|Any CPU.Build.0 = Release|Any CPU
{DA4FDEDC-AA81-4336-844F-562F9E763974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DA4FDEDC-AA81-4336-844F-562F9E763974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA4FDEDC-AA81-4336-844F-562F9E763974}.Debug|Any CPU.Build.0 = Debug|Any CPU {DA4FDEDC-AA81-4336-844F-562F9E763974}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA4FDEDC-AA81-4336-844F-562F9E763974}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA4FDEDC-AA81-4336-844F-562F9E763974}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -34,5 +29,12 @@ Global
{DBA85D7B-0A91-405B-9078-5463F49AE47E}.Debug|Any CPU.Build.0 = Debug|Any CPU {DBA85D7B-0A91-405B-9078-5463F49AE47E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBA85D7B-0A91-405B-9078-5463F49AE47E}.Release|Any CPU.ActiveCfg = Release|Any CPU {DBA85D7B-0A91-405B-9078-5463F49AE47E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBA85D7B-0A91-405B-9078-5463F49AE47E}.Release|Any CPU.Build.0 = Release|Any CPU {DBA85D7B-0A91-405B-9078-5463F49AE47E}.Release|Any CPU.Build.0 = Release|Any CPU
{3ABB7D30-4B64-43AD-A14F-E532B12AFC60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3ABB7D30-4B64-43AD-A14F-E532B12AFC60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3ABB7D30-4B64-43AD-A14F-E532B12AFC60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3ABB7D30-4B64-43AD-A14F-E532B12AFC60}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

46
Voile/GridSet.cs Normal file
View File

@@ -0,0 +1,46 @@
using System.Collections;
using System.Collections.Generic;
using System.Numerics;
using Voile.Extensions;
namespace Voile;
public class GridSet<T>
{
/// <summary>
/// The size of a cell of this <see cref="GridSet"/>.
/// </summary>
public float CellSize { get; }
public GridSet(float cellSize = 32.0f)
{
CellSize = cellSize;
}
/// <summary>
/// Add an element to this <see cref="GridSet"/>.
/// </summary>
/// <param name="position">Position of the element in this <see cref="GridSet"/>.</param>
/// <param name="child">Element to add.</param>
public void Add(Vector2 position, T child)
{
var snap = Vector2.One * CellSize;
position = position.Snapped(snap);
if (_values.TryGetValue(position, out var list))
{
list.Add(child);
}
else
{
_values.Add(position, new List<T>());
}
}
/// <summary>
/// Removes an element from this <see cref="GridSet"/>.
/// </summary>
/// <param name="child">Element to remove.</param>
public void Remove(T child) => throw new NotImplementedException();
private Dictionary<Vector2, List<T>> _values = new();
}

View File

@@ -1,7 +0,0 @@
namespace Voile
{
public class AudioBus
{
}
}

View File

@@ -1,9 +0,0 @@
namespace Voile
{
public class AudioEffect { }
public class AudioEffectReverb : AudioEffect
{
}
}

View File

@@ -1,40 +1,7 @@
using Voile;
namespace Voile.Audio; namespace Voile.Audio;
public abstract class AudioSystem : IStartableSystem, IUpdatableSystem, IDisposable public abstract class AudioSystem : IUpdatableSystem
{ {
public void Start() => Initialize(); public abstract void Update(double deltaTime);
public abstract void PlaySound(Sound sound, float volume);
public void Update(double deltaTime) => Update();
public void Dispose()
{
Shutdown();
GC.SuppressFinalize(this);
}
protected abstract void Initialize();
protected abstract void Update();
protected abstract void Shutdown();
// BUS
public abstract void CreateBus(string busName);
public abstract void SetBusVolume(string busName, float volume);
public abstract float GetBusVolume(string busName);
// SOUND
public abstract void PlaySound(Sound sound, float pitch, float volume, string bus = "Master");
public void PlaySound(Sound sound, string bus = "Master") => PlaySound(sound, 1.0f, 1.0f, bus);
public SoundInstance CreateInstance(Sound sound)
{
var instance = new SoundInstance(this, sound);
return instance;
}
// EFFECTS
public abstract void AddBusEffect<T>(T effect, string bus = "Master") where T : AudioEffect;
private LehmerRandom _random = new LehmerRandom();
} }

View File

@@ -1,48 +0,0 @@
namespace Voile.Audio
{
/// <summary>
/// Dummy audio system.
/// </summary>
public class DummyAudioSystem : AudioSystem
{
public override void AddBusEffect<T>(T effect, string bus = "Master")
{
return;
}
public override void CreateBus(string busName)
{
return;
}
public override float GetBusVolume(string busName)
{
return 0.0f;
}
protected override void Initialize()
{
return;
}
public override void SetBusVolume(string busName, float volume)
{
return;
}
protected override void Shutdown()
{
return;
}
protected override void Update()
{
return;
}
public override void PlaySound(Sound sound, float pitch, float volume, string bus = "Master")
{
return;
}
}
}

View File

@@ -1,42 +0,0 @@
namespace Voile.Audio
{
public class SoundInstance
{
protected virtual Sound Sound => _sound;
public SoundInstance(AudioSystem backend, Sound sound)
{
_backend = backend;
_sound = sound;
}
public SoundInstance PitchVariation(float min, float max)
{
var random = new LehmerRandom();
_pitch = (float)random.NextDouble() * (max - min) + min;
return this;
}
public SoundInstance VolumeVariation(float min, float max)
{
var random = new LehmerRandom();
_volume = (float)random.NextDouble() * (max - min) + min;
return this;
}
public SoundInstance OnBus(string bus = "Master")
{
_bus = bus;
return this;
}
public void Play()
{
_backend.PlaySound(Sound, _pitch, _volume, _bus);
}
private readonly AudioSystem _backend;
private readonly Sound _sound;
private string _bus = "Master";
private float _pitch, _volume = 1.0f;
}
}

302
Voile/Source/Color.cs Normal file
View File

@@ -0,0 +1,302 @@
namespace Voile
{
// Based on https://github.com/ppr-game/PPR/blob/engine/PER.Util/src/Color.cs
/// <summary>
/// A record struct representing a color.
/// </summary>
public record struct Color
{
public static readonly Color Transparent = new(1.0f, 1.0f, 1.0f, 0.0f);
public static readonly Color AliceBlue = new(0xF0F8FF);
public static readonly Color AntiqueWhite = new(0xFAEBD7);
public static readonly Color Aqua = new(0x00FFFF);
public static readonly Color Aquamarine = new(0x7FFFD4);
public static readonly Color Azure = new(0xF0FFFF);
public static readonly Color Beige = new(0xF5F5DC);
public static readonly Color Bisque = new(0xFFE4C4);
public static readonly Color Black = new(0x000000);
public static readonly Color BlanchedAlmond = new(0xFFEBCD);
public static readonly Color Blue = new(0x0000FF);
public static readonly Color BlueViolet = new(0x8A2BE2);
public static readonly Color Brown = new(0xA52A2A);
public static readonly Color BurlyWood = new(0xDEB887);
public static readonly Color CadetBlue = new(0x5F9EA0);
public static readonly Color Chartreuse = new(0x7FFF00);
public static readonly Color Chocolate = new(0xD2691E);
public static readonly Color Coral = new(0xFF7F50);
public static readonly Color CornflowerBlue = new(0x6495ED);
public static readonly Color Cornsilk = new(0xFFF8DC);
public static readonly Color Crimson = new(0xDC143C);
public static readonly Color Cyan = new(0x00FFFF);
public static readonly Color DarkBlue = new(0x00008B);
public static readonly Color DarkCyan = new(0x008B8B);
public static readonly Color White = new(0xFFFFFF);
public static readonly Color Green = new(0x00FF00);
public static readonly Color Red = new(0xFF0000);
public static readonly Color DarkGoldenRod = new(0xB8860B);
public static readonly Color DarkGray = new(0xA9A9A9);
public static readonly Color DarkGreen = new(0x006400);
public static readonly Color DarkKhaki = new(0xBDB76B);
public static readonly Color DarkMagenta = new(0x8B008B);
public static readonly Color DarkOliveGreen = new(0x556B2F);
public static readonly Color DarkOrange = new(0xFF8C00);
public static readonly Color DarkOrchid = new(0x9932CC);
public static readonly Color DarkRed = new(0x8B0000);
public static readonly Color DarkSalmon = new(0xE9967A);
public static readonly Color DarkSeaGreen = new(0x8FBC8F);
public static readonly Color DarkSlateBlue = new(0x483D8B);
public static readonly Color DarkSlateGray = new(0x2F4F4F);
public static readonly Color DarkTurquoise = new(0x00CED1);
public static readonly Color DarkViolet = new(0x9400D3);
public static readonly Color DeepPink = new(0xFF1493);
public static readonly Color DeepSkyBlue = new(0x00BFFF);
public static readonly Color DimGray = new(0x696969);
public static readonly Color DodgerBlue = new(0x1E90FF);
public static readonly Color FireBrick = new(0xB22222);
public static readonly Color FloralWhite = new(0xFFFAF0);
public static readonly Color ForestGreen = new(0x228B22);
public static readonly Color Gainsboro = new(0xDCDCDC);
public static readonly Color GhostWhite = new(0xF8F8FF);
public static readonly Color Gold = new(0xFFD700);
public static readonly Color GoldenRod = new(0xDAA520);
public static readonly Color Gray = new(0x808080);
public static readonly Color GreenYellow = new(0xADFF2F);
public static readonly Color HoneyDew = new(0xF0FFF0);
public static readonly Color HotPink = new(0xFF69B4);
public static readonly Color IndianRed = new(0xCD5C5C);
public static readonly Color Indigo = new(0x4B0082);
public static readonly Color Ivory = new(0xFFFFF0);
public static readonly Color Khaki = new(0xF0E68C);
public static readonly Color Lavender = new(0xE6E6FA);
public static readonly Color LavenderBlush = new(0xFFF0F5);
public static readonly Color LawnGreen = new(0x7CFC00);
public static readonly Color LemonChiffon = new(0xFFFACD);
public static readonly Color LightBlue = new(0xADD8E6);
public static readonly Color LightCoral = new(0xF08080);
public static readonly Color LightCyan = new(0xE0FFFF);
public static readonly Color LightGoldenRodYellow = new(0xFAFAD2);
public static readonly Color LightGray = new(0xD3D3D3);
public static readonly Color LightGreen = new(0x90EE90);
public static readonly Color LightPink = new(0xFFB6C1);
public static readonly Color LightSalmon = new(0xFFA07A);
public static readonly Color LightSeaGreen = new(0x20B2AA);
public static readonly Color LightSkyBlue = new(0x87CEFA);
public static readonly Color LightSlateGray = new(0x778899);
public static readonly Color LightSteelBlue = new(0xB0C4DE);
public static readonly Color LightYellow = new(0xFFFFE0);
public static readonly Color Lime = new(0x00FF00);
public static readonly Color LimeGreen = new(0x32CD32);
public static readonly Color Linen = new(0xFAF0E6);
public static readonly Color Magenta = new(0xFF00FF);
public static readonly Color Maroon = new(0x800000);
public static readonly Color MediumAquaMarine = new(0x66CDAA);
public static readonly Color MediumBlue = new(0x0000CD);
public static readonly Color MediumOrchid = new(0xBA55D3);
public static readonly Color MediumPurple = new(0x9370DB);
public static readonly Color MediumSeaGreen = new(0x3CB371);
public static readonly Color MediumSlateBlue = new(0x7B68EE);
public static readonly Color MediumSpringGreen = new(0x00FA9A);
public static readonly Color MediumTurquoise = new(0x48D1CC);
public static readonly Color MediumVioletRed = new(0xC71585);
public static readonly Color MidnightBlue = new(0x191970);
public static readonly Color MintCream = new(0xF5FFFA);
public static readonly Color MistyRose = new(0xFFE4E1);
public static readonly Color Moccasin = new(0xFFE4B5);
public static readonly Color NavajoWhite = new(0xFFDEAD);
public static readonly Color Navy = new(0x000080);
public static readonly Color OldLace = new(0xFDF5E6);
public static readonly Color Olive = new(0x808000);
public static readonly Color OliveDrab = new(0x6B8E23);
public static readonly Color Orange = new(0xFFA500);
public static readonly Color OrangeRed = new(0xFF4500);
public static readonly Color Orchid = new(0xDA70D6);
public static readonly Color PaleGoldenRod = new(0xEEE8AA);
public static readonly Color PaleGreen = new(0x98FB98);
public static readonly Color PaleTurquoise = new(0xAFEEEE);
public static readonly Color PaleVioletRed = new(0xDB7093);
public static readonly Color PapayaWhip = new(0xFFEFD5);
public static readonly Color PeachPuff = new(0xFFDAB9);
public static readonly Color Peru = new(0xCD853F);
public static readonly Color Pink = new(0xFFC0CB);
public static readonly Color Plum = new(0xDDA0DD);
public static readonly Color PowderBlue = new(0xB0E0E6);
public static readonly Color Purple = new(0x800080);
public static readonly Color RebeccaPurple = new(0x663399);
public static readonly Color RosyBrown = new(0xBC8F8F);
public static readonly Color RoyalBlue = new(0x4169E1);
public static readonly Color SaddleBrown = new(0x8B4513);
public static readonly Color Salmon = new(0xFA8072);
public static readonly Color SandyBrown = new(0xF4A460);
public static readonly Color SeaGreen = new(0x2E8B57);
public static readonly Color Seashell = new(0xFFF5EE);
public static readonly Color Sienna = new(0xA0522D);
public static readonly Color Silver = new(0xC0C0C0);
public static readonly Color SkyBlue = new(0x87CEEB);
public static readonly Color SlateBlue = new(0x6A5ACD);
public static readonly Color SlateGray = new(0x708090);
public static readonly Color Snow = new(0xFFFAFA);
public static readonly Color SpringGreen = new(0x00FF7F);
public static readonly Color SteelBlue = new(0x4682B4);
public static readonly Color Tan = new(0xD2B48C);
public static readonly Color Teal = new(0x008080);
public static readonly Color Thistle = new(0xD8BFD8);
public static readonly Color Tomato = new(0xFF6347);
public static readonly Color Turquoise = new(0x40E0D0);
public static readonly Color Violet = new(0xEE82EE);
public static readonly Color Wheat = new(0xF5DEB3);
public static readonly Color WhiteSmoke = new(0xF5F5F5);
public static readonly Color Yellow = new(0xFFFF00);
public static readonly Color YellowGreen = new(0x9ACD32);
/// <summary>
/// Red component of this <see cref="Color"/>.
/// </summary>
public byte R { get; set; }
/// <summary>
/// Green component of this <see cref="Color"/>.
/// </summary>
public byte G { get; set; }
/// <summary>
/// Blue component of this <see cref="Color"/>.
/// </summary>
public byte B { get; set; }
/// <summary>
/// Alpha component of this <see cref="Color"/>.
/// </summary>/// <summary>
/// Gets the color as a 32-bit ARGB integer in the format 0xAARRGGBB.
/// </summary>
public byte A { get; set; } = 255;
/// <summary>
/// Gets the color as a 32-bit ARGB integer in the format 0xAARRGGBB.
/// </summary>
public int Argb
{
get
{
int a = A << 24;
int r = R << 16;
int g = G << 8;
int b = B;
return a | r | g | b;
}
}
/// <summary>
/// Initializes a new instance of the <see cref="Color"/> struct using float RGB(A) values between 0 and 1.
/// </summary>
/// <param name="r">The red component (0.0 to 1.0).</param>
/// <param name="g">The green component (0.0 to 1.0).</param>
/// <param name="b">The blue component (0.0 to 1.0).</param>
/// <param name="a">The alpha component (0.0 to 1.0), default is 1.0 (fully opaque).</param>
public Color(float r, float g, float b, float a = 1.0f)
{
R = (byte)Math.Clamp(r * 255, 0, 255);
G = (byte)Math.Clamp(g * 255, 0, 255);
B = (byte)Math.Clamp(b * 255, 0, 255);
A = (byte)Math.Clamp(a * 255, 0, 255);
}
/// <summary>
/// Initializes a new instance of the <see cref="Color"/> struct using byte RGB(A) values.
/// </summary>
/// <param name="r">The red component (0 to 255).</param>
/// <param name="g">The green component (0 to 255).</param>
/// <param name="b">The blue component (0 to 255).</param>
/// <param name="a">The alpha component (0 to 255), default is 255 (fully opaque).</param>
public Color(byte r, byte g, byte b, byte a = 255)
{
R = r;
G = g;
B = b;
A = a;
}
/// <summary>
/// Initializes a new instance of the <see cref="Color"/> struct using a hexadecimal value.
/// </summary>
/// <param name="hex">
/// A 24-bit (RRGGBB) or 32-bit (AARRGGBB) integer representing the color.
/// Alpha is assumed to be 255 if not included.
/// </param>
public Color(int hex)
{
A = 255; // Default alpha to 255 if not provided
B = (byte)(hex & 0xFF);
G = (byte)((hex >> 8) & 0xFF);
R = (byte)((hex >> 16) & 0xFF);
if (hex > 0xFFFFFF) // If the hex value includes alpha
{
A = (byte)((hex >> 24) & 0xFF);
}
}
/// <summary>
/// Parses a color from a hexadecimal string in the format "#RRGGBB" or "#AARRGGBB".
/// </summary>
/// <param name="hex">The hex string representing the color.</param>
/// <returns>A <see cref="Color"/> instance parsed from the string.</returns>
/// <exception cref="ArgumentException">Thrown if the format is invalid.</exception>
public static Color FromHexString(string hex)
{
if (hex.StartsWith("#"))
{
hex = hex[1..];
}
if (hex.Length == 6)
{
int rgb = int.Parse(hex, System.Globalization.NumberStyles.HexNumber);
return new Color(rgb);
}
else if (hex.Length == 8)
{
int rgba = int.Parse(hex, System.Globalization.NumberStyles.HexNumber);
return new Color(rgba);
}
else
{
throw new ArgumentException("Invalid hex color format. Use #RRGGBB or #RRGGBBAA.");
}
}
/// <summary>
/// Returns a lightened version of the color by interpolating toward white.
/// </summary>
/// <param name="amount">A value from 0.0 (no change) to 1.0 (fully white).</param>
/// <returns>A lighter <see cref="Color"/>.</returns>
public Color Lightened(float amount)
{
var result = this;
result.R = (byte)Math.Min(255, R + (255 - R) * amount);
result.G = (byte)Math.Min(255, G + (255 - G) * amount);
result.B = (byte)Math.Min(255, B + (255 - B) * amount);
return result;
}
/// <summary>
/// Returns a darkened version of the color by interpolating toward black.
/// </summary>
/// <param name="amount">A value from 0.0 (no change) to 1.0 (fully black).</param>
/// <returns>A darker <see cref="Color"/>.</returns>
public Color Darkened(float amount)
{
var result = this;
result.R = (byte)(R * (1.0f - amount));
result.G = (byte)(G * (1.0f - amount));
result.B = (byte)(B * (1.0f - amount));
return result;
}
/// <summary>
/// Converts this color to a <see cref="System.Drawing.Color"/>.
/// </summary>
/// <returns>A <see cref="System.Drawing.Color"/> with equivalent ARGB values.</returns>
public System.Drawing.Color ToSystemColor()
{
var result = System.Drawing.Color.FromArgb(Argb);
return result;
}
}
}

View File

@@ -8,5 +8,31 @@ namespace Voile.Extensions
{ {
return new Vector2((float)MathUtils.Lerp(a.X, b.X, t), (float)MathUtils.Lerp(a.Y, b.Y, t)); return new Vector2((float)MathUtils.Lerp(a.X, b.X, t), (float)MathUtils.Lerp(a.Y, b.Y, t));
} }
public static Vector2 Snapped(this Vector2 a, Vector2 snap)
{
var x = a.X % snap.X;
var y = a.Y % snap.Y;
if (x == 0)
{
x = a.X;
}
else
{
x = a.X - x;
}
if (y == 0)
{
y = a.Y;
}
else
{
y = a.Y - y;
}
return new Vector2(x, y);
}
} }
} }

View File

@@ -1,8 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Voile.Audio;
using Voile.Input; using Voile.Input;
using Voile.Rendering; using Voile.Rendering;
using Voile.Resources; using Voile.Resources;
using Voile.VFS;
namespace Voile namespace Voile
{ {
@@ -41,6 +43,8 @@ namespace Voile
/// </summary> /// </summary>
protected RenderSystem? Renderer { get; set; } protected RenderSystem? Renderer { get; set; }
protected AudioSystem? AudioSystem { get; set; }
/// <summary> /// <summary>
/// Name of this game. Also used as a default window title. /// Name of this game. Also used as a default window title.
/// </summary> /// </summary>
@@ -88,6 +92,7 @@ namespace Voile
throw new NullReferenceException("No ResourceManager provided."); throw new NullReferenceException("No ResourceManager provided.");
} }
Mount();
LoadResources(); LoadResources();
Ready(); Ready();
Run(); Run();
@@ -159,6 +164,11 @@ namespace Voile
throw new NullReferenceException("No renderer provided."); throw new NullReferenceException("No renderer provided.");
} }
if (Input is null)
{
throw new NullReferenceException("No input system provided.");
}
Stopwatch stopwatch = Stopwatch.StartNew(); Stopwatch stopwatch = Stopwatch.StartNew();
double previousTime = stopwatch.Elapsed.TotalSeconds; double previousTime = stopwatch.Elapsed.TotalSeconds;
@@ -170,6 +180,8 @@ namespace Voile
_accumulator += elapsedTime; _accumulator += elapsedTime;
Input.Poll();
while (_accumulator >= UpdateTimeStep) while (_accumulator >= UpdateTimeStep)
{ {
foreach (var system in _updatableSystems) foreach (var system in _updatableSystems)
@@ -239,6 +251,12 @@ namespace Voile
Renderer?.Start(renderSettings); Renderer?.Start(renderSettings);
} }
private void Mount()
{
var resourceRootMount = new FileSystemMountPoint(ResourceRoot);
VirtualFileSystem.Mount(resourceRootMount);
}
private List<IStartableSystem> _startableSystems = new(); private List<IStartableSystem> _startableSystems = new();
private List<IUpdatableSystem> _updatableSystems = new(); private List<IUpdatableSystem> _updatableSystems = new();
private List<IUpdatableSystem> _renderableSystems = new(); private List<IUpdatableSystem> _renderableSystems = new();

View File

@@ -1,13 +1,14 @@
namespace Voile.Input namespace Voile.Input
{ {
public abstract class InputAction
public interface IInputAction
{ {
public abstract bool IsDown(InputSystem inputHandler); bool IsDown(InputSystem inputSystem);
public abstract bool IsPressed(InputSystem inputHandler); bool IsPressed(InputSystem inputSystem);
public abstract bool IsReleased(InputSystem inputHandler); bool IsReleased(InputSystem inputSystem);
} }
public class KeyInputAction : InputAction public struct KeyInputAction : IInputAction
{ {
public KeyboardKey Key => _keyboardKey; public KeyboardKey Key => _keyboardKey;
@@ -15,22 +16,46 @@ namespace Voile.Input
{ {
_keyboardKey = keyboardKey; _keyboardKey = keyboardKey;
} }
public override bool IsDown(InputSystem inputHandler) public bool IsDown(InputSystem inputSystem)
{ {
return inputHandler.IsKeyboardKeyDown(_keyboardKey); return inputSystem.IsKeyboardKeyDown(_keyboardKey);
} }
public override bool IsPressed(InputSystem inputHandler) public bool IsPressed(InputSystem inputSystem)
{ {
return inputHandler.KeyboardKeyJustPressed(_keyboardKey); return inputSystem.KeyboardKeyJustPressed(_keyboardKey);
} }
public override bool IsReleased(InputSystem inputHandler) public bool IsReleased(InputSystem inputSystem)
{ {
return inputHandler.KeyboardKeyJustReleased(_keyboardKey); return inputSystem.KeyboardKeyJustReleased(_keyboardKey);
} }
private KeyboardKey _keyboardKey; private KeyboardKey _keyboardKey;
} }
public struct MouseInputAction : IInputAction
{
public MouseButton MouseButton { get; private set; }
public MouseInputAction(MouseButton button)
{
MouseButton = button;
}
public bool IsPressed(InputSystem inputSystem)
{
return inputSystem.IsMouseButtonPressed(MouseButton);
}
public bool IsDown(InputSystem inputSystem)
{
return inputSystem.IsMouseButtonDown(MouseButton);
}
public bool IsReleased(InputSystem inputSystem)
{
return inputSystem.IsMouseButtonReleased(MouseButton);
}
}
} }

View File

@@ -12,32 +12,107 @@ namespace Voile.Input
/// <summary> /// <summary>
/// The list of all available input mappings, custom and built-in. /// The list of all available input mappings, custom and built-in.
/// </summary> /// </summary>
public static IReadOnlyDictionary<string, List<InputAction>> InputMappings => inputMappings; public static IReadOnlyDictionary<string, List<IInputAction>> InputMappings => inputMappings;
public void Start() public void Start()
{ {
inputMappings = new Dictionary<string, List<InputAction>>(); inputMappings = new Dictionary<string, List<IInputAction>>();
CreateDefaultMappings(); CreateDefaultMappings();
} }
/// <summary>
/// Forces this input system to poll all of its inputs during current frame.<br />
/// Some backends require inputs to be polled once per specific interval. Override this method to implement this behavior.
/// </summary>
public virtual void Poll() { }
public void Shutdown() => Dispose(); public void Shutdown() => Dispose();
public void Dispose() => GC.SuppressFinalize(this); public void Dispose() => GC.SuppressFinalize(this);
public bool Handled { get => _handled; set => _handled = value; } public bool Handled { get => _handled; set => _handled = value; }
public bool IsActionDown(string action)
{
List<IInputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (IInputAction inputAction in mappings)
if (inputAction.IsDown(this)) return true;
}
return false;
}
public bool IsActionPressed(string action)
{
List<IInputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (IInputAction inputAction in mappings)
if (inputAction.IsPressed(this)) return true;
}
return false;
}
public bool IsActionReleased(string action)
{
List<IInputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (IInputAction inputAction in mappings)
if (inputAction.IsReleased(this)) return true;
}
return false;
}
public Vector2 GetInputDirection(KeyboardKey leftKey, KeyboardKey rightKey, KeyboardKey upKey, KeyboardKey downKey)
{
Vector2 dir = Vector2.Zero;
if (IsKeyboardKeyDown(leftKey))
dir.X -= 1;
if (IsKeyboardKeyDown(rightKey))
dir.X += 1;
if (IsKeyboardKeyDown(upKey))
dir.Y -= 1;
if (IsKeyboardKeyDown(downKey))
dir.Y += 1;
return dir == Vector2.Zero ? Vector2.Zero : Vector2.Normalize(dir);
}
public Vector2 GetInputDirection(string leftAction, string rightAction, string upAction, string downAction)
{
Vector2 dir = Vector2.Zero;
if (IsActionDown(leftAction))
dir.X -= 1;
if (IsActionDown(rightAction))
dir.X += 1;
if (IsActionDown(upAction))
dir.Y -= 1;
if (IsActionDown(downAction))
dir.Y += 1;
return dir == Vector2.Zero ? Vector2.Zero : Vector2.Normalize(dir);
}
public abstract bool IsKeyboardKeyDown(KeyboardKey key); public abstract bool IsKeyboardKeyDown(KeyboardKey key);
public abstract bool KeyboardKeyJustPressed(KeyboardKey key); public abstract bool KeyboardKeyJustPressed(KeyboardKey key);
public abstract bool KeyboardKeyJustReleased(KeyboardKey key); public abstract bool KeyboardKeyJustReleased(KeyboardKey key);
public abstract Vector2 GetInputDirection(KeyboardKey leftKey, KeyboardKey rightKey, KeyboardKey upKey, KeyboardKey downKey);
public abstract Vector2 GetInputDirection(string leftAction, string rightAction, string upAction, string downAction);
public abstract int GetCharPressed(); public abstract int GetCharPressed();
public abstract bool IsActionDown(string action); public abstract bool IsMouseButtonPressed(MouseButton button);
public abstract bool IsActionPressed(string action);
public abstract bool IsActionReleased(string action);
public abstract bool IsMouseButtonDown(MouseButton button); public abstract bool IsMouseButtonDown(MouseButton button);
public abstract bool IsMouseButtonReleased(MouseButton button);
public abstract float GetMouseWheelMovement(); public abstract float GetMouseWheelMovement();
public abstract void SetMousePosition(Vector2 position); public abstract void SetMousePosition(Vector2 position);
public abstract Vector2 GetMousePosition(); public abstract Vector2 GetMousePosition();
public abstract void HideCursor(); public abstract void HideCursor();
@@ -46,48 +121,53 @@ namespace Voile.Input
public void SetAsHandled() => _handled = true; public void SetAsHandled() => _handled = true;
public void AddInputMapping(string actionName, IEnumerable<InputAction> inputActions) public void AddInputMapping(string actionName, IEnumerable<IInputAction> inputActions)
{ {
inputMappings.Add(actionName, inputActions.ToList()); inputMappings.Add(actionName, inputActions.ToList());
} }
private void CreateDefaultMappings() private void CreateDefaultMappings()
{ {
AddInputMapping("up", new InputAction[] { AddInputMapping("up", [
new KeyInputAction(KeyboardKey.W), new KeyInputAction(KeyboardKey.W),
new KeyInputAction(KeyboardKey.Up), new KeyInputAction(KeyboardKey.Up),
}); ]);
AddInputMapping("down", new InputAction[] { AddInputMapping("down", [
new KeyInputAction(KeyboardKey.S), new KeyInputAction(KeyboardKey.S),
new KeyInputAction(KeyboardKey.Down), new KeyInputAction(KeyboardKey.Down),
}); ]);
AddInputMapping("left", new InputAction[] { AddInputMapping("left", [
new KeyInputAction(KeyboardKey.A), new KeyInputAction(KeyboardKey.A),
new KeyInputAction(KeyboardKey.Left), new KeyInputAction(KeyboardKey.Left),
}); ]);
AddInputMapping("right", new InputAction[] { AddInputMapping("right", [
new KeyInputAction(KeyboardKey.D), new KeyInputAction(KeyboardKey.D),
new KeyInputAction(KeyboardKey.Right), new KeyInputAction(KeyboardKey.Right),
}); ]);
AddInputMapping("accept", [
new KeyInputAction(KeyboardKey.Enter),
]);
AddInputMapping("cancel", [
new KeyInputAction(KeyboardKey.Backspace),
]);
} }
protected bool TryGetInputMappings(string forAction, [NotNullWhen(true)] out IEnumerable<InputAction>? inputActions) protected bool TryGetInputMappings(string forAction, [NotNullWhen(true)] out List<IInputAction>? inputActions)
{ {
var contains = inputMappings.ContainsKey(forAction);
inputActions = null; inputActions = null;
if (!contains) if (inputMappings.TryGetValue(forAction, out var actions))
{ {
_logger.Error($"The action \"{forAction}\" is not present in the input mappings!"); inputActions = actions;
return false;
}
inputActions = inputMappings[forAction];
return true; return true;
} }
protected static Dictionary<string, List<InputAction>> inputMappings = new(); _logger.Error($"The action \"{forAction}\" is not present in the input mappings!");
return false;
}
protected static Dictionary<string, List<IInputAction>> inputMappings = new();
private bool _handled; private bool _handled;
private Logger _logger = new(nameof(InputSystem)); private Logger _logger = new(nameof(InputSystem));
} }

View File

@@ -8,125 +8,63 @@ namespace Voile.Input
/// </summary> /// </summary>
public class RaylibInputSystem : InputSystem public class RaylibInputSystem : InputSystem
{ {
public override int GetCharPressed() public override void Poll()
{ {
return Raylib.GetCharPressed(); _justPressedKeys.Clear();
_justReleasedKeys.Clear();
_downKeys.Clear();
_pressedMouseButtons.Clear();
_releasedMouseButtons.Clear();
_downMouseButtons.Clear();
for (int key = 32; key <= 349; key++)
{
var k = (KeyboardKey)key;
if (Raylib.IsKeyPressed((Raylib_cs.KeyboardKey)k)) _justPressedKeys.Add(k);
if (Raylib.IsKeyReleased((Raylib_cs.KeyboardKey)k)) _justReleasedKeys.Add(k);
if (Raylib.IsKeyDown((Raylib_cs.KeyboardKey)k)) _downKeys.Add(k);
} }
public override Vector2 GetInputDirection(KeyboardKey leftKey, KeyboardKey rightKey, KeyboardKey upKey, KeyboardKey downKey) foreach (MouseButton button in System.Enum.GetValues(typeof(MouseButton)))
{ {
Vector2 dir = Vector2.Zero; var rayButton = (Raylib_cs.MouseButton)button;
if (Raylib.IsMouseButtonPressed(rayButton)) _pressedMouseButtons.Add(button);
if (IsKeyboardKeyDown(leftKey)) if (Raylib.IsMouseButtonReleased(rayButton)) _releasedMouseButtons.Add(button);
dir += new Vector2(-1, 0); if (Raylib.IsMouseButtonDown(rayButton)) _downMouseButtons.Add(button);
if (IsKeyboardKeyDown(rightKey))
dir += new Vector2(1, 0);
if (IsKeyboardKeyDown(upKey))
dir += new Vector2(0, -1);
if (IsKeyboardKeyDown(downKey))
dir += new Vector2(0, 1);
return dir == Vector2.Zero ? Vector2.Zero : Vector2.Normalize(dir);
} }
public override Vector2 GetInputDirection(string leftAction, string rightAction, string upAction, string downAction) _mousePosition = Raylib.GetMousePosition();
{ _mouseWheelMove = Raylib.GetMouseWheelMove();
Vector2 dir = Vector2.Zero;
if (IsActionDown(leftAction))
dir += new Vector2(-1, 0);
if (IsActionDown(rightAction))
dir += new Vector2(1, 0);
if (IsActionDown(upAction))
dir += new Vector2(0, -1);
if (IsActionDown(downAction))
dir += new Vector2(0, 1);
return dir == Vector2.Zero ? Vector2.Zero : Vector2.Normalize(dir);
} }
public override Vector2 GetMousePosition() public override int GetCharPressed() => Raylib.GetCharPressed();
{ public override Vector2 GetMousePosition() => _mousePosition;
return Raylib.GetMousePosition(); public override float GetMouseWheelMovement() => _mouseWheelMove;
}
public override float GetMouseWheelMovement() public override void HideCursor() => Raylib.HideCursor();
{
return Raylib.GetMouseWheelMove();
}
public override void HideCursor()
{
Raylib.HideCursor();
}
public override bool IsActionDown(string action)
{
IEnumerable<InputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (InputAction inputAction in mappings)
if (inputAction.IsDown(this)) return true;
}
return false;
}
public override bool IsActionPressed(string action)
{
IEnumerable<InputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (InputAction inputAction in mappings)
if (inputAction.IsPressed(this)) return true;
}
return false;
}
public override bool IsActionReleased(string action)
{
IEnumerable<InputAction>? mappings;
if (TryGetInputMappings(action, out mappings))
{
foreach (InputAction inputAction in mappings)
if (inputAction.IsReleased(this)) return true;
}
return false;
}
public override bool IsKeyboardKeyDown(KeyboardKey key)
{
Raylib_cs.KeyboardKey rayKey = (Raylib_cs.KeyboardKey)key;
return Raylib.IsKeyDown(rayKey);
}
public override bool IsMouseButtonDown(MouseButton button)
{
return Raylib.IsMouseButtonDown((Raylib_cs.MouseButton)button);
}
public override bool KeyboardKeyJustPressed(KeyboardKey key)
{
Raylib_cs.KeyboardKey rayKey = (Raylib_cs.KeyboardKey)key;
return Raylib.IsKeyPressed(rayKey);
}
public override bool KeyboardKeyJustReleased(KeyboardKey key)
{
return Raylib.IsKeyReleased((Raylib_cs.KeyboardKey)key);
}
public override void SetMousePosition(Vector2 position)
{
Raylib.SetMousePosition((int)position.X, (int)position.Y);
}
public override void ShowCursor() => Raylib.ShowCursor(); public override void ShowCursor() => Raylib.ShowCursor();
public override bool IsCursorHidden() => Raylib.IsCursorHidden(); public override bool IsCursorHidden() => Raylib.IsCursorHidden();
public override bool IsKeyboardKeyDown(KeyboardKey key) => _downKeys.Contains(key);
public override bool KeyboardKeyJustPressed(KeyboardKey key) => _justPressedKeys.Contains(key);
public override bool KeyboardKeyJustReleased(KeyboardKey key) => _justReleasedKeys.Contains(key);
public override bool IsMouseButtonPressed(MouseButton button) => _pressedMouseButtons.Contains(button);
public override bool IsMouseButtonReleased(MouseButton button) => _releasedMouseButtons.Contains(button);
public override bool IsMouseButtonDown(MouseButton button) => _downMouseButtons.Contains(button);
public override void SetMousePosition(Vector2 position) => Raylib.SetMousePosition((int)position.X, (int)position.Y);
private readonly HashSet<KeyboardKey> _justPressedKeys = new();
private readonly HashSet<KeyboardKey> _justReleasedKeys = new();
private readonly HashSet<KeyboardKey> _downKeys = new();
private readonly HashSet<MouseButton> _pressedMouseButtons = new();
private readonly HashSet<MouseButton> _releasedMouseButtons = new();
private readonly HashSet<MouseButton> _downMouseButtons = new();
private Vector2 _mousePosition;
private float _mouseWheelMove;
} }
} }

81
Voile/Source/Rect.cs Normal file
View File

@@ -0,0 +1,81 @@
namespace Voile;
/// <summary>
/// Represents a rectangle. Used to determine widget confines for UI layout.
/// </summary>
public record Rect(float Width = 0.0f, float Height = 0.0f)
{
public float Width { get; set; } = Width;
public float Height { get; set; } = Height;
public static Rect Zero => new Rect(0.0f, 0.0f);
public float Area => Width * Height;
public int CompareTo(Rect? other)
{
if (other is null) return 1;
return Area.CompareTo(other.Area);
}
public static bool operator >(Rect left, Rect right) => left.CompareTo(right) > 0;
public static bool operator <(Rect left, Rect right) => left.CompareTo(right) < 0;
public static bool operator >=(Rect left, Rect right) => left.CompareTo(right) >= 0;
public static bool operator <=(Rect left, Rect right) => left.CompareTo(right) <= 0;
}
/// <summary>
/// Represents the size offsets applied around a rectangle.
/// </summary>
public struct Size : IEquatable<Size>
{
public float Left;
public float Right;
public float Top;
public float Bottom;
public Size(float uniform)
{
Left = Right = Top = Bottom = uniform;
}
public Size(float horizontal, float vertical)
{
Left = Right = horizontal;
Top = Bottom = vertical;
}
public Size(float left, float right, float top, float bottom)
{
Left = left;
Right = right;
Top = top;
Bottom = bottom;
}
public static Size Zero => new Size(0);
public static Rect operator +(Size margin, Rect rect) =>
new Rect(rect.Width + margin.Left + margin.Right,
rect.Height + margin.Top + margin.Bottom);
public static Rect operator +(Rect rect, Size margin) =>
margin + rect;
public static bool operator ==(Size a, Size b) =>
a.Equals(b);
public static bool operator !=(Size a, Size b) =>
!a.Equals(b);
public bool Equals(Size other) =>
Left == other.Left &&
Right == other.Right &&
Top == other.Top &&
Bottom == other.Bottom;
public override bool Equals(object? obj) =>
obj is Size other && Equals(other);
public override int GetHashCode() =>
HashCode.Combine(Left, Right, Top, Bottom);
}

View File

@@ -39,16 +39,17 @@ namespace Voile.Rendering
ConfigFlags flags = 0; ConfigFlags flags = 0;
// MSAA // MSAA
flags |= settings.Msaa == Msaa.Msaa4x ? ConfigFlags.FLAG_MSAA_4X_HINT : 0; flags |= settings.Msaa == Msaa.Msaa4x ? ConfigFlags.Msaa4xHint : 0;
// VSync // VSync
flags |= settings.UseVSync ? ConfigFlags.FLAG_VSYNC_HINT : 0; flags |= settings.UseVSync ? ConfigFlags.VSyncHint : 0;
_fullscreen = settings.Fullscreen; _fullscreen = settings.Fullscreen;
_defaultFlags = flags; _defaultFlags = flags;
Raylib.SetConfigFlags(flags); Raylib.SetConfigFlags(flags);
Raylib.SetTargetFPS(settings.TargetFps);
} }
public override void CreateAndInitializeWithWindow(RendererSettings settings) public override void CreateAndInitializeWithWindow(RendererSettings settings)
@@ -59,13 +60,13 @@ namespace Voile.Rendering
public override void CreateWindow(WindowSettings windowSettings) public override void CreateWindow(WindowSettings windowSettings)
{ {
Raylib.SetTraceLogLevel(TraceLogLevel.LOG_NONE); Raylib.SetTraceLogLevel(TraceLogLevel.None);
_defaultWindowSettings = windowSettings; _defaultWindowSettings = windowSettings;
_windowSize = windowSettings.Size; _windowSize = windowSettings.Size;
ConfigFlags windowFlags = 0; ConfigFlags windowFlags = 0;
windowFlags |= windowSettings.Resizable ? ConfigFlags.FLAG_WINDOW_RESIZABLE : 0; windowFlags |= windowSettings.Resizable ? ConfigFlags.ResizableWindow : 0;
_windowTitle = windowSettings.Title; _windowTitle = windowSettings.Title;
@@ -80,7 +81,7 @@ namespace Voile.Rendering
Raylib.InitWindow((int)_windowSize.X, (int)_windowSize.Y, windowSettings.Title); Raylib.InitWindow((int)_windowSize.X, (int)_windowSize.Y, windowSettings.Title);
} }
// Raylib.SetWindowState(windowFlags); Raylib.SetWindowState(windowFlags);
} }
// TODO // TODO
@@ -180,13 +181,24 @@ namespace Voile.Rendering
{ {
Raylib.DrawRectanglePro(new Rectangle() Raylib.DrawRectanglePro(new Rectangle()
{ {
x = transformPosition.X, X = transformPosition.X,
y = transformPosition.Y, Y = transformPosition.Y,
width = size.X, Width = size.X,
height = size.Y Height = size.Y
}, transformPivot, transformRotation, VoileColorToRaylibColor(color)); }, transformPivot, transformRotation, VoileColorToRaylibColor(color));
} }
public override void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1)
{
Raylib.DrawRectangleLinesEx(new Rectangle()
{
X = transformPosition.X,
Y = transformPosition.Y,
Width = size.X,
Height = size.Y
}, outlineWidth, VoileColorToRaylibColor(color));
}
public override void DrawDebugText(string text, int fontSize, Color color) public override void DrawDebugText(string text, int fontSize, Color color)
{ {
Raylib.DrawText(text, (int)transformPosition.X, (int)transformPosition.Y, fontSize, VoileColorToRaylibColor(color)); Raylib.DrawText(text, (int)transformPosition.X, (int)transformPosition.Y, fontSize, VoileColorToRaylibColor(color));
@@ -232,6 +244,12 @@ namespace Voile.Rendering
LoadFont(font); LoadFont(font);
} }
if (font.Dirty && font.Handle != -1)
{
UnloadFont(font);
LoadFont(font);
}
var rayFont = _fontPool[font.Handle]; var rayFont = _fontPool[font.Handle];
Raylib.DrawTextPro(rayFont, text, transformPosition, transformPivot, transformRotation, font.Size, 0.0f, VoileColorToRaylibColor(color)); Raylib.DrawTextPro(rayFont, text, transformPosition, transformPivot, transformRotation, font.Size, 0.0f, VoileColorToRaylibColor(color));
@@ -252,34 +270,31 @@ namespace Voile.Rendering
return Raylib.GetCurrentMonitor(); return Raylib.GetCurrentMonitor();
} }
private unsafe void LoadFont(Font font) private void LoadFont(Font font)
{ {
Raylib_cs.Font fontRay; Raylib_cs.Font fontRay;
ReadOnlySpan<char> ext = ".ttf"; // TODO: don't use a hardcoded extension. string ext = ".ttf";
Span<byte> extBytes = new byte[Encoding.Default.GetByteCount(ext) + 1]; int fontChars = font.Codepoints.Count;
Encoding.Default.GetBytes(ext, extBytes);
int fontChars = 2048; // TODO: control this dynamically to not load the entire font.
unsafe fontRay = Raylib.LoadFontFromMemory(ext, font.Buffer, font.Size, font.Codepoints.ToArray(), fontChars);
{
fixed (byte* extP = extBytes)
{
fixed (byte* bufferP = font.Buffer)
{
fontRay = Raylib.LoadFontFromMemory((sbyte*)extP, bufferP, (int)font.BufferSize, font.Size, null, fontChars);
}
}
}
Raylib.GenTextureMipmaps(ref fontRay.texture); Raylib.GenTextureMipmaps(ref fontRay.Texture);
Raylib.SetTextureFilter(fontRay.texture, TextureFilter.TEXTURE_FILTER_BILINEAR); Raylib.SetTextureFilter(fontRay.Texture, TextureFilter.Bilinear);
_fontPool.Add(fontRay); _fontPool.Add(fontRay);
font.Handle = _fontPool.Count - 1; font.Handle = _fontPool.Count - 1;
} }
private void UnloadFont(Font font)
{
var fontRay = _fontPool[font.Handle];
Raylib.UnloadFont(fontRay);
_fontPool.RemoveAt(font.Handle);
}
private void LoadTexture(Texture2d texture) private void LoadTexture(Texture2d texture)
{ {
Image image = new(); Image image = new();
@@ -288,14 +303,15 @@ namespace Voile.Rendering
{ {
fixed (void* dataPtr = texture.Buffer) fixed (void* dataPtr = texture.Buffer)
{ {
image.data = dataPtr; image.Data = dataPtr;
} }
} }
image.width = texture.Width;
image.height = texture.Height; image.Width = texture.Width;
image.mipmaps = texture.Mipmaps; image.Height = texture.Height;
image.format = (PixelFormat)texture.Format; image.Mipmaps = texture.Mipmaps;
image.Format = (PixelFormat)texture.Format;
Texture2D rayTexture; Texture2D rayTexture;

View File

@@ -180,6 +180,9 @@ namespace Voile.Rendering
/// <param name="size">Rectangle size.</param> /// <param name="size">Rectangle size.</param>
/// <param name="color">Fill color.</param> /// <param name="color">Fill color.</param>
public abstract void DrawRectangle(Vector2 size, Color color); public abstract void DrawRectangle(Vector2 size, Color color);
public abstract void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1.0f);
/// <summary> /// <summary>
/// Draws a debug text with a default font. /// Draws a debug text with a default font.
/// </summary> /// </summary>
@@ -253,7 +256,7 @@ namespace Voile.Rendering
{ {
public string Title; public string Title;
public Vector2 Size = new Vector2(1280, 720); public Vector2 Size = new Vector2(1280, 720);
public bool Resizable { get; set; } public bool Resizable { get; set; } = true;
public WindowSettings(string title, Vector2 size) public WindowSettings(string title, Vector2 size)
{ {

View File

@@ -399,6 +399,11 @@ namespace Voile.Rendering
throw new NotImplementedException(); throw new NotImplementedException();
} }
public override void DrawRectangleOutline(Vector2 size, Color color, float outlineWidth = 1)
{
throw new NotImplementedException();
}
private Vector2 _windowSize = Vector2.Zero; private Vector2 _windowSize = Vector2.Zero;
private IWindow? _window; private IWindow? _window;

View File

@@ -65,6 +65,9 @@ namespace Voile.Resources.DataReaders
/// <param name="defaultValue">Default value in case this getter fails to get data.</param> /// <param name="defaultValue">Default value in case this getter fails to get data.</param>
/// <returns></returns> /// <returns></returns>
double GetDouble(string key, double defaultValue = 0.0); double GetDouble(string key, double defaultValue = 0.0);
string GetString(string key, string defaultValue);
/// <summary> /// <summary>
/// Get a Voile.Color from this data getter. /// Get a Voile.Color from this data getter.
/// </summary> /// </summary>
@@ -79,5 +82,15 @@ namespace Voile.Resources.DataReaders
/// <param name="defaultValue">Default value in case this getter fails to get data.</param> /// <param name="defaultValue">Default value in case this getter fails to get data.</param>
/// <returns></returns> /// <returns></returns>
Vector2 GetVector2(string key, Vector2 defaultValue); Vector2 GetVector2(string key, Vector2 defaultValue);
/// <summary>
/// Get a <see cref="Size"/> from this data getter.
/// </summary>
/// <param name="key">Key of the value.</param>
/// <param name="defaultValue">Default value in case this getter fails to get data.</param>
/// <returns></returns>
Size GetSize(string key, Size defaultValue);
T[] GetArray<T>(string key, T[] defaultValue);
} }
} }

View File

@@ -7,172 +7,131 @@ namespace Voile.Resources.DataReaders;
/// <summary> /// <summary>
/// Reads key/value data from a TOML file. /// Reads key/value data from a TOML file.
/// </summary> /// </summary>
public class TomlDataReader : IStreamDataReader, IDataValidator, IStreamKeyValueGetter, IDisposable public class TomlDataReader : IStreamDataReader, IDataValidator, IStreamKeyValueGetter
{ {
public string ExpectedHeader { get; private set; } = string.Empty; public TomlDataReader() { }
public TomlDataReader(string expectedHeader)
{
ExpectedHeader = expectedHeader;
}
public void Read(Stream data) public void Read(Stream data)
{ {
if (data is not FileStream fs) using var reader = new StreamReader(data);
{
throw new ArgumentException("Toml data reader only supports file streams.");
}
_fs = fs;
using (var reader = new StreamReader(_fs))
{
_table = TOML.Parse(reader); _table = TOML.Parse(reader);
_valid = _table.HasKey(ExpectedHeader); _valid = _table != null;
} }
public bool Valid() => _valid;
public bool HasKey(string key)
{
return _table != null &&
_table.HasKey(key);
}
public IEnumerable<string> GetSubKeys()
{
if (_table == null)
return Enumerable.Empty<string>();
return _table.Keys
.Where(k => _table[k].IsTable)
.ToList();
}
public IEnumerable<string> GetSubKeysRecursive(string prefix = "")
{
if (_table == null)
yield break;
foreach (var key in _table.Keys)
{
var fullKey = string.IsNullOrEmpty(prefix) ? key : $"{prefix}.{key}";
if (_table[key].IsTable)
{
var subReader = GetSubReader(key);
if (subReader != null)
{
foreach (var subKey in subReader.GetSubKeysRecursive(fullKey))
yield return subKey;
yield return fullKey;
}
}
}
}
public IEnumerable<string> GetSubKeys(string subPath)
{
var subReader = GetSubReader(subPath);
if (subReader?._table == null)
return Enumerable.Empty<string>();
return subReader._table.Keys
.Where(k => subReader._table[k].IsTable)
.ToList();
}
public TomlDataReader? GetSubReader(string path)
{
var current = _table;
foreach (var part in path.Split('.'))
{
if (current == null || !current.HasKey(part) || !current[part].IsTable)
return null;
current = current[part].AsTable;
}
return new TomlDataReader { _table = current, _valid = true };
} }
public bool GetBool(string key, bool defaultValue = false) public bool GetBool(string key, bool defaultValue = false)
{ => TryGetNode(key, out var node) && node.IsBoolean ? node.AsBoolean : defaultValue;
if (_table is null)
{
return defaultValue;
}
var dataTable = _table[ExpectedHeader];
if (!dataTable.HasKey(key))
{
return defaultValue;
}
var tableValue = dataTable[key];
if (!tableValue.IsBoolean)
{
return defaultValue;
}
return tableValue.AsBoolean;
}
public int GetInt(string key, int defaultValue = 0) public int GetInt(string key, int defaultValue = 0)
{ => TryGetNode(key, out var node) && node.IsInteger ? node.AsInteger : defaultValue;
if (_table is null)
{
return defaultValue;
}
var dataTable = _table[ExpectedHeader];
if (!dataTable.HasKey(key))
{
return defaultValue;
}
var tableValue = dataTable[key];
if (!tableValue.IsInteger)
{
return defaultValue;
}
return tableValue.AsInteger;
}
public long GetLong(string key, long defaultValue = 0) public long GetLong(string key, long defaultValue = 0)
{ => TryGetNode(key, out var node) && node.IsInteger ? node.AsInteger.Value : defaultValue;
if (_table is null)
{
return defaultValue;
}
var dataTable = _table[ExpectedHeader];
if (!dataTable.HasKey(key))
{
return defaultValue;
}
var tableValue = dataTable[key];
if (!tableValue.IsInteger)
{
return defaultValue;
}
return tableValue.AsInteger.Value;
}
public float GetFloat(string key, float defaultValue = 0) public float GetFloat(string key, float defaultValue = 0)
{ {
if (_table is null) if (!TryGetNode(key, out var node))
{
return defaultValue; return defaultValue;
}
var dataTable = _table[ExpectedHeader]; if (node.IsFloat) return node.AsFloat;
if (node.IsInteger) return node.AsInteger;
if (!dataTable.HasKey(key))
{
return defaultValue; return defaultValue;
} }
var tableValue = dataTable[key];
if (!tableValue.IsFloat)
{
if (tableValue.IsInteger) return (float)tableValue.AsInteger.Value;
return defaultValue;
}
return tableValue.AsFloat;
}
public double GetDouble(string key, double defaultValue = 0) public double GetDouble(string key, double defaultValue = 0)
=> TryGetNode(key, out var node) && node.IsFloat ? node.AsFloat : defaultValue;
public string GetString(string key, string defaultValue)
{ {
if (_table is null) if (!TryGetNode(key, out var node))
{ {
return defaultValue; return defaultValue;
} }
var dataTable = _table[ExpectedHeader]; if (node.IsString)
if (!dataTable.HasKey(key))
{ {
return defaultValue; return node.AsString;
} }
if (!dataTable.IsFloat)
{
return defaultValue; return defaultValue;
} }
return dataTable.AsFloat.Value;
}
public Color GetColor(string key, Color defaultValue) public Color GetColor(string key, Color defaultValue)
{ {
if (_table is null) if (!TryGetNode(key, out var node))
{
return defaultValue; return defaultValue;
}
var dataTable = _table[ExpectedHeader]; if (node.IsInteger)
if (!dataTable.HasKey(key))
{ {
return defaultValue; return new Color(node.AsInteger);
} }
else if (node.IsArray)
var colorNode = dataTable[key];
if (colorNode.IsInteger)
{ {
return new Color(colorNode.AsInteger); var colorArray = node.AsArray;
}
else if (colorNode.IsArray)
{
var colorArray = colorNode.AsArray;
var rNode = colorArray[0]; var rNode = colorArray[0];
var gNode = colorArray[1]; var gNode = colorArray[1];
@@ -192,51 +151,102 @@ public class TomlDataReader : IStreamDataReader, IDataValidator, IStreamKeyValue
return new Color((byte)r, (byte)g, (byte)b, (byte)a); return new Color((byte)r, (byte)g, (byte)b, (byte)a);
} }
else if (colorNode.IsString) else if (node.IsString)
{ {
var colorHexString = colorNode.AsString.Value; var colorHexString = node.AsString.Value;
return Color.FromHexString(colorHexString); return Color.FromHexString(colorHexString);
} }
else else
{ {
throw new ArgumentException("Color can only be represented as an array of integers in the range of 0-255, array of floats (0-1), hexadecimal, or hex string."); return defaultValue;
} }
} }
public Vector2 GetVector2(string key, Vector2 defaultValue) public Vector2 GetVector2(string key, Vector2 defaultValue)
{ {
if (_table is null) if (!TryGetNode(key, out var node) || !node.IsArray || node.AsArray.RawArray.Count != 2)
return defaultValue;
var arr = node.AsArray;
return new Vector2(arr[0], arr[1]);
}
public Size GetSize(string key, Size defaultValue)
{ {
if (!TryGetNode(key, out var node))
return defaultValue;
if (node.IsInteger)
{
return new Size(node.AsInteger);
}
else if (node.IsFloat)
{
return new Size(node.AsFloat);
}
else if (node.IsArray)
{
var sizeArray = node.AsArray;
var lNode = sizeArray[0];
var rNode = sizeArray[1];
var tNode = sizeArray[2];
var bNode = sizeArray[3];
var l = lNode.IsInteger ? lNode.AsInteger : 0;
var t = tNode.IsInteger ? tNode.AsInteger : 0;
var r = rNode.IsInteger ? rNode.AsInteger : 0;
var b = bNode.IsInteger ? bNode.AsInteger : 0;
return new Size(l, r, t, b);
}
return defaultValue; return defaultValue;
} }
var dataTable = _table[ExpectedHeader]; public T[] GetArray<T>(string key, T[] defaultValue)
if (!dataTable.HasKey(key))
{ {
return defaultValue; throw new NotImplementedException("Generic array reading not implemented yet.");
} }
var vector2Node = dataTable[key]; private bool TryGetNode(string key, out TomlNode node)
if (!vector2Node.IsArray)
{ {
return defaultValue; node = null!;
if (_table == null)
return false;
var current = _table;
var parts = key.Split('.');
for (int i = 0; i < parts.Length; i++)
{
if (!current.HasKey(parts[i]))
return false;
var child = current[parts[i]];
if (i == parts.Length - 1)
{
node = child;
return true;
} }
var vector2Array = vector2Node.AsArray; if (!child.IsTable)
return false;
return new Vector2(vector2Array[0], vector2Array[1]); current = child.AsTable;
} }
public bool Valid() => _valid; return false;
public void Dispose()
{
_fs?.Dispose();
} }
private TomlTable? _table; private TomlTable? _table;
private FileStream? _fs;
private bool _valid; private bool _valid;
// Internal use for subreaders
private TomlDataReader(TomlTable table)
{
_table = table;
_valid = true;
}
} }

View File

@@ -1,9 +1,26 @@
using System.Numerics;
using FreeTypeSharp;
using static FreeTypeSharp.FT;
using static FreeTypeSharp.FT_LOAD;
namespace Voile; namespace Voile;
public struct Glyph
{
public int TextureId { get; set; } = -1;
public float Width { get; set; }
public float Height { get; set; }
public Vector2 Bearing { get; set; }
public int Advance { get; set; }
public Glyph() { }
}
/// <summary> /// <summary>
/// Represents font data. /// Represents font data.
/// </summary> /// </summary>
public class Font : Resource public class Font : Resource, IUpdatableResource, IDisposable
{ {
/// <summary> /// <summary>
/// Internal handle for the font. If it got successfully loaded into the GPU, the value will be other than -1. /// Internal handle for the font. If it got successfully loaded into the GPU, the value will be other than -1.
@@ -14,8 +31,162 @@ public class Font : Resource
public byte[]? Buffer { get; private set; } public byte[]? Buffer { get; private set; }
public long BufferSize { get; set; } public long BufferSize { get; set; }
public bool Dirty => _dirty;
internal float UnitsPerEm;
internal nint FacePtr;
internal nint LibraryPtr;
internal List<int> Codepoints => _glyphs.Keys.ToList();
public Font(string path, byte[] buffer) : base(path) public Font(string path, byte[] buffer) : base(path)
{ {
Buffer = buffer; Buffer = buffer;
} }
public void Dispose()
{
unsafe
{
if (FacePtr != IntPtr.Zero)
FT_Done_Face((FT_FaceRec_*)FacePtr);
if (LibraryPtr != IntPtr.Zero)
FT_Done_FreeType((FT_LibraryRec_*)LibraryPtr);
}
}
/// <summary>
/// Loads a basic ASCII charset for this font.
/// </summary>
public void LoadAsciiData()
{
for (char c = ' '; c < 127; c++)
{
GetGlyph(c);
}
}
/// <summary>
/// Measures a given string using the font metrics.
/// </summary>
/// <param name="text">Text to measure.</param>
/// <returns>A <see cref="Rect"/> with the sizes of a given text using this font.</returns>
public Rect Measure(string text)
{
if (string.IsNullOrEmpty(text))
return Rect.Zero;
float totalWidth = 0;
float maxAscent = 0;
float maxDescent = 0;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
Glyph glyph = GetGlyph(c);
totalWidth += glyph.Advance;
float ascent = glyph.Bearing.Y;
float descent = glyph.Height - glyph.Bearing.Y;
if (ascent > maxAscent)
maxAscent = ascent;
if (descent > maxDescent)
maxDescent = descent;
if (i > 0)
{
char prevChar = text[i - 1];
totalWidth += GetKerning(prevChar, c);
}
}
float totalHeight = Size;
return new Rect(totalWidth, totalHeight);
}
public int GetKerning(char left, char right)
{
unsafe
{
if (FacePtr == IntPtr.Zero)
return 0;
FT_FaceRec_* face = (FT_FaceRec_*)FacePtr;
uint leftIndex = FT_Get_Char_Index(face, left);
uint rightIndex = FT_Get_Char_Index(face, right);
if (leftIndex == 0 || rightIndex == 0)
return 0;
FT_Vector_ kerning;
if (FT_Get_Kerning(face, leftIndex, rightIndex, FT_Kerning_Mode_.FT_KERNING_DEFAULT, &kerning) != 0)
return 0;
return (int)kerning.x;
}
}
public Glyph GetGlyph(char character)
{
if (!HasGlyph(character))
{
_glyphs.TryGetValue('?', out var defGlyph);
return defGlyph;
}
if (_glyphs.TryGetValue(character, out var glyph))
return glyph;
var loaded = LoadGlyph(character);
_glyphs[character] = loaded;
_dirty = true;
return loaded;
}
public bool HasGlyph(char character)
{
unsafe
{
var face = (FT_FaceRec_*)FacePtr;
return FT_Get_Char_Index(face, character) != 0;
}
}
private unsafe Glyph LoadGlyph(char character)
{
var face = (FT_FaceRec_*)FacePtr;
// TODO: for now, we're loading glyphs for metrics, but when implementing WebGPU rendering, we want to somehow render them to SDF or bitmap.
FT_Error error = FT_Set_Pixel_Sizes(face, 0, (uint)Size);
error = FT_Load_Char(face, character, FT_LOAD_NO_BITMAP);
if (error != 0)
throw new Exception($"Failed to load glyph for '{character}'");
var glyph = face->glyph;
var bitmap = glyph->bitmap;
var metrics = glyph->metrics;
return new Glyph
{
Width = metrics.width >> 6,
Height = metrics.height >> 6,
Bearing = new Vector2(metrics.horiBearingX >> 6, metrics.horiBearingY >> 6),
Advance = (int)metrics.horiAdvance >> 6,
};
}
public void MarkUpdated()
{
_dirty = false;
}
private bool _dirty;
private Dictionary<int, Glyph> _glyphs = new();
} }

View File

@@ -0,0 +1,45 @@
using System.Diagnostics.CodeAnalysis;
namespace Voile.Resources;
/// <summary>
/// Contains a set of multiple fonts. Used to fetch fonts based on availability of glyphs.
/// </summary>
public class FontSet
{
public FontSet() { }
public FontSet(IEnumerable<ResourceRef<Font>> fonts)
{
_fonts = fonts.ToList();
}
public void AddFont(ResourceRef<Font> font) => _fonts.Add(font);
/// <summary>
/// Tries to get a suitable font that has a given character.
/// </summary>
/// <param name="c">Character to get a suitable font for.</param>
/// <param name="result">Font that contains this character.</param>
/// <returns><c>true</c> if a font that contains this character exists, <c>false</c> otherwise.</returns>
public bool TryGetFontFor(char c, [NotNullWhen(true)] out ResourceRef<Font>? result)
{
result = ResourceRef<Font>.Empty();
foreach (var fontRef in _fonts)
{
if (!fontRef.TryGetValue(out var font))
{
return false;
}
if (font.HasGlyph(c))
{
result = fontRef;
return true;
}
}
return false;
}
private List<ResourceRef<Font>> _fonts = new();
}

View File

@@ -1,4 +1,11 @@
using System.Numerics;
using FreeTypeSharp;
using Voile.VFS;
using static FreeTypeSharp.FT;
using static FreeTypeSharp.FT_LOAD;
namespace Voile.Resources; namespace Voile.Resources;
public class FontLoader : ResourceLoader<Font> public class FontLoader : ResourceLoader<Font>
@@ -8,12 +15,40 @@ public class FontLoader : ResourceLoader<Font>
".ttf" ".ttf"
}; };
protected override Font LoadResource(string path) protected override Font LoadResource(string path)
{ {
byte[] fileBuffer = File.ReadAllBytes(path); using Stream stream = VirtualFileSystem.Read(path);
byte[] fileBuffer = new byte[stream.Length];
int bytesRead = stream.Read(fileBuffer, 0, fileBuffer.Length);
var result = new Font(path, fileBuffer); var result = new Font(path, fileBuffer);
result.BufferSize = fileBuffer.Length;
result.BufferSize = bytesRead;
LoadFaceData(result);
result.LoadAsciiData();
return result; return result;
} }
private unsafe void LoadFaceData(Font font)
{
FT_LibraryRec_* lib;
FT_Error error = FT_Init_FreeType(&lib);
if (error != 0)
throw new Exception("Failed to init FreeType");
font.LibraryPtr = (nint)lib;
fixed (byte* data = font.Buffer)
{
FT_FaceRec_* face;
error = FT_New_Memory_Face(lib, data, (nint)font.BufferSize, 0, &face);
if (error != 0)
throw new Exception("Failed to load font face");
font.UnitsPerEm = face->units_per_EM;
font.FacePtr = (nint)face;
}
}
} }

View File

@@ -24,7 +24,6 @@ namespace Voile.Resources
var resource = LoadResource(path); var resource = LoadResource(path);
var guid = Guid.NewGuid(); var guid = Guid.NewGuid();
var loadedResources = ResourceManager.LoadedResources; var loadedResources = ResourceManager.LoadedResources;
var oldResourceGuid = loadedResources.FirstOrDefault(loadedResource => loadedResource.Value.Path == path).Key; var oldResourceGuid = loadedResources.FirstOrDefault(loadedResource => loadedResource.Value.Path == path).Key;
@@ -46,6 +45,7 @@ namespace Voile.Resources
{ {
foreach (var loadedResource in ResourceManager.LoadedResources) foreach (var loadedResource in ResourceManager.LoadedResources)
{ {
if (loadedResource.Value is not T) continue;
Load(loadedResource.Value.Path); Load(loadedResource.Value.Path);
} }
} }
@@ -65,7 +65,12 @@ namespace Voile.Resources
var resource = ResourceManager.LoadedResources[resourceGuid]; var resource = ResourceManager.LoadedResources[resourceGuid];
ResourceManager.RemoveResource(resourceGuid); ResourceManager.RemoveResource(resourceGuid);
resource.Dispose();
if (resource is IDisposable disposable)
{
disposable.Dispose();
}
return true; return true;
} }

View File

@@ -1,4 +1,5 @@
using StbVorbisSharp; using StbVorbisSharp;
using Voile.VFS;
namespace Voile.Resources namespace Voile.Resources
{ {
@@ -6,7 +7,7 @@ namespace Voile.Resources
{ {
public override IEnumerable<string> SupportedExtensions => new string[] public override IEnumerable<string> SupportedExtensions => new string[]
{ {
"ogg" ".ogg"
}; };
protected override Sound LoadResource(string path) protected override Sound LoadResource(string path)
@@ -14,9 +15,11 @@ namespace Voile.Resources
Vorbis vorbis; Vorbis vorbis;
Sound result; Sound result;
var fileBuffer = File.ReadAllBytes(path); using var stream = VirtualFileSystem.Read(path);
vorbis = Vorbis.FromMemory(fileBuffer); byte[] fileBuffer = new byte[stream.Length];
int bytesRead = stream.Read(fileBuffer, 0, fileBuffer.Length);
vorbis = Vorbis.FromMemory(fileBuffer);
vorbis.SubmitBuffer(); vorbis.SubmitBuffer();
if (vorbis.Decoded == 0) if (vorbis.Decoded == 0)
@@ -27,18 +30,9 @@ namespace Voile.Resources
var audioShort = vorbis.SongBuffer; var audioShort = vorbis.SongBuffer;
int length = vorbis.Decoded * vorbis.Channels; int length = vorbis.Decoded * vorbis.Channels;
byte[] audioData = new byte[length * 2];
for (int i = 0; i < length; i++) short[] audioData = new short[length];
{ Array.Copy(audioShort, audioData, length);
if (i * 2 >= audioData.Length) break;
var b1 = (byte)(audioShort[i] >> 8);
var b2 = (byte)(audioShort[i] & 256);
audioData[i * 2] = b2;
audioData[i * 2 + 1] = b1;
}
result = new Sound(path, audioData) result = new Sound(path, audioData)
{ {

View File

@@ -1,5 +1,6 @@
using Voile.Resources; using Voile.Resources;
using StbImageSharp; using StbImageSharp;
using Voile.VFS;
namespace Voile namespace Voile
{ {
@@ -18,7 +19,7 @@ namespace Voile
protected override Texture2d LoadResource(string path) protected override Texture2d LoadResource(string path)
{ {
ImageResult image; ImageResult image;
using (var stream = File.OpenRead(path)) using (var stream = VirtualFileSystem.Read(path))
{ {
image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha); image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
} }

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Voile.Resources; using Voile.Resources;
namespace Voile namespace Voile
@@ -13,21 +14,72 @@ namespace Voile
/// </summary> /// </summary>
public readonly Guid Guid = Guid.Empty; public readonly Guid Guid = Guid.Empty;
public bool HasValue => Guid != Guid.Empty; public bool HasValue => Guid != Guid.Empty;
/// <summary> /// <summary>
/// Retrieve a reference. /// Retrieves a <see cref="Resource"/>.<br />
/// This will throw an <see cref="InvalidOperationException"/> if the resource wasn't loaded or is invalid. <br />
/// You can check if resource was loaded with <see cref="HasValue"/>, or consider using <see cref="TryGetValue"/>.
/// </summary> /// </summary>
public T Value => ResourceManager.GetResource<T>(Guid); public T Value => ResourceManager.GetResource<T>(Guid)
?? throw new InvalidOperationException($"Resource with GUID {Guid} is not loaded or invalid.");
/// <summary>
/// Retrieves a resource or <c>null</c> if the resource wasn't loaded or is invalid.
/// </summary>
public T? ValueOrNull => ResourceManager.GetResource<T>(Guid);
/// <summary>
/// Tries to retrieve a <see cref="Resource"/>.
/// </summary>
/// <param name="value">An instance of a retrieved <see cref="Resource"/>.</param>
/// <returns><c>true</c> if the resource was successfully retrieved, otherwise <c>false</c>.</returns>
public bool TryGetValue([NotNullWhen(true)] out T? value)
{
value = ResourceManager.GetResource<T>(Guid);
return value != null;
}
/// <summary>
/// Create an empty <see cref="ResourceRef"/>.
/// </summary>
/// <returns></returns>
public static ResourceRef<T> Empty()
{
return new ResourceRef<T>(Guid.Empty);
}
public ResourceRef(Guid guid) public ResourceRef(Guid guid)
{ {
Guid = guid; Guid = guid;
} }
public override bool Equals(object? obj)
{
return obj is ResourceRef<T> other && Guid.Equals(other.Guid);
}
public override int GetHashCode()
{
return Guid.GetHashCode();
}
public static bool operator ==(ResourceRef<T>? left, ResourceRef<T>? right)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
return left.Guid == right.Guid;
}
public static bool operator !=(ResourceRef<T>? left, ResourceRef<T>? right)
{
return !(left == right);
}
} }
/// <summary> /// <summary>
/// Represents data usable by Voile. /// Represents data usable by Voile.
/// </summary> /// </summary>
public abstract class Resource : IDisposable public abstract class Resource
{ {
/// <summary> /// <summary>
/// Path to this resource. /// Path to this resource.
@@ -38,9 +90,21 @@ namespace Voile
{ {
Path = path; Path = path;
} }
public void Dispose()
{
} }
/// <summary>
/// Represents a Resource that requires systems to react to its changes.
/// </summary>
public interface IUpdatableResource
{
/// <summary>
/// Gets a value indicating whether this resource's state has changed and needs to be reloaded.
/// </summary>
bool Dirty { get; }
/// <summary>
/// Marks this resource as updated.
/// </summary>
void MarkUpdated();
} }
} }

View File

@@ -1,8 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using Voile.UI;
using Voile.Utils; using Voile.Utils;
using Voile.VFS;
namespace Voile.Resources namespace Voile.Resources
{ {
@@ -92,10 +92,7 @@ namespace Voile.Resources
T? resource = default; T? resource = default;
result = null; result = null;
var fullPath = Path.Combine(ResourceRoot, path); if (!VirtualFileSystem.FileExists(path))
// TODO: don't check if file doesn't exist in the file system, make it more generic but for now it's fine
if (!File.Exists(fullPath))
{ {
_logger.Error($"File at \"{path}\" doesn't exist!"); _logger.Error($"File at \"{path}\" doesn't exist!");
return false; return false;
@@ -108,7 +105,7 @@ namespace Voile.Resources
return false; return false;
} }
var extension = Path.GetExtension(fullPath); var extension = Path.GetExtension(path);
var hasExtension = loader.SupportedExtensions.Any(ext => ext == extension); var hasExtension = loader.SupportedExtensions.Any(ext => ext == extension);
if (!hasExtension) if (!hasExtension)
@@ -116,7 +113,7 @@ namespace Voile.Resources
_logger.Error($"Extension {extension} is not supported!"); _logger.Error($"Extension {extension} is not supported!");
} }
var resourceGuid = loader.Load(fullPath); var resourceGuid = loader.Load(path);
if (!GetResource(resourceGuid, out T? loadedResource)) if (!GetResource(resourceGuid, out T? loadedResource))
{ {
@@ -210,7 +207,7 @@ namespace Voile.Resources
/// <param name="resourceGuid">Resource's GUID.</param> /// <param name="resourceGuid">Resource's GUID.</param>
/// <param name="resource">Retrieved resource. Otherwise null if nothing got retrieved.</param> /// <param name="resource">Retrieved resource. Otherwise null if nothing got retrieved.</param>
/// <returns>True if resource got successfully retrieved, otherwise false.</returns> /// <returns>True if resource got successfully retrieved, otherwise false.</returns>
public static T GetResource<T>(Guid resourceGuid) where T : Resource public static T? GetResource<T>(Guid resourceGuid) where T : Resource
{ {
if (!TryGetLoader(out ResourceLoader<T>? loader)) if (!TryGetLoader(out ResourceLoader<T>? loader))
{ {
@@ -219,6 +216,11 @@ namespace Voile.Resources
if (!GetResource(resourceGuid, out T? loadedResource)) if (!GetResource(resourceGuid, out T? loadedResource))
{ {
if (resourceGuid == Guid.Empty)
{
_logger.Warn("Trying to load a resource with an empty GUID, ignoring.");
return null;
}
throw new Exception($"No resource with GUID \"{resourceGuid}\" found!"); throw new Exception($"No resource with GUID \"{resourceGuid}\" found!");
} }
@@ -229,7 +231,7 @@ namespace Voile.Resources
/// </summary> /// </summary>
/// <param name="path">Path to the resource.</param> /// <param name="path">Path to the resource.</param>
/// <returns>True if a resource at the specified path is loaded, otherwise false.</returns> /// <returns>True if a resource at the specified path is loaded, otherwise false.</returns>
public bool IsResourceLoaded(string path) => _resourcePathMap.ContainsKey(path); public static bool IsResourceLoaded(string path) => _resourcePathMap.ContainsKey(path);
/// <summary> /// <summary>
/// Adds a resource loader associated with a resource type. /// Adds a resource loader associated with a resource type.
@@ -267,7 +269,6 @@ namespace Voile.Resources
| NotifyFilters.CreationTime | NotifyFilters.CreationTime
| NotifyFilters.DirectoryName | NotifyFilters.DirectoryName
| NotifyFilters.FileName | NotifyFilters.FileName
| NotifyFilters.LastAccess
| NotifyFilters.LastWrite | NotifyFilters.LastWrite
| NotifyFilters.Security | NotifyFilters.Security
| NotifyFilters.Size; | NotifyFilters.Size;
@@ -343,11 +344,12 @@ namespace Voile.Resources
private static Logger _logger = new(nameof(ResourceManager)); private static Logger _logger = new(nameof(ResourceManager));
// TODO: don't include types from optional systems. Create a way for third-party systems to register their own loader associations.
private static readonly Dictionary<Type, object> _resourceLoaderAssociations = new() private static readonly Dictionary<Type, object> _resourceLoaderAssociations = new()
{ {
{typeof(Sound), new SoundLoader()}, { typeof(Sound), new SoundLoader()},
{typeof(Texture2d), new Texture2dLoader()}, {typeof(Texture2d), new Texture2dLoader()},
{typeof(Font), new FontLoader()} {typeof(Font), new FontLoader()},
}; };
private static readonly Dictionary<Type, object> _resourceSaverAssociations = new() private static readonly Dictionary<Type, object> _resourceSaverAssociations = new()

View File

@@ -1,17 +1,17 @@
namespace Voile namespace Voile
{ {
/// <summary> /// <summary>
/// Represents raw audio samples. /// Represents raw audio samples in 16-bit PCM format.
/// </summary> /// </summary>
public class Sound : Resource public class Sound : Resource
{ {
public SoundChannel Channel { get; set; } public SoundChannel Channel { get; set; }
public int SampleRate { get; set; } public int SampleRate { get; set; }
public byte[]? Buffer { get; private set; } public short[]? Buffer { get; private set; }
public long BufferSize { get; set; } public long BufferSize { get; set; }
public Sound(string path, byte[] buffer) : base(path) public Sound(string path, short[] buffer) : base(path)
{ {
Buffer = buffer; Buffer = buffer;
} }

View File

@@ -1,3 +1,5 @@
using Voile.Rendering;
namespace Voile; namespace Voile;
public interface IStartableSystem public interface IStartableSystem
@@ -36,3 +38,11 @@ public interface IUpdatableSystem
/// <param name="deltaTime">Time step.</param> /// <param name="deltaTime">Time step.</param>
void Update(double deltaTime); void Update(double deltaTime);
} }
/// <summary>
/// A system that renders itself to the screen, ex. UI or particles.
/// </summary>
public interface IRenderableSystem
{
void Render(RenderSystem renderer);
}

View File

@@ -1,7 +1,7 @@
using System.Numerics; using System.Numerics;
using Voile.Resources; using Voile.Resources;
using Voile.Resources.DataReaders; using Voile.Resources.DataReaders;
using Voile.Utils; using Voile.VFS;
namespace Voile.Systems.Particles; namespace Voile.Systems.Particles;
@@ -62,9 +62,8 @@ public class ParticleEmitterSettingsResourceLoader : ResourceLoader<ParticleEmit
{ {
var settings = new ParticleEmitterSettings(); var settings = new ParticleEmitterSettings();
using (var reader = new TomlDataReader("ParticleEmitterSettings")) var reader = new TomlDataReader();
{ reader.Read(VirtualFileSystem.Read(path));
reader.Read(File.Open(path, FileMode.Open));
settings.Local = reader.GetBool("Local", true); settings.Local = reader.GetBool("Local", true);
settings.MaxParticles = reader.GetInt("MaxParticles"); settings.MaxParticles = reader.GetInt("MaxParticles");
@@ -83,7 +82,6 @@ public class ParticleEmitterSettingsResourceLoader : ResourceLoader<ParticleEmit
settings.ColorEnd = reader.GetColor("ColorEnd", Color.Black); settings.ColorEnd = reader.GetColor("ColorEnd", Color.Black);
settings.LinearVelocityDamping = reader.GetFloat("LinearVelocityDamping", 1.0f); settings.LinearVelocityDamping = reader.GetFloat("LinearVelocityDamping", 1.0f);
settings.AngularVelocityDamping = reader.GetFloat("AngularVelocityDamping", 1.0f); settings.AngularVelocityDamping = reader.GetFloat("AngularVelocityDamping", 1.0f);
}
return new ParticleEmitterSettingsResource(path, settings); return new ParticleEmitterSettingsResource(path, settings);
} }

94
Voile/Source/UI/Anchor.cs Normal file
View File

@@ -0,0 +1,94 @@
using System.Numerics;
namespace Voile.UI;
/// <summary>
/// Specifies predefined anchor points used to position UI elements relative to their parent container.
/// </summary>
public enum Anchor
{
/// <summary>
/// Anchors the element to the top-left corner of the parent.
/// </summary>
TopLeft,
/// <summary>
/// Anchors the element to the top-center of the parent.
/// </summary>
TopCenter,
/// <summary>
/// Anchors the element to the top-right corner of the parent.
/// </summary>
TopRight,
/// <summary>
/// Anchors the element to the center-left edge of the parent.
/// </summary>
CenterLeft,
/// <summary>
/// Anchors the element to the exact center of the parent.
/// </summary>
Center,
/// <summary>
/// Anchors the element to the center-right edge of the parent.
/// </summary>
CenterRight,
/// <summary>
/// Anchors the element to the bottom-left corner of the parent.
/// </summary>
BottomLeft,
/// <summary>
/// Anchors the element to the bottom-center of the parent.
/// </summary>
BottomCenter,
/// <summary>
/// Anchors the element to the bottom-right corner of the parent.
/// </summary>
BottomRight,
Fill
}
/// <summary>
/// Provides extension methods for calculating anchored positions of UI elements.
/// </summary>
public static class AnchorExtensions
{
/// <summary>
/// Calculates the offset position for an element based on the specified <see cref="Anchor"/>.
/// </summary>
/// <param name="anchor">The anchor mode to use.</param>
/// <param name="parentPosition">The absolute position of the parent container (top-left corner).</param>
/// <param name="parentRect">The bounding rectangle of the parent container.</param>
/// <param name="elementRect">The size of the element being anchored.</param>
/// <returns>
/// A <see cref="Vector2"/> representing the local offset position where the element should be placed inside the parent.
/// </returns>
/// <remarks>
/// The result is the relative offset from the parent's origin, not a global position.
/// </remarks>
public static Vector2 Calculate(this Anchor anchor, Vector2 parentPosition, Rect parentRect, Rect elementRect)
{
var size = new Vector2(elementRect.Width, elementRect.Height);
var parentSize = new Vector2(parentRect.Width, parentRect.Height);
return anchor switch
{
Anchor.TopLeft => Vector2.Zero,
Anchor.TopCenter => new Vector2((parentSize.X - size.X) / 2, 0),
Anchor.TopRight => new Vector2(parentSize.X - size.X, 0),
Anchor.CenterLeft => new Vector2(0, (parentSize.Y - size.Y) / 2),
Anchor.Center => (parentSize - size) / 2,
Anchor.CenterRight => new Vector2(parentSize.X - size.X, (parentSize.Y - size.Y) / 2),
Anchor.BottomLeft => new Vector2(0, parentSize.Y - size.Y),
Anchor.BottomCenter => new Vector2((parentSize.X - size.X) / 2, parentSize.Y - size.Y),
Anchor.BottomRight => parentSize - size,
_ => Vector2.Zero
};
}
}

View File

@@ -0,0 +1,160 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
/// A base class for all UI containers, used to position and rendering child <see cref="IElement">s.
/// </summary>
public abstract class Container : UIElement, IParentableElement
{
/// <inheritdoc />
public IReadOnlyList<UIElement> Children => _children;
/// <summary>
/// Specifies if this <see cref="Container"/>'s minimum size will be confined to contents.
/// </summary>
public bool ConfineToContents { get; set; } = false;
public override string? StyleElementName => nameof(Container);
public override Rect MinimumSize => _minimumSize;
public Container()
{
MarkDirty();
}
public Container(Rect minimumSize)
{
_minimumSize = minimumSize;
MarkDirty();
}
public Container(Rect minimumSize, List<UIElement> children)
{
_minimumSize = minimumSize;
_children = children;
MarkDirty();
}
protected override void OnUpdate()
{
foreach (var child in _children)
{
if (child is not IUpdatableElement updatable) continue;
updatable.Update();
if (child is IAnchorableElement anchorable)
{
anchorable.ApplyAnchor(GlobalPosition, Size);
}
}
Arrange();
if (ConfineToContents)
{
RecalculateSizes();
}
}
public override void MarkDirty()
{
base.MarkDirty();
foreach (var child in _children)
{
if (child is not IUpdatableElement updatable) continue;
updatable.MarkDirty();
}
}
/// <summary>
/// Called when this <see cref="Container"/> has to rearrange its children.
/// </summary>
public abstract void Arrange();
/// <summary>
/// Recalculates sizes for this <see cref="Container"/>.
/// This is done automatically if <see cref="ConfineToContents"/> is true.
/// </summary>
public void RecalculateSizes()
{
float minX = float.MaxValue;
float minY = float.MaxValue;
float maxX = float.MinValue;
float maxY = float.MinValue;
foreach (var child in Children)
{
var pos = child.GlobalPosition;
var size = child.Size;
minX = MathF.Min(minX, pos.X);
minY = MathF.Min(minY, pos.Y);
maxX = MathF.Max(maxX, pos.X + size.Width);
maxY = MathF.Max(maxY, pos.Y + size.Height);
}
float padding = 0f;
float occupiedWidth = (maxX - minX) + padding * 2;
float occupiedHeight = (maxY - minY) + padding * 2;
float finalWidth = MathF.Max(occupiedWidth, _minimumSize.Width);
float finalHeight = MathF.Max(occupiedHeight, _minimumSize.Height);
var finalSize = new Rect(finalWidth, finalHeight);
if (finalSize != Size)
{
Size = finalSize;
}
if (_minimumSize > Size)
{
Size = _minimumSize;
}
}
public void AddChild(UIElement child)
{
// child.StyleSheetOverride = StyleSheet;
_children.Add(child);
child.SetParent(this);
MarkDirty();
Update();
}
public void RemoveChild(UIElement child)
{
_children.Remove(child);
MarkDirty();
Update();
}
protected override void OnRender(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
if (!child.TryGetStyle(StyleSheet.Value, out var childStyle))
{
childStyle = new Style();
}
renderable.Render(renderer, childStyle);
}
}
private List<UIElement> _children = new();
private Rect _minimumSize = Rect.Zero;
}

View File

@@ -0,0 +1,63 @@
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
/// A special container that occupies the entire available size of the parent. <br />
/// Usually used as a root element for the UI system.
/// </summary>
public class FillContainer : Container
{
public FillContainer()
{
}
public FillContainer(Rect minimumSize) : base(minimumSize)
{
}
public override void Arrange()
{
}
protected override void OnRender(RenderSystem renderer, Style style)
{
base.OnRender(renderer, style);
Rect parentSize;
if (Parent != null)
{
parentSize = Parent.Size;
}
else
{
var windowSize = renderer.WindowSize;
var windowRect = new Rect(windowSize.X, windowSize.Y);
parentSize = windowRect;
}
if (_lastParentSize != parentSize)
{
Size = parentSize;
_lastParentSize = parentSize;
}
}
protected override void OnUpdate()
{
base.OnUpdate();
Size = _lastParentSize;
if (Children.Count != 0)
{
Children[0].Size = Size;
}
}
private Rect _lastParentSize = Rect.Zero;
}

View File

@@ -0,0 +1,185 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
public enum FlexDirection
{
Row,
Column
}
public enum JustifyContent
{
/// <summary>
/// Align children to the start of the line.
/// </summary>
Start,
/// <summary>
/// Align children to the center of the line.
/// </summary>
Center,
/// <summary>
/// Align children to the end of the line.
/// </summary>
End,
/// <summary>
/// Equal space between items (no space at edges if multiple).
/// </summary>
SpaceBetween
}
public enum AlignItems
{
/// <summary>
/// Align to the top or left (cross-axis)
/// </summary>
Start,
/// <summary>
/// Center along the cross-axis.
/// </summary>
Center,
/// <summary>
/// Align to the bottom or right.
/// </summary>
End
}
/// <summary>
/// A flexible layout container that arranges its child elements in rows or columns, similar to CSS flexbox. Supports wrapping, alignment, spacing, and space distribution.
/// </summary>
public class FlexContainer : Container
{
/// <summary>
/// Layout direction to use with this <see cref="FlexContainer"/>.
/// </summary>
public FlexDirection Direction { get; set; } = FlexDirection.Row;
/// <summary>
/// Controls main-axis alignment of child elements. By default, children will be positioned at the start of a line.
/// </summary>
public JustifyContent Justify { get; set; } = JustifyContent.Start;
/// <summary>
/// Controls cross-axis alignment (start, center, end).
/// </summary>
public AlignItems Align { get; set; } = AlignItems.Start;
/// <summary>
/// If true, children wrap onto multiple lines (rows or columns).
/// </summary>
public bool Wrap { get; set; } = false;
/// <summary>
/// Space between child elements.
/// </summary>
public float Gap { get; set; } = 16f;
/// <summary>
/// (Reserved) If true, distributes extra space among children (not implemented)
/// </summary>
public bool DistributeRemainingSpace { get; set; } = false;
public FlexContainer() : base() { }
public FlexContainer(Rect minimumSize, List<UIElement> children) : base(minimumSize, children) { }
public override void Arrange()
{
float containerMainSize = (Direction == FlexDirection.Row) ? Size.Width : Size.Height;
float mainPos = 0.0f;
float crossPos = 0.0f;
List<List<UIElement>> lines = new();
List<UIElement> currentLine = new();
float lineMainSum = 0f;
float maxCross = 0f;
foreach (var child in Children)
{
Vector2 childSize = GetChildSize(child);
float mainSize = GetMainSize(childSize);
float crossSize = GetCrossSize(childSize);
bool wrapLine = Wrap && currentLine.Count > 0 && (lineMainSum + mainSize + Gap > containerMainSize);
if (wrapLine)
{
lines.Add(currentLine);
currentLine = new();
lineMainSum = 0f;
maxCross += crossSize + Gap;
}
currentLine.Add(child);
lineMainSum += mainSize + (currentLine.Count > 1 ? Gap : 0);
}
if (currentLine.Count > 0)
{
lines.Add(currentLine);
}
float currentCross = crossPos;
foreach (var line in lines)
{
float lineMainLength = line.Sum(child => GetMainSize(GetChildSize(child))) + Gap * (line.Count - 1);
float justifyOffset = GetJustifyOffset(containerMainSize, lineMainLength, line.Count);
float currentMain = mainPos + justifyOffset;
float maxLineCross = line.Select(child => GetCrossSize(GetChildSize(child))).Max();
foreach (var child in line)
{
Vector2 childSize = GetChildSize(child);
float alignedCross = currentCross + GetAlignOffset(maxLineCross, GetCrossSize(childSize));
Vector2 childPos = (Direction == FlexDirection.Row)
? new Vector2(currentMain, alignedCross)
: new Vector2(alignedCross, currentMain);
child.LocalPosition = childPos;
currentMain += GetMainSize(childSize) + Gap;
}
currentCross += maxLineCross + Gap;
}
}
private Vector2 GetChildSize(IElement child)
{
if (child is IResizeableElement resizeable)
return new Vector2(resizeable.MinimumSize.Width, resizeable.MinimumSize.Height);
return new Vector2(child.Size.Width, child.Size.Height);
}
private float GetMainSize(Vector2 size)
{
return Direction == FlexDirection.Row ? size.X : size.Y;
}
private float GetCrossSize(Vector2 size)
{
return Direction == FlexDirection.Row ? size.Y : size.X;
}
private float GetJustifyOffset(float containerSize, float totalMain, int itemCount)
{
return Justify switch
{
JustifyContent.Center => (containerSize - totalMain) / 2f,
JustifyContent.End => containerSize - totalMain,
JustifyContent.SpaceBetween => (itemCount > 1) ? 0 : (containerSize - totalMain) / 2f,
_ => 0
};
}
private float GetAlignOffset(float maxCross, float childCross)
{
return Align switch
{
AlignItems.Center => (maxCross - childCross) / 2f,
AlignItems.End => maxCross - childCross,
_ => 0f
};
}
}

View File

@@ -0,0 +1,69 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
/// A grid container arranges its children in a grid layout with a given column count, and column and row spacing between child elements.
/// </summary>
public class GridContainer : Container
{
/// <summary>
/// Amount of columns this grid has.
/// </summary>
public int Columns { get; set; } = 2;
/// <summary>
/// Spacing between each grid column.
/// </summary>
public float ColumnSpacing { get; set; } = 16.0f;
/// <summary>
/// Spacing between each grid row.
/// </summary>
public float RowSpacing { get; set; } = 16.0f;
public GridContainer(Rect minimumSize, List<UIElement> children, int columns = 2, float colSpacing = 16.0f, float rowSpacing = 16.0f)
: base(minimumSize, children)
{
Columns = columns;
ColumnSpacing = colSpacing;
RowSpacing = rowSpacing;
}
public GridContainer(int columns = 2, float colSpacing = 16.0f, float rowSpacing = 16.0f)
{
Columns = columns;
ColumnSpacing = colSpacing;
RowSpacing = rowSpacing;
}
public override void Arrange()
{
float currentX = 0.0f;
float currentY = 0.0f;
int colIndex = 0;
foreach (var child in Children)
{
child.LocalPosition = new Vector2(currentX, currentY);
float childWidth = 0.0f;
float childHeight = 0.0f;
childWidth = child.Size.Width;
childHeight = child.Size.Height;
colIndex++;
if (colIndex >= Columns)
{
colIndex = 0;
currentX = 0.0f;
currentY += childHeight + RowSpacing;
}
else
{
currentX += childWidth + ColumnSpacing;
}
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
/// A horizontal container arranges its children horizontally, with a given horizontal spacing between child elements.
/// </summary>
public class HorizontalContainer : Container
{
/// <summary>
/// Horizontal spacing between child elements.
/// </summary>
public float Spacing { get; set; } = 16.0f;
public HorizontalContainer(Rect minimumSize, List<UIElement> children, float spacing = 16.0f) : base(minimumSize, children)
{
Spacing = spacing;
}
public HorizontalContainer(float spacing = 16.0f)
{
Spacing = spacing;
}
public override void Arrange()
{
float currentX = 0.0f;
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
var pos = new Vector2(currentX, 0.0f);
child.LocalPosition = pos;
currentX += child.Size.Width;
if (i < Children.Count - 1)
{
currentX += Spacing;
}
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Numerics;
namespace Voile.UI.Containers;
public class MarginContainer : Container
{
/// <summary>
/// The margin to apply around the contents of this container.
/// </summary>
public Size Margin { get; set; }
/// <summary>
/// Specifies if this <see cref="MarginContainer"/> will fill to parent size.
/// </summary>
public bool Fill { get; set; } = true;
public MarginContainer() : this(new Size()) { }
public MarginContainer(Size margin)
{
Margin = margin;
}
protected override void OnUpdate()
{
base.OnUpdate();
if (Parent == null) return;
if (Size != Parent.Size)
{
Size = Parent.Size;
}
}
public override void Arrange()
{
foreach (var child in Children)
{
var newPosition = new Vector2(Margin.Left, Margin.Top);
var newSize = new Rect(
Size.Width - Margin.Left - Margin.Right,
Size.Height - Margin.Top - Margin.Bottom
);
child.Size = newSize;
child.LocalPosition = newPosition;
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
/// <summary>
/// A vertical container arranges its children vertically, with a given vertical spacing between child elements.
/// </summary>
public class VerticalContainer : Container
{
/// <summary>
/// Vertical spacing between child elements.
/// </summary>
public float Spacing { get; set; } = 16.0f;
public VerticalContainer(Rect minimumSize, List<UIElement> children, float spacing = 16.0f) : base(minimumSize, children)
{
Spacing = spacing;
}
public VerticalContainer(float spacing = 16.0f)
{
Spacing = spacing;
}
public override void Arrange()
{
float currentY = 0.0f;
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
var pos = new Vector2(0.0f, currentY);
child.LocalPosition = pos;
currentY += child.Size.Height;
if (i < Children.Count - 1)
{
currentY += Spacing;
}
}
}
}

130
Voile/Source/UI/IElement.cs Normal file
View File

@@ -0,0 +1,130 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI;
/// <summary>
/// Represents a basic UI element with position and size information.
/// </summary>
public interface IElement
{
/// <summary>
/// This element's position in pixels relative to the viewport top-left edge.
/// </summary>
public Vector2 GlobalPosition { get; }
/// <summary>
/// The size of this element.
/// </summary>
public Rect Size { get; set; }
}
/// <summary>
/// Represents a UI element that can contain child elements.
/// </summary>
public interface IParentableElement
{
/// <summary>
/// This parentable element's children.
/// </summary>
public IReadOnlyList<UIElement> Children { get; }
/// <summary>
/// Add a child element to this element.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/>.</param>
public void AddChild(UIElement child);
/// <summary>
/// Remove a child element from this element.
/// </summary>
/// <param name="child">Child <see cref="UIElement"/> to remove.</param>
public void RemoveChild(UIElement child);
}
/// <summary>
/// Represents a UI element that can provide a minimum size constraint.<br />
/// Implement this interface if your UI element is expected to be resizeable.
/// </summary>
public interface IResizeableElement
{
/// <summary>
/// Get a minimum rectangle size for this element.
/// </summary>
public abstract Rect MinimumSize { get; }
}
/// <summary>
/// Represents a UI element that supports updates when its state changes.
/// </summary>
public interface IUpdatableElement
{
/// <summary>
/// Gets a value indicating whether the element's state has changed and needs to be updated.
/// </summary>
public bool Dirty { get; }
/// <summary>
/// Update this element.
/// </summary>
void Update(float dt = 0.0f);
/// <summary>
/// Marks this element as changed, requiring an update.
/// </summary>
void MarkDirty();
}
/// <summary>
/// Represents a UI element that can be rendered to the screen.
/// </summary>
public interface IRenderableElement
{
/// <summary>
/// Specifies if this element should be drawn.
/// </summary>
public bool Visible { get; set; }
/// <summary>
/// Render this element.
/// </summary>
/// <param name="renderer">Renderer to call draw operations on.</param>
/// <param name="style">A style to use to draw this element.</param>
public void Render(RenderSystem renderer, Style style);
/// <summary>
/// Draws this element's size bounds.
/// </summary>
/// <param name="renderer">Renderer to use.</param>
public void DrawSize(RenderSystem renderer);
}
/// <summary>
/// Represents a UI element that can receive and process user input.
/// </summary>
public interface IInputElement
{
/// <summary>
/// Specifies if this element should ignore inputs.
/// </summary>
public bool IgnoreInput { get; set; }
/// <summary>
/// Send an input action to this element.
/// </summary>
/// <param name="action">Input action to send.</param>
void Input(UIInputContext action);
}
/// <summary>
/// Represents a UI element that supports positional anchoring within a parent.
/// </summary>
public interface IAnchorableElement
{
/// <summary>
/// Gets or sets the anchor point relative to the parent container.
/// </summary>
public Anchor Anchor { get; set; }
/// <summary>
/// Gets or sets an additional offset to apply after anchoring, in pixels.
/// </summary>
public Vector2 AnchorOffset { get; set; }
/// <summary>
/// Applies the current anchor settings based on the parent's position and size.
/// </summary>
/// <param name="parentPosition">The parent's top-left global position.</param>
/// <param name="parentRect">The bounding rectangle of the parent container.</param>
public void ApplyAnchor(Vector2 parentPosition, Rect parentRect);
}

230
Voile/Source/UI/Style.cs Normal file
View File

@@ -0,0 +1,230 @@
using System.Diagnostics.CodeAnalysis;
using Voile.Resources;
using Voile.Resources.DataReaders;
using Voile.UI.Containers;
using Voile.VFS;
namespace Voile.UI;
/// <summary>
/// UI style settings.
/// </summary>
public class Style
{
public enum AnimationType
{
Linear,
EaseIn,
EaseOut,
EaseInOut
}
public float TransitionDuration = 0f;
public AnimationType TransitionType = AnimationType.Linear;
public Style() { }
public Size? Padding { get; set; }
public Color? BackgroundColor { get; set; }
public Size? BorderSize { get; set; }
public Color? BorderColor { get; set; }
public float CornerRadius { get; set; }
public Color? TextColor { get; set; }
/// <summary>
/// Merges this <see cref="Style"/> with a different one.<br />
/// Properties that are not set for this <see cref="Style"/> will be inherited from <paramref name="overrideStyle"/>.
/// </summary>
/// <param name="overrideStyle"></param>
/// <returns>A merged <see cref="Style"/>.</returns>
public Style Merge(Style overrideStyle)
{
return new Style
{
BackgroundColor = overrideStyle.BackgroundColor != default ? overrideStyle.BackgroundColor : BackgroundColor,
TextColor = overrideStyle.TextColor != default ? overrideStyle.TextColor : TextColor,
Padding = overrideStyle.Padding != default ? overrideStyle.Padding : Padding,
BorderSize = overrideStyle.BorderSize != default ? overrideStyle.BorderSize : BorderSize,
BorderColor = overrideStyle.BorderColor != default ? overrideStyle.BorderColor : BorderColor,
};
}
}
public class StyleSheetLoader : ResourceLoader<StyleSheet>
{
public override IEnumerable<string> SupportedExtensions => [".toml"];
protected override StyleSheet LoadResource(string path)
{
var result = new StyleSheet(path);
var allStyles = new Dictionary<string, Style>();
using var stream = VirtualFileSystem.Read(path);
_reader.Read(stream);
foreach (var styleKey in _reader.GetSubKeysRecursive())
{
var subReader = _reader.GetSubReader(styleKey);
if (subReader == null || !subReader.Valid())
continue;
var style = ParseStyle(subReader, styleKey);
if (style != null)
{
allStyles[styleKey] = style;
}
}
foreach (var kvp in allStyles)
{
var finalStyle = GetMergedStyle(kvp.Key, allStyles);
result.Add(kvp.Key, finalStyle);
}
return result;
}
private Style ParseStyle(TomlDataReader reader, string input)
{
var style = new Style();
string easingName = reader.GetString("TransitionType", "Linear");
if (!Enum.TryParse<Style.AnimationType>(easingName, true, out var easing))
easing = Style.AnimationType.Linear;
style.TransitionType = easing;
if (reader.HasKey("BackgroundColor"))
style.BackgroundColor = reader.GetColor("BackgroundColor", Color.Transparent);
if (reader.HasKey("TextColor"))
style.TextColor = reader.GetColor("TextColor", Color.Black);
if (reader.HasKey("Padding"))
style.Padding = reader.GetSize("Padding", Size.Zero);
if (reader.HasKey("BorderSize"))
style.BorderSize = reader.GetSize("BorderSize", Size.Zero);
if (reader.HasKey("BorderColor"))
style.BorderColor = reader.GetColor("BorderColor", Color.Transparent);
return style;
}
private Style GetMergedStyle(string fullKey, Dictionary<string, Style> allStyles)
{
var parts = fullKey.Split('.');
var merged = new Style();
for (int i = 1; i <= parts.Length; i++)
{
var subKey = string.Join('.', parts.Take(i));
if (allStyles.TryGetValue(subKey, out var parentStyle))
{
merged = merged.Merge(parentStyle);
}
}
return merged;
}
private readonly TomlDataReader _reader = new();
}
public class StyleSheet : Resource
{
public StyleSheet(string path) : base(path)
{
}
public StyleSheet(Dictionary<string, Style> styles) : base(string.Empty)
{
_styles = styles;
}
public void Add(string key, Style style) => _styles.Add(key, style);
public bool TryGet(string styleName, [NotNullWhen(true)] out Style? style)
{
return _styles.TryGetValue(styleName, out style);
}
public static StyleSheet Default => new(new Dictionary<string, Style>()
{
{"Label", new Style()
{
TextColor = Color.FromHexString("#161616"),
BackgroundColor = Color.DarkRed,
BorderSize = new Size(2.0f),
BorderColor = Color.Red
}},
{ "Button", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#0f62fe"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Normal", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#0f62fe"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Hovered", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#0353e9"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Pressed", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#002d9c"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Danger", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#da1e28"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Danger.Normal", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#da1e28"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Danger.Hovered", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#ba1b23"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Button.Danger.Pressed", new Style()
{
Padding = new Size(8.0f),
BackgroundColor = Color.FromHexString("#750e13"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Container", new Style()
{
BackgroundColor = Color.FromHexString("#ffffff"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Container.Layer01", new Style()
{
BackgroundColor = Color.FromHexString("#f4f4f4"),
TextColor = Color.FromHexString("#ffffff"),
}},
{"Container.Layer02", new Style()
{
BackgroundColor = Color.FromHexString("#e8e8e8"),
TextColor = Color.FromHexString("#ffffff"),
}},
});
private Dictionary<string, Style> _styles = new();
}

View File

@@ -0,0 +1,56 @@
namespace Voile.UI;
public class StyleAnimator
{
public bool IsComplete => _elapsed >= _duration;
public StyleAnimator(Style from, Style to, float duration)
{
_from = from;
_to = to;
_duration = duration;
_elapsed = 0f;
}
public static float Ease(float t, Style.AnimationType type)
{
return type switch
{
Style.AnimationType.Linear => t,
Style.AnimationType.EaseIn => t * t,
Style.AnimationType.EaseOut => t * (2 - t),
Style.AnimationType.EaseInOut => t < 0.5f
? 2 * t * t
: -1 + (4 - 2 * t) * t,
_ => t
};
}
public Style Update(float deltaTime)
{
_elapsed = MathF.Min(_elapsed + deltaTime, _duration);
float t = _duration == 0 ? 1 : _elapsed / _duration;
float easedT = Ease(t, _to.TransitionType);
return LerpStyle(_from, _to, easedT);
}
private static Style LerpStyle(Style from, Style to, float t)
{
var result = new Style()
{
BackgroundColor = MathUtils.LerpColor(from.BackgroundColor ?? Color.Transparent, to.BackgroundColor ?? Color.Transparent, t),
TextColor = MathUtils.LerpColor(from.TextColor ?? Color.Black, to.TextColor ?? Color.Black, t),
Padding = MathUtils.LerpSize(from.Padding ?? Size.Zero, to.Padding ?? Size.Zero, t),
BorderColor = MathUtils.LerpColor(from.BorderColor ?? Color.Transparent, to.BorderColor ?? Color.Transparent, t),
BorderSize = MathUtils.LerpSize(from.BorderSize ?? Size.Zero, to.BorderSize ?? Size.Zero, t),
TransitionType = to.TransitionType
};
return result;
}
private Style _from, _to;
private float _duration, _elapsed;
}

View File

@@ -0,0 +1,232 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Text;
using Voile.Rendering;
namespace Voile.UI;
/// <summary>
/// Base class for all UI elements.
/// </summary>
public abstract class UIElement : IElement, IRenderableElement, IResizeableElement, IUpdatableElement, IAnchorableElement
{
public bool Visible { get; set; } = true;
public bool IgnoreInput { get; set; } = false;
public Vector2 LocalPosition { get; set; } = Vector2.Zero;
public Vector2 GlobalPosition => _parent?.GlobalPosition + LocalPosition ?? LocalPosition;
public string StyleName => $"{StyleElementName ?? "UIElement"}{GetStyleVariantString()}{ConstructStyleModifiers(StyleModifiers)}";
/// <summary>
/// An element name for style.
/// </summary>
public virtual string? StyleElementName { get; }
public string StyleVariant { get; set; } = string.Empty;
/// <summary>
/// List of style modifiers for this <see cref="UIElement"/>.
/// </summary>
public virtual string[]? StyleModifiers { get; }
public ResourceRef<StyleSheet> StyleSheet => Parent?.StyleSheet ?? StyleSheetOverride;
public ResourceRef<StyleSheet> StyleSheetOverride { get; set; } = ResourceRef<StyleSheet>.Empty();
/// <summary>
/// Parent <see cref="UIElement"/> of this element.
/// </summary>
public UIElement? Parent => _parent;
public Rect Size
{
get => _size;
set
{
if (value.Width < MinimumSize.Width)
{
_size.Width = MinimumSize.Width;
}
if (value.Height < MinimumSize.Height)
{
_size.Height = MinimumSize.Height;
}
if (_size != value)
{
MarkDirty();
}
_size = value;
}
}
public Vector2 AnchorOffset { get; set; } = Vector2.Zero;
public Anchor Anchor { get; set; } = Anchor.TopLeft;
public abstract Rect MinimumSize { get; }
public bool Dirty => _dirty;
public bool TryGetStyle(StyleSheet styleSheet, [NotNullWhen(true)] out Style? style)
{
return styleSheet.TryGet(StyleName, out style);
}
public virtual void MarkDirty()
{
if (Parent != null && !Parent.Dirty)
{
Parent.MarkDirty();
}
_dirty = true;
}
/// <summary>
/// Sets a parent element for this <see cref="UIElement"/>.
/// </summary>
/// <param name="parent">Element to parent this <see cref="UIElement"/> to.</param>
public void SetParent(UIElement parent)
{
_parent = parent;
MarkDirty();
}
public void Update(float dt = 0.0f)
{
if (!_dirty) return;
_dirty = false;
if (Size == Rect.Zero)
Size = MinimumSize;
OnUpdate();
if (_parent is not null && _parent.Size != Rect.Zero)
{
ApplyAnchor(_parent.GlobalPosition, _parent.Size);
}
}
public void Render(RenderSystem renderer, Style style)
{
RenderStyleBox(renderer, style);
OnRender(renderer, style);
}
protected abstract void OnRender(RenderSystem renderer, Style style);
protected abstract void OnUpdate();
/// <summary>
/// Renders a stylebox from a given style.
/// </summary>
/// <param name="renderer"></param>
/// <param name="style"></param>
protected void RenderStyleBox(RenderSystem renderer, Style style)
{
var backgroundColor = style.BackgroundColor ?? Color.Transparent;
var borderColor = style.BorderColor ?? Color.Transparent;
var borderSize = style.BorderSize;
var borderLeft = borderSize?.Left ?? 0;
var borderRight = borderSize?.Right ?? 0;
var borderTop = borderSize?.Top ?? 0;
var borderBottom = borderSize?.Bottom ?? 0;
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(new Vector2(Size.Width, Size.Height), backgroundColor);
if (borderLeft > 0)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(
new Vector2(borderLeft, Size.Height),
borderColor
);
}
if (borderTop > 0)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(
new Vector2(Size.Width, borderTop),
borderColor
);
}
if (borderRight > 0)
{
var rightX = GlobalPosition.X + Size.Width - borderRight;
renderer.SetTransform(new Vector2(rightX, GlobalPosition.Y), Vector2.Zero);
renderer.DrawRectangle(
new Vector2(borderRight, Size.Height),
borderColor
);
}
if (borderBottom > 0)
{
var bottomY = GlobalPosition.Y + Size.Height - borderBottom;
renderer.SetTransform(new Vector2(GlobalPosition.X, bottomY), Vector2.Zero);
renderer.DrawRectangle(
new Vector2(Size.Width, borderBottom),
borderColor
);
}
}
public void DrawSize(RenderSystem renderer)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangleOutline(new Vector2(Size.Width, Size.Height), Color.Red, 2.0f);
}
/// <summary>
/// Determines if this <see cref="UIElement"/> contains a point within its confines.
/// </summary>
/// <param name="pointPosition">A global position of the point.</param>
/// <returns>True if the point is inside the widget; otherwise, false.</returns>
public bool ContainsPoint(Vector2 point)
{
return point.X >= GlobalPosition.X && point.Y >= GlobalPosition.Y &&
point.X <= GlobalPosition.X + Size.Width &&
point.Y <= GlobalPosition.Y + Size.Height;
}
/// <summary>
/// Applies this <see cref="UIElement"/> anchor.
/// </summary>
public virtual void ApplyAnchor(Vector2 parentPosition, Rect parentRect)
{
LocalPosition = Anchor.Calculate(parentPosition, parentRect, Size) + new Vector2(AnchorOffset.X, AnchorOffset.Y);
}
private string ConstructStyleModifiers(string[]? modifiers)
{
if (modifiers == null)
{
return string.Empty;
}
var sb = new StringBuilder();
foreach (var modifier in modifiers)
{
sb.Append($".{modifier}");
}
return sb.ToString();
}
private string GetStyleVariantString()
{
if (string.IsNullOrEmpty(StyleVariant))
return string.Empty;
return $".{StyleVariant}";
}
private bool _dirty = true;
private Rect _size = Rect.Zero;
private UIElement? _parent;
}

View File

@@ -0,0 +1,64 @@
using System.Numerics;
using Voile.Input;
namespace Voile.UI;
/// <summary>
/// Input information for UI elements.
/// </summary>
public class UIInputContext
{
/// <summary>
/// Current action handled by this <see cref="UIElement"/>.
/// </summary>
public IInputAction Action { get; }
/// <summary>
/// Current mouse position.
/// </summary>
public Vector2 MousePosition { get; }
/// <summary>
/// Determines if a mouse button was pressed.
/// </summary>
public bool MousePressed { get; set; }
/// <summary>
/// Determines if a mouse button was released.
/// </summary>
public bool MouseReleased { get; set; }
/// <summary>
/// Determines if a mouse button is currently held.
/// </summary>
public bool MouseDown { get; set; }
/// <summary>
/// Name of the current <see cref="IInputAction"/>.
/// </summary>
public string ActionName { get; }
/// <summary>
/// Keycode of a currently pressed character.
/// </summary>
public int CharPressed { get; }
/// <summary>
/// Determines if this <see cref="UIInputContext"/> registered any character input from keyboard.
/// </summary>
public bool HasCharInput => CharPressed != 0;
/// <summary>
/// Determines if this context's input was already handled and no longer needs to be processed.
/// </summary>
public bool Handled => _handled;
/// <summary>
/// Marks this context as handled, meaning next elements that will receive it should discard it.
/// </summary>
public void SetHandled() => _handled = true;
public UIInputContext(IInputAction action, Vector2 mousePosition, string actionName, int charPressed = 0)
{
Action = action;
MousePosition = mousePosition;
ActionName = actionName;
CharPressed = charPressed;
}
private bool _handled;
}

186
Voile/Source/UI/UISystem.cs Normal file
View File

@@ -0,0 +1,186 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
using Voile.Resources;
namespace Voile.UI;
public class UISystem : IUpdatableSystem, IRenderableSystem, IReloadableSystem
{
public IReadOnlyList<IElement> Elements => _elements;
public bool RenderDebugRects { get; set; }
public Color DebugSizeRectColor { get; set; } = Color.Red;
public Color DebugDirtyRectColor { get; set; } = new Color(1.0f, 1.0f, 0.0f, 0.5f);
public UISystem(InputSystem inputSystem)
{
_input = inputSystem;
}
public UISystem(InputSystem inputSystem, List<UIElement> elements)
{
_input = inputSystem;
_elements = elements;
}
public void SetStyleSheet(ResourceRef<StyleSheet> styleSheet)
{
_styleSheet = styleSheet;
}
public void AddElement(UIElement element)
{
element.StyleSheetOverride = _styleSheet;
_elements.Add(element);
_inputElementIndices.Add(element.GlobalPosition, _elements.Count - 1);
}
public void RemoveElement(UIElement element) => _elements.Remove(element);
public void Update(double deltaTime)
{
// HandleInput();
}
public void Render(RenderSystem renderer)
{
// Update elements each time UI system is rendered.
HandleInput();
foreach (var element in _elements)
{
if (element is not IUpdatableElement updatable) continue;
updatable.Update();
}
foreach (var element in _elements)
{
if (element is IRenderableElement renderable)
{
var styleSheet = _styleSheet.Value;
if (!styleSheet.TryGet(element.StyleName, out var style))
{
style = new Style();
}
renderable.Render(renderer, style);
}
}
if (!RenderDebugRects) return;
foreach (var element in _elements)
{
if (element is not UIElement uiElement) continue;
DrawDebugForElement(renderer, uiElement);
}
}
public void Reload()
{
throw new NotImplementedException();
}
private void DrawDebugForElement(RenderSystem renderer, UIElement element)
{
var size = new Vector2(element.Size.Width, element.Size.Height);
renderer.SetTransform(element.GlobalPosition, Vector2.Zero);
renderer.DrawRectangleOutline(size, DebugSizeRectColor);
if (element.Dirty)
{
renderer.DrawRectangle(size, DebugDirtyRectColor);
}
if (element is IParentableElement parentableElement)
{
foreach (var child in parentableElement.Children)
{
if (child is not UIElement childElement) continue;
DrawDebugForElement(renderer, childElement);
}
}
}
private void HandleInput()
{
int charPressed = _input.GetCharPressed();
Vector2 mousePos = _input.GetMousePosition();
Vector2 currentMousePosition = _input.GetMousePosition();
foreach (var (actionName, mappings) in InputSystem.InputMappings)
{
foreach (var action in mappings)
{
if (action.IsPressed(_input))
{
// TODO: specify which mouse button is used in the context.
var context = new UIInputContext(action, mousePos, actionName, charPressed)
{
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonReleased(MouseButton.Left),
};
if (PropagateInput(_elements, context))
return;
}
}
}
if (charPressed != 0)
{
var context = new UIInputContext(new KeyInputAction(KeyboardKey.Null), mousePos, "", charPressed);
PropagateInput(_elements, context);
}
if (currentMousePosition != _lastMousePosition)
{
// TODO: specify which mouse button is used in the context.
var context = new UIInputContext(new MouseInputAction(MouseButton.Left), mousePos, "", charPressed)
{
MouseDown = _input.IsMouseButtonDown(MouseButton.Left),
MouseReleased = _input.IsMouseButtonReleased(MouseButton.Left),
MousePressed = _input.IsMouseButtonPressed(MouseButton.Left),
};
PropagateInput(_elements, context);
}
}
private bool PropagateInput(List<UIElement> elements, UIInputContext context)
{
for (int i = elements.Count - 1; i >= 0; i--)
{
var element = elements[i];
// if (!element.ContainsPoint(context.MousePosition)) continue;
if (element is IInputElement inputElement && !inputElement.IgnoreInput)
{
inputElement.Input(context);
_input.SetAsHandled();
// return true;
}
if (element is IParentableElement parent)
{
if (PropagateInput(parent.Children.ToList(), context))
return true;
}
}
return false;
}
private ResourceRef<StyleSheet> _styleSheet;
private List<UIElement> _elements = new();
private InputSystem _input;
private GridSet<int> _inputElementIndices = new();
private Vector2 _lastMousePosition = Vector2.Zero;
}

View File

@@ -0,0 +1,170 @@
using System.Numerics;
using Voile.Rendering;
using Voile.Resources;
using Voile.UI.Containers;
namespace Voile.UI.Widgets;
public enum ButtonState
{
Disabled,
Hovered,
Pressed,
Normal
}
/// <summary>
/// A clickable button with a label.
/// </summary>
public class Button : Widget
{
public string Text
{
get => _text; set
{
_text = value;
MarkDirty();
}
}
public ButtonState CurrentState { get; private set; } = ButtonState.Normal;
public override Rect MinimumSize => Padding + _textSize;
/// <summary>
/// <see cref="FontSet"/> to use with this button.
/// </summary>
public FontSet FontSet { get; set; } = new();
public Size Padding
{
get => _padding; set
{
_padding = value;
}
}
public override string? StyleElementName => nameof(Button);
public override string[]? StyleModifiers =>
[
CurrentState.ToString()
];
public Button(string text, ResourceRef<Font> fontOverride, Action pressedAction)
{
_text = text;
_pressedAction = pressedAction;
FontSet.AddFont(fontOverride);
MarkDirty();
Update();
}
public Button(string text, FontSet fontSet)
{
_text = text;
FontSet = fontSet;
MarkDirty();
Update();
}
public Button(string text, FontSet fontSet, Action pressedAction)
{
_text = text;
_pressedAction = pressedAction;
FontSet = fontSet;
MarkDirty();
Update();
}
protected override void OnRender(RenderSystem renderer, Style style)
{
if (_padding != style.Padding)
{
MarkDirty();
}
_padding = style.Padding ?? Voile.Size.Zero;
var textColor = style.TextColor ?? Color.Black;
var textPosition = new Vector2(GlobalPosition.X + Padding.Left, GlobalPosition.Y + Padding.Top);
renderer.SetTransform(textPosition, Vector2.Zero);
renderer.DrawText(_suitableFont, _text, textColor);
}
protected virtual void Pressed() { }
protected override void OnInput(UIInputContext action)
{
bool isHovering = ContainsPoint(action.MousePosition);
if (action.MousePressed && isHovering)
{
_isHeldDown = true;
CurrentState = ButtonState.Pressed;
}
else if (action.MouseReleased)
{
if (_isHeldDown && isHovering)
{
_pressedAction?.Invoke();
Pressed();
}
_isHeldDown = false;
CurrentState = isHovering ? ButtonState.Hovered : ButtonState.Normal;
}
else
{
if (_isHeldDown)
{
CurrentState = ButtonState.Pressed; // keep showing as pressed
}
else if (isHovering)
{
CurrentState = ButtonState.Hovered;
}
else
{
CurrentState = ButtonState.Normal;
}
}
}
protected override void OnUpdate()
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
foreach (var c in _text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
{
if (fallbackFont != fontRef)
{
fontRef = fallbackFont;
}
}
}
_suitableFont = fontRef;
var font = _suitableFont.Value;
_textSize = font.Measure(_text);
Size = _padding + _textSize;
}
private Action? _pressedAction;
private ResourceRef<Font> _suitableFont = ResourceRef<Font>.Empty();
private string _text = "Hello, World!";
private Rect _textSize = Rect.Zero;
private Size _padding;
private bool _isHeldDown;
}

View File

@@ -0,0 +1,85 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
using Voile.Resources;
namespace Voile.UI.Widgets;
public class Label : Widget
{
public override Rect MinimumSize => _textSize;
public string Text
{
get => _text; set
{
_text = value;
MarkDirty();
}
}
public override string? StyleElementName => nameof(Label);
/// <summary>
/// <see cref="FontSet"/> to use with this label.
/// </summary>
public FontSet FontSet { get; set; } = new();
public Label(string text, ResourceRef<Font> fontOverride)
{
_text = text;
FontSet.AddFont(fontOverride);
MarkDirty();
Update();
}
public Label(string text, FontSet fontSet)
{
_text = text;
FontSet = fontSet;
MarkDirty();
Update();
}
protected override void OnInput(UIInputContext action)
{
}
protected override void OnRender(RenderSystem renderer, Style style)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawText(_suitableFont, _text, style.TextColor ?? Color.Black);
}
protected override void OnUpdate()
{
ResourceRef<Font> fontRef = ResourceRef<Font>.Empty();
foreach (var c in _text)
{
if (FontSet.TryGetFontFor(c, out var fallbackFont))
{
if (fallbackFont != fontRef)
{
fontRef = fallbackFont;
}
}
}
_suitableFont = fontRef;
var font = _suitableFont.Value;
_textSize = font.Measure(_text);
Size = _textSize;
}
private ResourceRef<Font> _suitableFont = ResourceRef<Font>.Empty();
private string _text = "Hello, World!";
private Rect _textSize = Rect.Zero;
}

View File

@@ -0,0 +1,78 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
namespace Voile.UI.Widgets;
public class RectangleWidget : Widget
{
public override Rect MinimumSize { get; }
public Color Color { get; set; } = Color.White;
public RectangleWidget(Rect minimumRect, Color color) : base()
{
MinimumSize = minimumRect;
Color = color;
_defaultColor = color;
_defaultSize = Size;
_hoverColor = color.Lightened(0.25f);
}
protected override void OnRender(RenderSystem renderer, Style style)
{
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(new Vector2(Size.Width, Size.Height), Color);
}
protected override void OnInput(UIInputContext inputContext)
{
if (_defaultSize == Rect.Zero)
{
_defaultSize = Size;
}
if (_downSize == Rect.Zero)
{
_downSize = new Rect(Size.Width * 0.75f, Size.Height * 0.75f);
}
var mouseInside = ContainsPoint(inputContext.MousePosition);
if (mouseInside)
{
Color = _hoverColor;
}
else
{
Color = _defaultColor;
}
// if (mouseInside && inputContext.MouseDown)
// {
// Size = _downSize;
// }
// if (mouseInside && inputContext.MouseReleased)
// {
// Size = _defaultSize;
// }
if (mouseInside && inputContext.MousePressed)
{
Console.WriteLine("Hello, I was clicked!");
}
}
protected override void OnUpdate()
{
}
private Color _defaultColor;
private Color _hoverColor;
private Rect _defaultSize = Rect.Zero;
private Rect _downSize = Rect.Zero;
}

View File

@@ -0,0 +1,40 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
namespace Voile.UI.Widgets;
/// <summary>
/// A base class for all UI widgets.
/// </summary>
public abstract class Widget : UIElement, IInputElement
{
public Widget()
{
MarkDirty();
}
public Widget(Rect size)
{
Size = size;
MarkDirty();
}
public Widget(Vector2 position)
{
LocalPosition = position;
MarkDirty();
}
public void Input(UIInputContext context)
{
if (context.Handled || IgnoreInput) return;
OnInput(context);
}
/// <summary>
/// Called when this widget receives input.
/// </summary>
/// <param name="action">An input action this widget received.</param>
protected abstract void OnInput(UIInputContext context);
}

View File

@@ -1,130 +0,0 @@
namespace Voile
{
// Based on https://github.com/ppr-game/PPR/blob/engine/PER.Util/src/Color.cs
/// <summary>
/// A record struct representing a color.
/// </summary>
public record struct Color
{
// TODO: add more HTML colors.
public static Color AliceBlue = new(0xF0F8FF);
public static Color AntiqueWhite = new(0xFAEBD7);
public static Color Aqua = new(0x00FFFF);
public static Color Aquamarine = new(0x7FFFD4);
public static Color Azure = new(0xF0FFFF);
public static Color Beige = new(0xF5F5DC);
public static Color Bisque = new(0xFFE4C4);
public static Color Black = new(0x000000);
public static Color BlanchedAlmond = new(0xFFEBCD);
public static Color Blue = new(0x0000FF);
public static Color BlueViolet = new(0x8A2BE2);
public static Color Brown = new(0xA52A2A);
public static Color BurlyWood = new(0xDEB887);
public static Color CadetBlue = new(0x5F9EA0);
public static Color Chartreuse = new(0x7FFF00);
public static Color Chocolate = new(0xD2691E);
public static Color Coral = new(0xFF7F50);
public static Color CornflowerBlue = new(0x6495ED);
public static Color Cornsilk = new(0xFFF8DC);
public static Color Crimson = new(0xDC143C);
public static Color Cyan = new(0x00FFFF);
public static Color DarkBlue = new(0x00008B);
public static Color DarkCyan = new(0x008B8B);
public static Color White = new(0xFFFFFF);
public static Color Green = new(0x00FF00);
public static Color Red = new(0xFF0000);
public byte R { get; set; }
public byte G { get; set; }
public byte B { get; set; }
public byte A { get; set; } = 255;
public int Argb
{
get
{
int a = A << 24;
int r = R << 16;
int g = G << 8;
int b = B;
return a | r | g | b;
}
}
public Color(float r, float g, float b, float a = 1.0f)
{
R = (byte)Math.Clamp(r * 255, 0, 255);
G = (byte)Math.Clamp(g * 255, 0, 255);
B = (byte)Math.Clamp(b * 255, 0, 255);
A = (byte)Math.Clamp(a * 255, 0, 255);
}
public Color(byte r, byte g, byte b, byte a = 255)
{
R = r;
G = g;
B = b;
A = a;
}
public Color(int hex)
{
A = 255; // Default alpha to 255 if not provided
B = (byte)(hex & 0xFF);
G = (byte)((hex >> 8) & 0xFF);
R = (byte)((hex >> 16) & 0xFF);
if (hex > 0xFFFFFF) // If the hex value includes alpha
{
A = (byte)((hex >> 24) & 0xFF);
}
}
public static Color FromHexString(string hex)
{
if (hex.StartsWith("#"))
{
hex = hex[1..];
}
if (hex.Length == 6)
{
int rgb = int.Parse(hex, System.Globalization.NumberStyles.HexNumber);
return new Color(rgb);
}
else if (hex.Length == 8)
{
int rgba = int.Parse(hex, System.Globalization.NumberStyles.HexNumber);
return new Color(rgba);
}
else
{
throw new ArgumentException("Invalid hex color format. Use #RRGGBB or #RRGGBBAA.");
}
}
public Color Lightened(float amount)
{
var result = this;
result.R = (byte)Math.Min(255, R + (255 - R) * amount);
result.G = (byte)Math.Min(255, G + (255 - G) * amount);
result.B = (byte)Math.Min(255, B + (255 - B) * amount);
return result;
}
public Color Darkened(float amount)
{
var result = this;
result.R = (byte)(R * (1.0f - amount));
result.G = (byte)(G * (1.0f - amount));
result.B = (byte)(B * (1.0f - amount));
return result;
}
public System.Drawing.Color ToSystemColor()
{
var result = System.Drawing.Color.FromArgb(Argb);
return result;
}
}
}

View File

@@ -15,6 +15,18 @@ namespace Voile
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public static float Lerp(float a, float b, float t) => t <= 0f ? a : t >= 1f ? b : LerpUnclamped(a, b, t); public static float Lerp(float a, float b, float t) => t <= 0f ? a : t >= 1f ? b : LerpUnclamped(a, b, t);
public static Size LerpSize(Size a, Size b, float t)
{
t = Math.Clamp(t, 0f, 1f);
float left = Lerp(a.Left, b.Left, t);
float right = Lerp(a.Right, b.Right, t);
float top = Lerp(a.Top, b.Top, t);
float bottom = Lerp(a.Bottom, b.Bottom, t);
return new Size(left, right, top, bottom);
}
public static Color LerpColor(Color colorA, Color colorB, float t) public static Color LerpColor(Color colorA, Color colorB, float t)
{ {
t = Math.Clamp(t, 0f, 1f); t = Math.Clamp(t, 0f, 1f);
@@ -35,6 +47,15 @@ namespace Voile
return new Vector2((float)x, (float)y); return new Vector2((float)x, (float)y);
} }
public static Color RandomColor()
{
var r = _random.NextDouble(0.0f, 1.0f);
var g = _random.NextDouble(0.0f, 1.0f);
var b = _random.NextDouble(0.0f, 1.0f);
return new Color((float)r, (float)g, (float)b);
}
public static float EaseOutBack(float x) public static float EaseOutBack(float x)
{ {
var c1 = 1.70158f; var c1 = 1.70158f;

View File

@@ -0,0 +1,69 @@
using Voile.Utils;
namespace Voile.VFS;
/// <summary>
/// A file in the OS file system.
/// </summary>
public class FileSystemFile : VirtualFile
{
public FileSystemFile(string path)
{
_fsPath = path;
}
public override Stream GetStream()
{
return new FileStream(_fsPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
}
private string _fsPath;
}
/// <summary>
/// A <see cref="IVirtualMountPoint"/> implementation for an OS file system.
/// </summary>
public class FileSystemMountPoint : IVirtualMountPoint
{
public int Order => int.MaxValue;
public FileSystemMountPoint(string path)
{
_fsPath = path;
}
public void Mount()
{
_logger.Info($"Mounting from file system path \"{_fsPath}\".");
int rootLength = _fsPath.Length;
var files =
Directory.GetFiles(_fsPath, "*", SearchOption.AllDirectories)
.Select(p => p.Remove(0, rootLength))
.ToList();
foreach (string file in files)
{
var relativePath = NormalizePath(file);
var fullPath = NormalizePath(Path.Combine(_fsPath, file));
_files[relativePath] = new FileSystemFile(fullPath);
}
}
public VirtualFile GetFile(string path) => _files[path];
public IDictionary<string, VirtualFile> GetFiles() => _files;
public bool HasFile(string path) => _files.ContainsKey(path);
private string NormalizePath(string path)
{
return path
.Replace(@"\\", @"\")
.Replace(@"\", @"/");
}
private Logger _logger = new(nameof(FileSystemMountPoint));
private string _fsPath;
private Dictionary<string, VirtualFile> _files = new();
}

View File

@@ -0,0 +1,6 @@
namespace Voile.VFS;
public abstract class VirtualFile
{
public abstract Stream GetStream();
}

View File

@@ -0,0 +1,33 @@
namespace Voile.VFS;
/// <summary>
/// A virtual mounting point.
/// </summary>
public interface IVirtualMountPoint
{
/// <summary>
/// Order of mounting for this mount point. Lower values indicate higher priority for lookup.
/// </summary>
int Order { get; }
/// <summary>
/// Mounts this <see cref="IVirtualMountPoint"/>.
/// </summary>
void Mount();
/// <summary>
/// Gets a file.
/// </summary>
/// <param name="path">Relative path to the file.</param>
/// <returns>An instance of <see cref="VirtualFile"/> if the file exists; otherwise, an exception is thrown.</returns>
VirtualFile GetFile(string path);
/// <summary>
/// Gets all files available at this <see cref="IVirtualMountPoint"/>.
/// </summary>
/// <returns>A dictionary mapping a relative path to an instance of a <see cref="VirtualFile"/>.</returns>
IDictionary<string, VirtualFile> GetFiles();
/// <summary>
/// Determines whether a file exists at the given relative path within this mount point.
/// </summary>
/// <param name="path">The relative path of the file to check.</param>
/// <returns><c>true</c> if the file exists; otherwise, <c>false</c>.</returns>
bool HasFile(string path);
}

View File

@@ -0,0 +1,59 @@
namespace Voile.VFS;
/// <summary>
/// A virtual file system that provides an abstract interface for manipulating files from various sources.
/// </summary>
public static class VirtualFileSystem
{
/// <summary>
/// Mounts a <see cref="IVirtualMountPoint"/>.
/// This will make files available for this mount point accessible.
/// </summary>
/// <param name="mountPoint"></param>
public static void Mount(IVirtualMountPoint mountPoint)
{
mountPoint.Mount();
_mounts.Add(mountPoint);
_mounts = _mounts.OrderBy(mount => mount.Order).ToList();
}
/// <summary>
/// Check if file exists or not.
/// </summary>
/// <param name="path">Relative path to the file.</param>
/// <returns></returns>
public static bool FileExists(string path)
{
return _mounts.Any(mount => mount.HasFile(path));
}
/// <summary>
/// Gets a <see cref="VirtualFile"/> from path.
/// </summary>
/// <param name="path">Relative path to the file.</param>
/// <returns>A virtual file at the path.</returns>
public static VirtualFile GetFile(string path)
{
var mount = _mounts.FirstOrDefault(mount => mount.HasFile(path));
if (mount == null)
{
throw new FileNotFoundException($"File \"{path}\" was not found in any mounted point.");
}
return mount.GetFile(path);
}
/// <summary>
/// Reads a file.
/// </summary>
/// <param name="path">Relative path to the file.</param>
/// <returns>A readable stream.</returns>
public static Stream Read(string path)
{
var file = GetFile(path);
return file.GetStream();
}
private static List<IVirtualMountPoint> _mounts = new();
}

View File

@@ -2,28 +2,23 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FreeTypeSharp" Version="3.0.0" />
<PackageReference Include="Silk.NET.WebGPU" Version="2.20.0" /> <PackageReference Include="Silk.NET.WebGPU" Version="2.20.0" />
<PackageReference Include="Silk.NET.WebGPU.Native.WGPU" Version="2.20.0" /> <PackageReference Include="Silk.NET.WebGPU.Native.WGPU" Version="2.20.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.20.0" /> <PackageReference Include="Silk.NET.Windowing" Version="2.20.0" />
<PackageReference Include="SoLoud.NET" Version="2020.2.7.1" />
<PackageReference Include="Tommy" Version="3.1.2" /> <PackageReference Include="Tommy" Version="3.1.2" />
<PackageReference Include="Voile.Fmod" Version="0.2.2.8" />
<PackageReference Include="ImGui.NET" Version="1.89.4" /> <PackageReference Include="ImGui.NET" Version="1.89.4" />
<PackageReference Include="Raylib-cs" Version="4.2.0.1" /> <PackageReference Include="Raylib-cs" Version="7.0.1" />
<PackageReference Include="SharpFont" Version="4.0.1" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19" />
<PackageReference Include="StbImageSharp" Version="2.27.13" /> <PackageReference Include="StbImageSharp" Version="2.27.13" />
<PackageReference Include="StbVorbisSharp" Version="1.22.4" /> <PackageReference Include="StbVorbisSharp" Version="1.22.4" />
</ItemGroup> </ItemGroup>
<Target Name="BuildFmod" BeforeTargets="BeforeBuild">
<MSBuild Projects="../Voile.Fmod/Voile.Fmod.csproj" Targets="Restore;Build" />
</Target>
</Project> </Project>