Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 13 additions & 16 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
# Quack! 🦆 ![Static Badge](https://img.shields.io/badge/.NET-10.0,%2011.0-512BD4) [![NuGet Version](https://img.shields.io/nuget/v/KappaDuck.Quack?style=flat&label=NuGet)][NuGet]

A modern .NET multimedia framework built on SDL3
A modern .NET multimedia framework for building games and interactive apps, built on SDL3

---

## Overview

Quack! is a modern, simple and fast multimedia framework built on top of [SDL3] and its extensions ([SDL_image], [SDL_mixer], [SDL_ttf]).
Quack! is a modern, simple and fast multimedia framework for building games and interactive applications, built on top of SDL3 and its extensions ([SDL_image], [SDL_mixer], [SDL_ttf]).
It targets .NET 10+ desktop and web apps, providing a clean and flexible API that hides the complexity of SDL.

## Features

- 2D rendering via [Renderer API][SDL_Renderer] and 3D rendering via [GPU API][SDL_GPU]
- Cross-platform support
- 2D rendering via the Renderer API and 3D rendering via the GPU API
- Window and display management
- Input handling
- Audio management
- Event system
- System utilities
- Native UI integration
- Image and Font management
- Input & events
- Image and font loading
- Native UI integration, system utilities, and cross-platform support

## Usage

```csharp
using KappaDuck.Quack.Core;
using KappaDuck.Quack.Events;
using KappaDuck.Quack.Windows;

using EngineScope _ = QuackEngine.Init(Subsystem.Video);
using Window window = new("Quack!", 1280, 720);

while (window.IsOpen)
Expand Down Expand Up @@ -97,9 +96,9 @@ SDL3 native libraries are bundled via `KappaDuck.Quack.Runtimes`. The table belo

During active development, `KappaDuck.Quack` references the **pre-release** version of `KappaDuck.Quack.Runtimes`. When a stable release of `KappaDuck.Quack` is published, it switches to the corresponding **production** version of `KappaDuck.Quack.Runtimes`.

| Quack! | Runtimes | SDL3 | SDL_image | SDL_ttf | SDL_mixer |
| :------: | :------------: | :-----: | :-------: | :-----: | :-------: |
| `source` | `0.1.0-beta.1` | `3.4.8` | `3.4.4` | `3.2.2` | `3.2.0` |
| Quack! | Runtimes | SDL3 | SDL_image | SDL_ttf | SDL_mixer |
| :------: | :------------: | :------: | :-------: | :-----: | :-------: |
| `source` | `0.1.0-beta.2` | `3.4.10` | `3.4.4` | `3.2.2` | `3.2.2` |

## Development & Sandbox

Expand All @@ -124,9 +123,11 @@ cd quack
The sandbox project requires at least one `.cs` file to compile. All `.cs` files inside `src/Quack.Sandbox/` are listed in `.gitignore`, so create any file you like there. A simple starting point:

```csharp
using KappaDuck.Quack.Core;
using KappaDuck.Quack.Events;
using KappaDuck.Quack.Windows;

using EngineScope _ = QuackEngine.Init(Subsystem.Video);
using Window window = new("Quack! Sandbox", 1280, 720);

while (window.IsOpen)
Expand Down Expand Up @@ -170,8 +171,6 @@ or simply run in your IDE

AI tools assisted with two things in this project: **documentation** (XML doc comments, README, CONTRIBUTING guidelines) and **design exploration** (prototyping API shapes, exploring implementation approaches, and thinking through architecture decisions).

All code was written by the author from scratch. No AI-generated code was copied or adapted into `src/`.

## Credits

Built with inspiration from
Expand All @@ -188,8 +187,6 @@ Built with inspiration from
[samples]: samples
[NuGet]: https://www.nuget.org/packages/KappaDuck.Quack/
[SDL3]: https://www.libsdl.org/
[SDL_Renderer]: https://wiki.libsdl.org/CategoryRender
[SDL_GPU]: https://wiki.libsdl.org/CategoryGPU
[SDL_image]: https://github.com/libsdl-org/SDL_image
[SDL_mixer]: https://github.com/libsdl-org/SDL_mixer
[SDL_ttf]: https://github.com/libsdl-org/SDL_ttf
Expand Down
2 changes: 1 addition & 1 deletion runtimes/package.nuspec
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<authors>KappaDuck</authors>
<copyright>Copyright © KappaDuck</copyright>
<description>SDL3 runtimes for KappaDuck.Quack</description>
<tags>NET SDL SDL3 GameEngine Engine CSharp Game Multimedia GameFramework</tags>
<tags>NET SDL SDL3 GameEngine Engine CSharp Game Multimedia GameFramework MultimediaFramework Runtimes</tags>
<repository type="git" url="https://github.com/kappaduck/quack.git" />
<projectUrl>https://github.com/kappaduck/quack</projectUrl>
<readme>readme.md</readme>
Expand Down
28 changes: 28 additions & 0 deletions src/KappaDuck.Quack/Core/EngineScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) KappaDuck.
// Licensed under the MIT license.

