Compare commits

..

38 Commits

Author SHA1 Message Date
dc7122ed26 Merge branch 'main' into standard-renderer 2025-06-24 21:38: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
30c438c407 Begin standard-renderer branch, fix wrong Voile to WebGPU color conversion. 2025-06-20 23:10:18 +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
55 changed files with 1854 additions and 430 deletions

2
.gitignore vendored
View File

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

30
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
@@ -61,7 +71,17 @@
## UI ## UI
- Immediate mode API. - ~~Layout~~
- Styling. - ~~Containers~~
- ~~VerticalContainer~~
- ~~HorizontalContainer~~
- ~~GridContainer~~
- ~~FlexContainer~~
- ~~Positioning (anchors)~~
- ~~Move layouting to Render instead of Update, use Update for input.~~
- Input propagation
- Basic input elements (button, text field, toggle). - Basic input elements (button, text field, toggle).
- Containers (vertical and horizontal). - Styling
- Add style settings for UI panels (for buttons, labels, etc.).
- Find a way to reference external assets in the style (fonts, textures).
- Create a default style for widgets.

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

@@ -5,17 +5,26 @@ 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, ResourceRef<Style>.Empty());
_uiSystem.RenderDebugRects = true;
_particleSystem = new ParticleSystem(); _particleSystem = new ParticleSystem();
AddSystemToUpdate(_uiSystem);
AddSystemToUpdate(_particleSystem); AddSystemToUpdate(_particleSystem);
} }
@@ -34,6 +43,7 @@ public class TestGame : Game
} }
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))
{ {
@@ -43,17 +53,16 @@ public class TestGame : Game
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);
_frame.AddChild(_container);
_uiSystem.AddElement(_frame);
} }
protected override void Update(double deltaTime) protected override void Update(double deltaTime)
{
}
protected override void Render(double deltaTime)
{ {
if (Input.IsActionPressed("reload")) if (Input.IsActionPressed("reload"))
{ {
@@ -61,28 +70,34 @@ public class TestGame : Game
_particleSystem!.RestartEmitter(_emitterId); _particleSystem!.RestartEmitter(_emitterId);
} }
if (Input.KeyboardKeyJustPressed(KeyboardKey.One)) if (Input.IsActionPressed("accept"))
{ {
_particleSystem.CreateEmitter(Input.GetMousePosition(), _fireEffect); _container.AddChild(new RectangleWidget(new Rect(32.0f, 32.0f), MathUtils.RandomColor()));
}
if (Input.IsActionPressed("cancel") && _container.Children.Count != 0)
{
var lastChild = _container.Children.Last();
_container.RemoveChild(lastChild);
} }
if (Input.IsMouseButtonDown(MouseButton.Left)) if (Input.IsMouseButtonDown(MouseButton.Left))
{ {
_particleSystem.SetEmitterPosition(_emitterId, Input.GetMousePosition()); var mousePos = Input.GetMousePosition();
_frame.Size = new Rect(mousePos.X, mousePos.Y);
}
} }
Renderer.ClearBackground(Color.Black); protected override void Render(double deltaTime)
foreach (var emitter in _particleSystem!.Emitters)
{ {
DrawEmitter(emitter); Renderer.ClearBackground(Color.CadetBlue);
} // foreach (var emitter in _particleSystem!.Emitters)
// {
// 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 +120,29 @@ 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<Sound> _sound;
private ResourceRef<Texture2d> _icon; private ResourceRef<Texture2d> _icon;
private FlexContainer _container = new(minimumSize: new Rect(64.0f, 64.0f), new())
{
Anchor = Anchor.Center,
Size = new Rect(500, 300),
Direction = FlexDirection.Column,
Justify = JustifyContent.Start,
Align = AlignItems.Center,
Wrap = true,
Gap = 10f
};
private Frame _frame = new();
// private VerticalContainer _container = new(new Rect(128.0f, 64.0f), new(), 16)
// {
// ConfineToContents = true,
// Anchor = Anchor.CenterLeft,
// AnchorOffset = new Vector2(0.5f, 0.0f)
// };
} }

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

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;
}
}

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();
@@ -103,7 +108,7 @@ namespace Voile
if (Renderer is null) if (Renderer is null)
{ {
Renderer = new RaylibRenderSystem(); Renderer = new StandardRenderSystem();
} }
if (Input is null) if (Input is null)
@@ -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.IsMousePressed(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 IsMousePressed(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 IsMousePressed(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;
} }
} }

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;
@@ -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));
@@ -252,28 +264,17 @@ 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"; // TODO: don't use a hardcoded extension.
Span<byte> extBytes = new byte[Encoding.Default.GetByteCount(ext) + 1]; int fontChars = 250; // TODO: control this dynamically to not load the entire font.
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, null, 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);
@@ -288,14 +289,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>

View File