namespace KappaDuck.Quack.Core;

/// <summary>
/// Represents the lifetime of the initialized <see cref="QuackEngine"/>.
/// Dispose it on the main thread to shut the engine down.
/// </summary>
public sealed class EngineScope : IDisposable
{
private readonly IDisposable _context;
private bool _disposed;

internal EngineScope() => _context = QuackSynchronizationContext.Enter();

/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
return;

_disposed = true;

_context.Dispose();
QuackEngine.Release();
}
}
140 changes: 72 additions & 68 deletions src/KappaDuck.Quack/Core/QuackEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT license.

using KappaDuck.Quack.Exceptions;
using KappaDuck.Quack.Interop.SDL.Primitives;
using System.Diagnostics;

namespace KappaDuck.Quack.Core;
Expand All @@ -13,43 +12,90 @@ namespace KappaDuck.Quack.Core;
public static class QuackEngine
{
private static readonly Lock _lock = new();
private static readonly long _startTimestamp = Stopwatch.GetTimestamp();
private static readonly int _mainThreadId = Environment.CurrentManagedThreadId;

private static int _refCount;
private static Subsystem _subsystems;
private static long _startTimestamp;
private static int? _mainThreadId;
private static Subsystem _subsystem;

/// <summary>
/// Gets the elapsed time since the engine started.
/// Gets the elapsed time since the engine was initialized.
/// </summary>
public static TimeSpan ElapsedTime => Stopwatch.GetElapsedTime(_startTimestamp);
/// <remarks>
/// Using <see cref="ElapsedTime"/> before the engine is initialized will return <see cref="TimeSpan.Zero"/>.
/// </remarks>
public static TimeSpan ElapsedTime => IsInitialized ? Stopwatch.GetElapsedTime(_startTimestamp) : TimeSpan.Zero;

/// <summary>
/// Gets a value indicating whether the engine has been initialized.
/// </summary>
public static bool IsInitialized { get; private set; }

/// <summary>
/// Gets a value indicating whether the calling thread is the application's main thread.
/// </summary>
public static bool IsMainThread => Environment.CurrentManagedThreadId == _mainThreadId;
public static bool IsMainThread => _mainThreadId == Environment.CurrentManagedThreadId;

/// <summary>
/// Gets the application metatadata provided through <see cref="SetMetadata(ApplicationMetadata)"/>.
/// Gets the application metadata provided through <see cref="SetMetadata(ApplicationMetadata)"/>.
/// </summary>
public static ApplicationMetadata? Metadata { get; private set; }

/// <summary>
/// Sets the application metadata.
/// Initializes the engine and the given <paramref name="subsystem"/>.
/// </summary>
/// <param name="metadata">The application metadata.</param>
/// <remarks>
/// <para>
/// You can set it only once; every subsequent call is ignored. You must call it at the very
/// beginning of your application, before any module initialization.
/// Call this once on the application's main thread before using any engine feature. It captures the
/// main thread, installs the synchronization context, and brings up the requested subsystems.
/// </para>
/// <para>
/// Dispose the returned <see cref="EngineScope"/> to shut the engine down and restore the previous
/// synchronization context.
/// </para>
/// </remarks>
/// <param name="subsystem">The subsystems to initialize.</param>
/// <returns>A scope that shuts the engine down when disposed.</returns>
/// <exception cref="QuackException">The engine is already initialized.</exception>
/// <exception cref="QuackInteropException">Failed to initialize a subsystem.</exception>
public static EngineScope Init(Subsystem subsystem)
{
lock (_lock)
{
ThrowHelper.ThrowIf(IsInitialized, "The engine is already initialized.");

_mainThreadId = Environment.CurrentManagedThreadId;
_startTimestamp = Stopwatch.GetTimestamp();

Initialize(subsystem);

_subsystem = subsystem | Subsystem.Events;
IsInitialized = true;
}

return new EngineScope();
}

/// <summary>
/// Determines whether the given <paramref name="subsystem"/> is currently initialized.
/// </summary>
/// <param name="subsystem">The subsystem(s) to check.</param>
/// <returns><see langword="true"/> if every requested subsystem is initialized; otherwise <see langword="false"/>.</returns>
public static bool HasSubsystem(Subsystem subsystem) => (_subsystem & subsystem) == subsystem;

/// <summary>
/// Sets the application metadata.
/// </summary>
/// <param name="metadata">The application metadata.</param>
/// <remarks>
/// You can set it only once and must do so before <see cref="Init(Subsystem)"/>; every subsequent
/// call is ignored.
/// </remarks>
/// <exception cref="QuackInteropException">Failed to set an application metadata property.</exception>
public static void SetMetadata(ApplicationMetadata metadata)
{
lock (_lock)
{
if (_refCount > 0 || Metadata is not null)
if (IsInitialized || Metadata is not null)
return;

Metadata = metadata;
Expand All @@ -62,12 +108,11 @@ public static void SetMetadata(ApplicationMetadata metadata)
SetMetadataProperty("SDL.app.metadata.url", metadata.Url?.ToString());
SetMetadataProperty("SDL.app.metadata.type", metadata.Type switch
{
ApplicationType.Application => nameof(ApplicationType.Application),
ApplicationType.Game => nameof(ApplicationType.Game),
ApplicationType.MediaPlayer => nameof(ApplicationType.MediaPlayer),
ApplicationType.Application => nameof(ApplicationType.Application),
_ => nameof(ApplicationType.Application)
});

}

static void SetMetadataProperty(string name, string? value)
Expand All @@ -79,71 +124,30 @@ static void SetMetadataProperty(string name, string? value)
}
}

/// <summary>
/// Initializes the given <paramref name="subsystem"/> if needed and increments the reference count.
/// </summary>
/// <param name="subsystem">The subsystem(s) to initialize.</param>
/// <exception cref="QuackInteropException">Failed to initialize a subsystem.</exception>
internal static void AddRef(Subsystem subsystem)
{
lock (_lock)
{
_refCount++;

Subsystem missing = subsystem & ~_subsystems;
if (missing == Subsystem.None)
return;

Initialize(missing);
_subsystems |= missing;
}
}

/// <summary>
/// Initializes the given <paramref name="subsystem"/> without taking a reference on it.
/// </summary>
/// <param name="subsystem">The subsystem(s) to initialize.</param>
/// <remarks>
/// The subsystem is brought up but is not reference-counted, so it can be released by
/// <see cref="Release"/> once the last counted reference is gone. Only use this when another
/// owner guarantees the engine stays alive for the whole duration you need the subsystem.
/// </remarks>
/// <exception cref="QuackInteropException">Failed to initialize a subsystem.</exception>
internal static void DangerousAddRef(Subsystem subsystem)
{
lock (_lock)
{
Subsystem missing = subsystem & ~_subsystems;
if (missing == Subsystem.None)
return;

Initialize(missing);
_subsystems |= missing;
}
}
internal static void EnsureInitialized(Subsystem subsystem, [CallerMemberName] string member = "")
=> ThrowHelper.ThrowIf(!HasSubsystem(subsystem), $"The {subsystem} subsystem is required. Call QuackEngine.Init({subsystem}) first.", member);

/// <summary>
/// Decrements the reference count and shuts down every subsystem once it reaches zero.
/// </summary>
internal static void Release()
{
lock (_lock)
{
if (_refCount == 0)
if (!IsInitialized)
return;

if (--_refCount > 0)
return;

if ((_subsystems & Subsystem.Mixer) == Subsystem.Mixer)
if ((_subsystem & Subsystem.Mixer) == Subsystem.Mixer)
SDL3_mixer.Quit();

if ((_subsystems & Subsystem.TTF) == Subsystem.TTF)
if ((_subsystem & Subsystem.TTF) == Subsystem.TTF)
SDL3_ttf.Quit();

Subsystem core = _subsystem & ~(Subsystem.TTF | Subsystem.Mixer);

SDL3.QuitSubSystem(core);
SDL3.Quit();

_subsystems = Subsystem.None;
_subsystem = Subsystem.None;
_mainThreadId = null;
IsInitialized = false;
}
}

Expand Down
7 changes: 2 additions & 5 deletions src/KappaDuck.Quack/Core/QuackSynchronizationContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) KappaDuck.
// Licensed under the MIT license.

using KappaDuck.Quack.Exceptions;
using System.Runtime.ExceptionServices;

namespace KappaDuck.Quack.Core;
Expand All @@ -14,10 +15,6 @@ namespace KappaDuck.Quack.Core;
/// so you can call SDL's main-thread-only APIs after an <see langword="await"/> without explicitly going
/// through <see cref="MainThreadDispatcher"/>.
/// </para>
/// <para>
/// Continuations are queued onto <see cref="MainThreadDispatcher"/> and run when the engine drains it,
/// which happens while polling events. Install it for the lifetime of your loop with <see cref="Enter"/>.
/// </para>
/// </remarks>
internal sealed class QuackSynchronizationContext : SynchronizationContext
{
Expand All @@ -30,7 +27,7 @@ internal sealed class QuackSynchronizationContext : SynchronizationContext
public static IDisposable Enter()
{
if (!QuackEngine.IsMainThread)
throw new InvalidOperationException("The Quack! synchronization context must be installed on the main thread.");
ThrowHelper.ThrowInvalidOperation("The Quack! synchronization context must be installed on the main thread.");

return new Scope();
}
Expand Down
Loading