@@ -363,7 +363,7 @@ namespace Voile.Rendering
private Silk.NET.WebGPU.Color VoileColorToWebGPUColor(Color color) private Silk.NET.WebGPU.Color VoileColorToWebGPUColor(Color color)
{ {
return new Silk.NET.WebGPU.Color(color.R, color.G, color.B, color.A); return new Silk.NET.WebGPU.Color((double)color.R / 255, (double)color.G / 255, (double)color.B / 255, (double)color.A / 255);
} }
private unsafe RenderPassColorAttachment CreateClearColorAttachment(TextureView* view, Color clearColor) private unsafe RenderPassColorAttachment CreateClearColorAttachment(TextureView* view, Color clearColor)
@@ -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

@@ -7,7 +7,7 @@ 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 string ExpectedHeader { get; private set; } = string.Empty;
public TomlDataReader(string expectedHeader) public TomlDataReader(string expectedHeader)
@@ -17,14 +17,7 @@ public class TomlDataReader : IStreamDataReader, IDataValidator, IStreamKeyValue
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.HasKey(ExpectedHeader);
@@ -231,12 +224,6 @@ public class TomlDataReader : IStreamDataReader, IDataValidator, IStreamKeyValue
public bool Valid() => _valid; public bool Valid() => _valid;
public void Dispose()
{
_fs?.Dispose();
}
private TomlTable? _table; private TomlTable? _table;
private FileStream? _fs;
private bool _valid; private bool _valid;
} }

View File

@@ -1,4 +1,6 @@
using Voile.VFS;
namespace Voile.Resources; namespace Voile.Resources;
public class FontLoader : ResourceLoader<Font> public class FontLoader : ResourceLoader<Font>
@@ -11,9 +13,14 @@ public class FontLoader : ResourceLoader<Font>
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;
return result; return result;
} }
} }

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);
} }
} }

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

@@ -0,0 +1,17 @@
using Voile.UI;
namespace Voile.Resources;
public class StyleLoader : ResourceLoader<Style>
{
public override IEnumerable<string> SupportedExtensions =>
[
".toml"
];
protected override Style LoadResource(string path)
{
// TODO: implement loading styles.
return new Style(string.Empty);
}
}

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

@@ -18,6 +18,11 @@ namespace Voile
/// </summary> /// </summary>
public T Value => ResourceManager.GetResource<T>(Guid); public T Value => ResourceManager.GetResource<T>(Guid);
public static ResourceRef<T> Empty()
{
return new ResourceRef<T>(Guid.Empty);
}
public ResourceRef(Guid guid) public ResourceRef(Guid guid)
{ {
Guid = guid; Guid = guid;

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!");
} }
@@ -343,11 +345,13 @@ 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()},
{ typeof(Style), new StyleLoader()}
}; };
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("ParticleEmitterSettings");
{ 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);
} }

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

@@ -0,0 +1,41 @@
using System.Numerics;
namespace Voile.UI;
public enum Anchor
{
TopLeft,
TopCenter,
TopRight,
CenterLeft,
Center,
CenterRight,
BottomLeft,
BottomCenter,
BottomRight,
Fill
}
public static class AnchorExtensions
{
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,136 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI.Containers;
// TODO: make Container extend Widget, it already implements similar behaviors.
/// <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 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.MarkDirty();
updatable.Update();
if (child is IAnchorableElement anchorable)
{
anchorable.ApplyAnchor(GlobalPosition, Size);
}
}
Arrange();
if (ConfineToContents)
{
RecalculateSizes();
}
}
/// <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);
Size = new Rect(finalWidth, finalHeight);
if (_minimumSize > Size)
{
Size = _minimumSize;
}
}
public void AddChild(UIElement child)
{
_children.Add(child);
child.SetParent(this);
MarkDirty();
Update();
}
public void RemoveChild(UIElement child)
{
_children.Remove(child);
MarkDirty();
Update();
}
public override void Render(RenderSystem renderer, Style style)
{
foreach (var child in Children)
{
if (child is not IRenderableElement renderable) continue;
renderable.Render(renderer, style);
}
}
private List<UIElement> _children = new();
private Rect _minimumSize = 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,19 @@
namespace Voile.UI.Containers;
public class Frame : Container
{
public Frame()
{
}
public Frame(Rect minimumSize) : base(minimumSize)
{
}
public override void Arrange()
{
}
}

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,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;
}
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
namespace Voile.UI;
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; }
}
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);
}
public interface IResizeableElement
{
/// <summary>
/// Get a minimum rectangle size for this element.
/// </summary>
public abstract Rect MinimumSize { get; }
}
public interface IUpdatableElement
{
/// <summary>
/// Specifies if this element's properties have changed, making it necessary to update it.
/// </summary>
public bool Dirty { get; }
/// <summary>
/// Update this element.
/// </summary>
void Update();
/// <summary>
/// Marks this element as changed, requiring an update.
/// </summary>
void MarkDirty();
}
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);
}
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);
}
public interface IAnchorableElement
{
public Anchor Anchor { get; set; }
public Vector2 AnchorOffset { get; set; }
public void ApplyAnchor(Vector2 parentPosition, Rect parentRect);
}

23
Voile/Source/UI/Rect.cs Normal file
View File

@@ -0,0 +1,23 @@
namespace Voile.UI;
/// <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;
}

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

@@ -0,0 +1,13 @@
using Voile.Resources;
namespace Voile.UI;
/// <summary>
/// A resource containing UI style settings.
/// </summary>
public class Style : TextDataResource
{
public Style(string path) : base(path)
{
}
}

View File

@@ -0,0 +1,95 @@
using System.Numerics;
using Voile.Rendering;
namespace Voile.UI;
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 Rect Size
{
get => _size;
set
{
_size = value;
if (value.Width < MinimumSize.Width)
{
_size.Width = MinimumSize.Width;
}
if (value.Height < MinimumSize.Height)
{
_size.Height = MinimumSize.Height;
}
MarkDirty();
}
}
public Vector2 AnchorOffset { get; set; } = Vector2.Zero;
public Anchor Anchor { get; set; } = Anchor.TopLeft;
public abstract Rect MinimumSize { get; }
public bool Dirty => _dirty;
public virtual void MarkDirty() => _dirty = true;
public void SetParent(UIElement parent)
{
_parent = parent;
}
public void Update()
{
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 abstract void Render(RenderSystem renderer, Style style);
protected abstract void OnUpdate();
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 bool _dirty = true;
private Rect _size = Rect.Zero;
private UIElement? _parent;
}

View File

@@ -0,0 +1,35 @@
using System.Numerics;
using Voile.Input;
namespace Voile.UI;
public class UIInputContext
{
public IInputAction Action { get; }
public Vector2 MousePosition { get; }
public bool MousePressed { get; set; }
public bool MouseReleased { get; set; }
public bool MouseDown { get; set; }
public string ActionName { get; }
public int CharPressed { get; }
public bool HasCharInput => CharPressed != 0;
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;
}

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

@@ -0,0 +1,147 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
namespace Voile.UI;
public class UISystem : IUpdatableSystem, IRenderableSystem
{
public IReadOnlyList<IElement> Elements => _elements;
public bool RenderDebugRects { get; set; }
public UISystem(InputSystem inputSystem)
{
_style = ResourceRef<Style>.Empty();
_input = inputSystem;
}
public UISystem(InputSystem inputSystem, ResourceRef<Style> style)
{
_input = inputSystem;
_style = style;
}
public UISystem(InputSystem inputSystem, ResourceRef<Style> style, List<UIElement> elements)
{
_input = inputSystem;
_style = style;
_elements = elements;
}
public void AddElement(UIElement element) => _elements.Add(element);
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.
foreach (var element in _elements)
{
if (element is not IUpdatableElement updatable) continue;
updatable.Update();
}
foreach (var element in _elements)
{
if (element is IRenderableElement renderable)
{
renderable.Render(renderer, _style.Value);
if (!RenderDebugRects) return;
renderable.DrawSize(renderer);
}
if (element is IParentableElement parentable)
{
foreach (var child in parentable.Children)
{
if (child is not IRenderableElement renderableChild) continue;
if (!RenderDebugRects) return;
renderableChild.DrawSize(renderer);
}
}
}
}
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.IsMouseButtonReleased(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 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<Style> _style;
private List<UIElement> _elements = new();
private InputSystem _input;
private Vector2 _lastMousePosition = Vector2.Zero;
}

View File

@@ -0,0 +1,47 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
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 Label { get; set; } = "Button";
public override Rect MinimumSize => new Rect(Width: 128.0f, Height: 64.0f);
public Button(string label, Action pressedAction)
{
Label = label;
_pressedAction = pressedAction;
}
public override void Render(RenderSystem renderer, Style style)
{
// TODO: use a button color from style.
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawRectangle(new Vector2(MinimumSize.Width, MinimumSize.Height), new Color(0.25f, 0.25f, 0.25f));
}
protected override void OnInput(UIInputContext action)
{
}
protected override void OnUpdate()
{
throw new NotImplementedException();
}
private Action _pressedAction;
}

View File

@@ -0,0 +1,43 @@
using System.Numerics;
using Voile.Input;
using Voile.Rendering;
namespace Voile.UI.Widgets;
public class Label : Widget
{
public override Rect MinimumSize => throw new NotImplementedException();
public string Text { get; set; } = "Hello World!";
public Label(string text)
{
Text = text;
}
public Label(string text, ResourceRef<Font> fontOverride)
{
Text = text;
_fontOverride = fontOverride;
}
protected override void OnInput(UIInputContext action)
{
throw new NotImplementedException();
}
public override void Render(RenderSystem renderer, Style style)
{
// TODO: use style here.
if (!_fontOverride.HasValue) return;
renderer.SetTransform(GlobalPosition, Vector2.Zero);
renderer.DrawText(_fontOverride, Text, Color.White);
}
protected override void OnUpdate()
{
throw new NotImplementedException();
}
private ResourceRef<Font> _fontOverride = ResourceRef<Font>.Empty();
}

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);
}
public override void Render(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,43 @@
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;
if (ContainsPoint(context.MousePosition))
{
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

@@ -35,6 +35,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);
}
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,7 +2,7 @@
<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>
@@ -12,18 +12,14 @@
<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="SharpFont" Version="4.0.1" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19" /> <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>