Add project files

This commit is contained in:
alejo committed 2026-09-14 09:42:06 -03:00
commit c92f6a1865
15 files changed
+1672

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.20)
project(acrux_coroutines CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Header-only module: no library target to build, just the include path.
add_library(acrux_coroutines INTERFACE)
add_library(acrux::coroutines ALIAS acrux_coroutines)
target_include_directories(acrux_coroutines INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_compile_features(acrux_coroutines INTERFACE cxx_std_20)
option(ACRUX_COROUTINES_BUILD_TEST "Build the test/example executable" ON)
if(ACRUX_COROUTINES_BUILD_TEST)
enable_testing()
add_executable(acrux_coroutines_test test/coroutines/main.cpp)
target_link_libraries(acrux_coroutines_test PRIVATE acrux::coroutines)
if(NOT MSVC)
target_compile_options(acrux_coroutines_test PRIVATE -Wall -Wextra)
endif()
# The test writes an interactive "cutscene" to stdout and waits on
# std::cin.get() partway through -- pipe it some input so it can run
# unattended under ctest.
add_test(NAME acrux_coroutines_test
COMMAND ${CMAKE_COMMAND} -E env sh -c "printf '\\n\\n\\n' | $<TARGET_FILE:acrux_coroutines_test>")
endif()
+68
View File
@@ -0,0 +1,68 @@
# acrux::coroutines
A Unity-style coroutine system for C++20, built on `co_await`/`co_yield`. Header-only.
```cpp
#include <acrux/coroutines/Coroutine.h>
#include <acrux/coroutines/CoroutineRunner.h>
#include <acrux/coroutines/standard/WaitConditions.h>
using namespace acrux::coroutines;
using namespace acrux::coroutines::standard;
Coroutine fadeOut(float& alpha, float delta)
{
while (alpha > 0)
{
alpha -= delta;
co_yield waitForSeconds(0.1f);
}
}
int main()
{
CoroutineRunner runner;
float alpha = 1.0f;
CoroutineHandle h = startCoroutine(fadeOut(alpha, 0.1f));
while (h.isRunning())
{
runner.update(); // call once per frame
}
}
```
## Layout
```
include/acrux/coroutines/
Coroutine.h the Coroutine return type + its promise_type
CoroutineHandle.h a cheap, hashable value identifying a coroutine
CoroutineRunner.h the scheduler
Predicate.h small-buffer-optimized type-erased bool() callable
standard/
WaitConditions.h waitForSeconds / waitUntil / waitWhile / waitFor --
built entirely on the public core API, and not
depended on by anything above
test/coroutines/main.cpp a runnable example, also the module's test suite
docs/coroutines/ one file per type, plus architecture.md and example.md
```
## Three features worth knowing about up front
**The scheduler doesn't know about `waitForSeconds`/`waitUntil`/etc.** `Coroutine` accepts `co_yield` of anything invocable as `bool()`. `standard/WaitConditions.h` is just a set of functions returning small callables that satisfy that contract. Writing your own wait condition follows the exact same pattern, without touching the core headers. See `docs/coroutines/architecture.md`.
**`CoroutineRunner` can be derived from** to attach extra data to each coroutine it starts -- for example, tagging a coroutine with the game entity it belongs to. `CoroutineHandle` is hashable for this reason, and two protected virtual hooks (`onCoroutineStarted`/`onCoroutineFinished`) keep a subclass's own bookkeeping in sync automatically. See `docs/coroutines/example.md` for a complete example.
**A coroutine's children inherit its extra data automatically.** `onCoroutineStarted(handle, parent)` is told which coroutine was resuming when `handle` was started -- whether via `co_yield childTask()` or a direct `startCoroutine()` call made from inside a coroutine's body -- so a subclass can propagate its own per-coroutine data from parent to child with a couple of lines, with no special handling required at the call site that starts the child.
## Building
```sh
cmake -S . -B build
cmake --build build
ctest --test-dir build
```
Or without CMake: this module has no `.cpp` files to compile, so just add `include/` to your include path.
+37
View File
@@ -0,0 +1,37 @@
# Coroutine
`#include <acrux/coroutines/Coroutine.h>`
## Description
`Coroutine` is the return type used for any function that suspends itself with `co_yield`. It applies equally to free functions, static functions, and non-static member functions, with or without arguments. `Coroutine` is move-only.
```cpp
Coroutine foo(int a, float b);
Coroutine Enemy::attackSequence(int n);
```
## Members
| Member | Description |
|---|---|
| `m_handle` | The underlying `std::coroutine_handle<promise_type>`. Owned by this object; the destructor destroys it if non-null. |
| `promise_type` | The compiler-required promise type. Member names such as `get_return_object`, `initial_suspend`, and so on are fixed by the coroutine machinery and cannot be renamed. |
### `promise_type`
| Member | Description |
|---|---|
| `initial_suspend()` | Always suspends. Constructing a `Coroutine` builds its frame without running any of its body until it is registered with a `CoroutineRunner`. |
| `final_suspend()` | Always suspends, and is `noexcept`. |
| `unhandled_exception()` | Calls `std::terminate()`. Exceptions thrown inside a coroutine body are not propagated to the caller. |
| `yield_value(std::nullptr_t)` | Handles `co_yield nullptr` — resume on the next frame. |
| `yield_value(CoroutineHandle)` | Handles `co_yield someHandle` — wait for another coroutine, by handle, to finish. |
| `yield_value(Coroutine&&)` | Handles `co_yield otherObj.otherCoroutine(...)`. Starts the child coroutine on the active `CoroutineRunner` and waits for it to finish. Asserts if no `CoroutineRunner` is active. Defined in `CoroutineRunner.h`, since it requires `CoroutineRunner` to be a complete type. |
| `yield_value<F>(F condition)` | Handles `co_yield condition` for any `condition` invocable as `bool()`. Waits until `condition()` returns `true`. This is the extension point `waitForSeconds`/`waitUntil`/`waitWhile`/`waitFor` (see `standard/WaitConditions.h`) are built on; a custom wait condition can be written the same way, without modifying this file. |
## Notes
`Coroutine` is not copyable. A non-static member coroutine captures `this`; if the owning object is destroyed while the coroutine is still suspended, resuming it later is undefined behavior. See the architecture document for how to guard against this.
This header does not include or depend on `standard/WaitConditions.h`. The generic `yield_value<F>` overload above is what allows `waitForSeconds` and similar helpers to exist without this file knowing about them — see the architecture document for the reasoning.
+35
View File
@@ -0,0 +1,35 @@
# CoroutineHandle
`#include <acrux/coroutines/CoroutineHandle.h>`
## Description
`CoroutineHandle` identifies a coroutine registered with a `CoroutineRunner`. It is a small, trivially-copyable value (a single `uint64_t` internally) rather than a raw integer, so it cannot be confused with an unrelated value, and supports `handle.stop()`/`handle.isRunning()` directly.
## Members
| Member | Description |
|---|---|
| `operator bool() const` | `explicit`. `true` unless this handle equals `INVALID_COROUTINE_HANDLE`. Does not check whether the coroutine is still running; use `isRunning()` for that. |
| `isRunning() const` | Equivalent to calling `isCoroutineRunning()` on the active `CoroutineRunner`. Asserts if no `CoroutineRunner` is active. |
| `stop()` | Stops the coroutine and resets this handle to `INVALID_COROUTINE_HANDLE`. Safe to call on an already-invalid handle. Asserts if no `CoroutineRunner` is active. |
| `operator==` / `operator!=` | Compares the underlying id. |
| `id() const` | The handle's raw packed id, exposed so `CoroutineHandle` can be used as a key in `std::unordered_map`/`std::unordered_set`. Not meant to be decoded into its index/generation components directly. |
## `INVALID_COROUTINE_HANDLE`
An `inline constexpr CoroutineHandle` representing "no coroutine." Both `CoroutineHandle{}` and `CoroutineHandle()` produce this value.
## `std::hash<CoroutineHandle>`
A specialization is provided, based on `id()`, so `CoroutineHandle` can be used directly as a key in an unordered container:
```cpp
std::unordered_map<CoroutineHandle, EntityId> owners;
```
This is the mechanism a `CoroutineRunner` subclass is expected to use for tracking its own per-coroutine data — see `CoroutineRunner.md` and `example.md`.
## Notes
`CoroutineHandle` can only be constructed from a raw id by `CoroutineRunner`, which is declared a friend for this purpose; application code cannot construct an arbitrary handle. `isRunning()`/`stop()` are declared here but defined in `CoroutineRunner.h`, since they require `CoroutineRunner` to be a complete type.
+49
View File
@@ -0,0 +1,49 @@
# CoroutineRunner
`#include <acrux/coroutines/CoroutineRunner.h>`
## Description
`CoroutineRunner` owns and advances every coroutine registered with it, using a generational slot map. See the architecture document for the reasoning behind its internal data structures. `CoroutineRunner` is not copyable, and is not `final` — it is designed to be derived from; see "Extending CoroutineRunner" below.
## Members
| Member | Description |
|---|---|
| `CoroutineRunner()` | Becomes the active runner. Only one `CoroutineRunner` is expected to be active at a time; constructing a second while one already exists replaces which one is considered active, without restoring the first when the second is destroyed. |
| `startCoroutine(task)` | Registers `task` and begins advancing it. Reuses a free slot immediately if one is available; otherwise, the task is staged for insertion at the start of the next `update()` call. Returns `INVALID_COROUTINE_HANDLE` if `task` does not hold a valid coroutine frame. |
| `startSingleCoroutine(slotHandle, task)` | Stops whatever coroutine `slotHandle` currently refers to, if any, then starts `task` and stores the new handle in `slotHandle`. Useful for ensuring at most one instance of a given coroutine is running at a time. |
| `isCoroutineRunning(handle) const` | Whether `handle` refers to a coroutine that is still running. |
| `stopCoroutine(handle)` | Stops the coroutine referred to by `handle`, if still running, and resets `handle` to `INVALID_COROUTINE_HANDLE`. Safe to call on an already-invalid or already-stopped handle. |
| `update()` | Advances every running coroutine by one tick: inserts staged coroutines, resumes any whose wait condition is satisfied, then reclaims finished slots. Intended to be called once per frame. |
| `getActiveCount() const` | The number of coroutines currently tracked, including any still staged for insertion. |
## The active runner
`CoroutineRunner` maintains a static pointer to the most recently constructed instance, cleared by its destructor if it was still the active one. This pointer backs the free functions below, as well as `CoroutineHandle::isRunning()`/`stop()`.
## Free functions
| Function | Description |
|---|---|
| `startCoroutine(task)` | Starts `task` on the active `CoroutineRunner`, without suspending the caller. May be called from `main()`, an ordinary function, or from within another running coroutine. Asserts if no `CoroutineRunner` is active. |
| `stopCoroutine(handle)` | Equivalent to `handle.stop()`. |
## Extending CoroutineRunner
`CoroutineRunner` has a virtual destructor and two protected virtual hooks, provided specifically to support subclassing:
| Member | Description |
|---|---|
| `onCoroutineStarted(handle, parent)` | `protected virtual`. Called once a coroutine becomes live in the slot map — immediately, if `startCoroutine()` reused a free slot, or at the start of the next `update()` otherwise. `parent` is whichever coroutine was actually resuming at the moment `handle` was started (via `co_yield childTask()`, or a direct `startCoroutine()` call from inside a coroutine's body), or `INVALID_COROUTINE_HANDLE` if it was started from outside any coroutine. Not called for a task that is stopped before ever becoming live. Default implementation does nothing. |
| `onCoroutineFinished(handle)` | `protected virtual`. Called when a coroutine's slot is reclaimed, whether it finished naturally (in `update()`) or was stopped explicitly (`stopCoroutine()`). Only called for a handle that previously triggered `onCoroutineStarted()`. Default implementation does nothing. |
A derived class typically adds its own `startCoroutine` overload, accepting whatever extra data it wants to associate with a coroutine, and stores that data itself — keyed by `CoroutineHandle`, which is hashable (see `CoroutineHandle.md`). `onCoroutineFinished()` is then overridden to remove the corresponding entry, keeping the side table from accumulating stale data. `CoroutineRunner` itself has no knowledge of, or opinion on, what the extra data represents.
The `parent` argument to `onCoroutineStarted()` makes it straightforward for a coroutine's children to inherit the same data automatically: a subclass looks `parent` up in its own side table, and if found, copies the association across to the new `handle` before returning. This applies equally to a child started with `co_yield childTask()` and to one started with a plain `startCoroutine()` call made directly from inside a coroutine's body — both are visible as `parent` being that coroutine's own handle. An explicit `startCoroutine(task, extraData)` call made from outside any coroutine still overrides whatever `onCoroutineStarted()` would have inherited, since it runs afterward.
Declaring a new `startCoroutine` overload in a derived class hides the base class's `startCoroutine(Coroutine)` overload, per ordinary C++ overload resolution rules. A `using CoroutineRunner::startCoroutine;` declaration in the derived class restores it. See `example.md` for a complete example.
## Notes
`update()` processes coroutines in three phases — staged insertions, then resumption, then reclaiming finished slots — specifically so that starting new coroutines from within a coroutine resumed during the same call never triggers a reallocation of the active slot storage mid-iteration.
+29
View File
@@ -0,0 +1,29 @@
# Predicate
`#include <acrux/coroutines/Predicate.h>`
## Description
`Predicate` is a small-buffer-optimized, type-erased `bool()` callable. Any callable that fits within its inline buffer is stored directly, without a heap allocation — covering the common case of a lambda capturing `this` and a small number of local values.
It is used directly by `Coroutine::promise_type` to store whatever condition the currently suspended coroutine is waiting on (see `Coroutine.md`), and by the helpers in `standard/WaitConditions.h`. Unlike those helpers, `Predicate` is part of the core module — the scheduler's ability to wait on an arbitrary condition depends on it.
```cpp
Predicate p1 = [this]{ return this->targetInRange; };
Predicate p2 = [hp = enemy->hp]{ return hp <= 0; };
```
## Members
| Member | Description |
|---|---|
| `Predicate()` | Constructs an empty predicate. `operator bool()` returns `false`; calling `operator()` asserts. |
| `Predicate(f)` | Constructs from any callable `f` convertible to `bool()`. Requires `sizeof(std::decay_t<F>) <= 32` bytes and `alignof(std::decay_t<F>) <= alignof(std::max_align_t)`; both are enforced with `static_assert`. A lambda exceeding this size should capture by pointer or reference instead of by value. |
| `operator()() const` | Invokes the stored callable. Asserts if the predicate is empty. |
| `operator bool() const` | `explicit`. `true` if a callable is currently stored. |
| Move construction / assignment | Transfers the stored callable. |
| Copy construction / assignment | Deleted. `Predicate` is move-only. |
## Notes
The 32-byte inline buffer size is fixed at compile time and is not configurable per instance. Storage larger than this is not supported; there is no fallback to heap allocation. `Predicate` is implemented with a small, hand-written function-pointer table rather than `std::function`, specifically to guarantee no heap allocation for anything that fits.
+37
View File
@@ -0,0 +1,37 @@
# standard/WaitConditions.h
`#include <acrux/coroutines/standard/WaitConditions.h>` (namespace `acrux::coroutines::standard`)
## Description
Provides `waitForSeconds`, `waitUntil`, `waitWhile`, and `waitFor` — the built-in helpers for describing what a coroutine should wait on. None of these are part of the core module; each is an ordinary function returning a small callable object that satisfies `Coroutine`'s generic "co_yield anything invocable as `bool()`" contract. See `architecture.md` for what this separation means and how to write an equivalent helper of your own.
## Functions
| Function | Description |
|---|---|
| `waitForSeconds(seconds)` | Resumes once `seconds` of real time have elapsed, measured from `std::chrono::steady_clock`. The clock starts on the condition's first evaluation — the first time `CoroutineRunner::update()` checks it after the coroutine suspends — not when `waitForSeconds()` itself was called. |
| `waitFor(handle)` | Resumes once the coroutine referred to by `handle` has finished. Implemented through `CoroutineHandle::isRunning()`, so it behaves the same as `co_yield handle` directly; provided so that waiting on an already-started handle reads consistently with the other functions here. |
| `waitUntil(predicate)` | Resumes once `predicate()` returns `true`. `predicate` must be convertible to `bool()`; lambdas capturing `this` or other coroutine-local values are supported. This is a thin pass-through — `co_yield predicate` directly would behave identically — provided so a call site reads "waitUntil". |
| `waitWhile(predicate)` | Resumes once `predicate()` returns `false`. Implemented as a small wrapper that negates `predicate()`. |
## Notes
`waitWhile`'s negating wrapper adds a small amount of size on top of the predicate it wraps, which counts against `Predicate`'s 32-byte inline storage limit (see `Predicate.md`). In practice this is only relevant for predicates that were already close to that limit.
None of the functions above are referenced by `Coroutine.h` or `CoroutineRunner.h`. A custom wait condition — for example, waiting until a character's health drops below a threshold — is written the same way, as a small callable with `bool operator()() const`, optionally wrapped in a helper function for a readable call site:
```cpp
class UntilHealthBelow
{
public:
UntilHealthBelow(const Character& c, int threshold) : m_character(c), m_threshold(threshold) {}
bool operator()() const { return m_character.hp < m_threshold; }
private:
const Character& m_character;
int m_threshold;
};
co_yield UntilHealthBelow(enemy, 10);
```
+85
View File
@@ -0,0 +1,85 @@
# Coroutines
The Coroutines module provides a Unity-style coroutine system built on C++20's `co_await`/`co_yield`. A function returning `Coroutine` may suspend itself with `co_yield` and be resumed later — after a number of frames, after a delay, or once a condition becomes true — by a central `CoroutineRunner`.
## Components
| Location | Role |
|---|---|
| `Coroutine.h` | The return type of a coroutine function, along with its `promise_type`. Required for `Coroutine foo() { co_yield ...; }` to compile. |
| `CoroutineHandle.h` | A lightweight, opaque value identifying a running coroutine. |
| `CoroutineRunner.h` | Owns and advances every running coroutine. |
| `Predicate.h` | A small-buffer-optimized, type-erased `bool()` callable, used internally by the scheduler and by the wait conditions below. |
| `standard/WaitConditions.h` | `waitForSeconds`, `waitUntil`, `waitWhile`, `waitFor` — the built-in helpers for describing what to wait on. |
## Core versus standard
`Coroutine.h` and `CoroutineRunner.h` form the core of the module. They know how to suspend and resume a coroutine, and how to wait on exactly three things:
- the next frame (`co_yield nullptr`)
- another coroutine finishing (`co_yield someHandle`, or `co_yield someOtherCoroutine()` to start and wait on a child)
- **any callable that returns `bool`** (`co_yield someCondition`)
That last case is deliberately open-ended. The core has no concept of "waiting for seconds" or "waiting for a condition" as distinct kinds of instruction — it only knows how to call something and check whether it returned `true`.
`standard/WaitConditions.h` is built entirely on top of that public interface. `waitForSeconds`, `waitUntil`, `waitWhile`, and `waitFor` are ordinary functions that return small callable objects satisfying the core's `bool()` contract. Neither `Coroutine.h` nor `CoroutineRunner.h` includes this header, or knows it exists. It is kept in its own `standard/` directory specifically to make this separation visible: the core scheduler does not depend on it, and it can be replaced or extended without touching the scheduler at all.
Writing a custom wait condition follows the same one-line pattern used internally:
```cpp
class UntilHealthBelow
{
public:
UntilHealthBelow(const Character& c, int threshold) : m_character(c), m_threshold(threshold) {}
bool operator()() const { return m_character.hp < m_threshold; }
private:
const Character& m_character;
int m_threshold;
};
co_yield UntilHealthBelow(enemy, 10);
```
No changes to the core module, and no registration step, are required.
## Execution model
A function returning `Coroutine` behaves like an ordinary function at the call site — it may take arguments, and may be a free function, a static function, or a non-static member function. Calling it does not run its body. It constructs a suspended coroutine frame, since `initial_suspend()` always suspends. The body does not begin executing until the coroutine is registered with a `CoroutineRunner`, either directly through `startCoroutine()` or implicitly through `co_yield childTask`, and `CoroutineRunner::update()` is called — typically once per frame.
## CoroutineRunner internals
`CoroutineRunner` stores coroutines in a generational slot map rather than a plain list:
- A `CoroutineHandle` packs a 32-bit slot index and a 32-bit generation counter into a single `uint64_t`. Looking up a handle is a direct index into a vector, and the generation check rejects a handle whose slot has since been reused by a different coroutine.
- Freed slots are placed on a free list and reused before the underlying storage grows.
- Growing the underlying storage is deferred to the start of the next `update()` call. During `update()`, references into the slot array remain valid while coroutines are resumed, and a resumed coroutine may start new coroutines in the same tick. Reallocating the vector during that loop would invalidate those references, so newly allocated slots are staged and only inserted at the beginning of the following `update()`. Reusing an existing free slot has no such restriction and happens immediately.
- A scratch buffer used to track finished slots each tick is retained and cleared between calls, rather than being reallocated.
- The active coroutine count is tracked incrementally rather than recomputed from the slot array.
## The active runner
`CoroutineRunner` maintains a static pointer to the most recently constructed instance. Only one `CoroutineRunner` is expected to be active at a time. This pointer backs the free functions `startCoroutine()`/`stopCoroutine()`, as well as `CoroutineHandle::isRunning()`/`stop()`, allowing them to be called from anywhere — including from within another running coroutine — without passing a `CoroutineRunner&` explicitly. Each of these asserts if no `CoroutineRunner` currently exists.
## Extending CoroutineRunner
`CoroutineRunner` is not `final`, specifically so it can be derived from. This is the intended way to attach extra data to a coroutine — for example, tagging each coroutine in a game engine with the entity it belongs to.
A derived class adds its own `startCoroutine` overload accepting whatever extra data it needs, and tracks that data itself, keyed by `CoroutineHandle`. `CoroutineHandle` is hashable (`std::hash<CoroutineHandle>` is provided) specifically to support this. Two protected virtual hooks keep that side table in sync automatically:
| Hook | Called |
|---|---|
| `onCoroutineStarted(handle, parent)` | Once a coroutine becomes live in the slot map. `parent` is whichever coroutine was actually resuming at that moment, or `INVALID_COROUTINE_HANDLE` if none was. |
| `onCoroutineFinished(handle)` | Once a coroutine's slot is reclaimed, whether it finished naturally or was stopped explicitly. |
The base class has no opinion on what the extra data represents — that is entirely up to the derived class. `parent` is what allows a coroutine's children to inherit that data automatically: a subclass looks `parent` up in its own side table inside `onCoroutineStarted()`, and copies the association across if found. This applies whether the child was started with `co_yield childTask()` or with a plain `startCoroutine()` call made directly from within a coroutine's body — both cases set `parent` to that coroutine's own handle. See `example.md` for a complete example.
One consequence of ordinary C++ overload resolution to be aware of: declaring a new `startCoroutine` overload in a derived class hides the base class's `startCoroutine(Coroutine)` overload, rather than adding to it. A `using CoroutineRunner::startCoroutine;` declaration in the derived class restores access to it.
## Header organization
`CoroutineHandle::isRunning()`/`stop()` and `Coroutine::promise_type::yield_value(Coroutine&&)` are declared in `CoroutineHandle.h`/`Coroutine.h`, but defined in `CoroutineRunner.h`. Their implementations require `CoroutineRunner` to be a complete type, and `CoroutineRunner.h` is the header that includes both `CoroutineHandle.h` and `Coroutine.h` — including it the other way around would create a circular include.
## Lifetime
A non-static member coroutine captures `this`, in the same way any other closure would. If the owning object is destroyed while the coroutine is still suspended, resuming it afterward accesses freed memory. If there is any possibility of this happening, the coroutine should be stopped explicitly — through `CoroutineRunner::stopCoroutine()` or `handle.stop()` — in the owning object's destructor.
+158
View File
@@ -0,0 +1,158 @@
# Coroutines — Examples
Based on `test/coroutines/main.cpp`.
```cpp
#include <acrux/coroutines/Coroutine.h>
#include <acrux/coroutines/CoroutineRunner.h>
#include <acrux/coroutines/standard/WaitConditions.h>
using namespace acrux::coroutines;
using namespace acrux::coroutines::standard;
struct Character
{
int hp = 100;
Coroutine attack(Character& target)
{
print("Attacking...\n");
co_yield waitForSeconds(1.0f); // waits one real-time second
target.hp -= 20;
co_yield nullptr; // waits exactly one frame
}
Coroutine battleSequence(Character& enemy)
{
co_yield attack(enemy); // starts a child Coroutine and waits
// for it to finish
co_yield waitUntil([&]{ return enemy.hp <= 0; }); // waits on a predicate
}
};
int main()
{
CoroutineRunner runner; // becomes the active runner
Character hero, enemy;
CoroutineHandle battle = startCoroutine(hero.battleSequence(enemy));
while (battle.isRunning())
{
runner.update(); // called once per frame/tick
}
}
```
## Yield expressions
```cpp
co_yield nullptr; // resume next frame
co_yield waitForSeconds(2.5f); // resume after a real-time delay
co_yield waitUntil([this]{ return ready; }); // resume once the predicate is true
co_yield waitWhile([this]{ return loading; }); // resume once the predicate is false
co_yield someOtherCoroutine(); // start a coroutine and wait for it to finish
co_yield waitFor(handleStartedEarlier); // wait for a coroutine already in progress
```
`waitForSeconds`/`waitUntil`/`waitWhile`/`waitFor` are declared in `standard/WaitConditions.h`, in the `acrux::coroutines::standard` namespace. They are not part of the core module — see `architecture.md` for what that means in practice, and how to write an equivalent helper of your own.
## Starting a coroutine without waiting on it immediately
```cpp
CoroutineHandle bg = startCoroutine(controller.fadeOut(delta)); // starts immediately
// ... other work, possibly across several yields ...
co_yield waitFor(bg); // waits on it later, if needed
```
## Stopping a coroutine
```cpp
CoroutineHandle h = startCoroutine(enemy.patrol());
...
stopCoroutine(h); // h becomes INVALID_COROUTINE_HANDLE; safe to call again
// equivalently:
h.stop();
```
## Attaching extra data to a coroutine
`CoroutineRunner` can be derived from to associate extra data with each coroutine it starts — for example, which entity a coroutine belongs to in a game engine. `CoroutineHandle` is hashable, so it can be used directly as a key. Children started by a coroutine — whether through `co_yield childTask()` or a direct `startCoroutine()` call from inside its body — can inherit the same data automatically, using the `parent` argument to `onCoroutineStarted()`.
```cpp
#include <unordered_map>
using EntityId = int;
class GameCoroutineRunner : public CoroutineRunner
{
public:
// Without this, declaring startCoroutine(task, owner) below would hide
// the base class's startCoroutine(task) entirely.
using CoroutineRunner::startCoroutine;
CoroutineHandle startCoroutine(Coroutine task, EntityId owner)
{
CoroutineHandle handle = CoroutineRunner::startCoroutine(std::move(task));
if (handle) m_owners[handle] = owner;
return handle;
}
EntityId ownerOf(CoroutineHandle handle) const
{
auto it = m_owners.find(handle);
return it != m_owners.end() ? it->second : -1;
}
protected:
// If `parent` is a coroutine we're already tracking an owner for,
// inherit it -- this is what makes a coroutine's children automatically
// belong to the same entity, without either call site passing an
// owner explicitly. The explicit-owner startCoroutine() overload above
// still runs its own assignment afterward, so an explicit owner always
// takes priority over an inherited one.
void onCoroutineStarted(CoroutineHandle handle, CoroutineHandle parent) override
{
auto it = m_owners.find(parent);
if (it != m_owners.end())
{
m_owners[handle] = it->second;
}
}
// Called whenever a tracked coroutine finishes, however it finished --
// keeps m_owners from accumulating stale entries.
void onCoroutineFinished(CoroutineHandle handle) override
{
m_owners.erase(handle);
}
private:
std::unordered_map<CoroutineHandle, EntityId> m_owners;
};
```
```cpp
GameCoroutineRunner runner;
CoroutineHandle h = runner.startCoroutine(enemy.patrol(), enemy.id());
// ...
EntityId owner = runner.ownerOf(h);
```
With this in place, a coroutine spawning its own sub-coroutines needs no special handling at all — they simply belong to the same entity:
```cpp
Coroutine Enemy::patrol()
{
// co_yield of a child: inherits this coroutine's owner automatically.
co_yield lookAround();
// a fire-and-forget child, started directly: also inherits it.
startCoroutine(playFootstepSounds());
co_yield waitForSeconds(1.0f);
}
```
This pattern is not limited to a single piece of data. A derived class can store anything it needs — a full struct, a pointer back to a subsystem, several parallel maps — since `CoroutineRunner` places no constraints on what "extra data" means, or on how `onCoroutineStarted()` chooses to propagate it.
+132
View File
@@ -0,0 +1,132 @@
#pragma once
// ============================================================================
// Coroutine.hpp — the coroutine return type (the "Task") and its
// promise_type.
//
// Nothing here needs to change for arguments or member coroutines: in C++20
// the promise_type used for a coroutine is selected purely from its RETURN
// TYPE (Coroutine), never from whether the function is free, static, or a
// non-static member, and never from its parameter list. So:
//
// Coroutine foo(int a, float b); // args: just works
// Coroutine Enemy::attackSequence(int n); // member: just works
//
// The one thing this library genuinely cannot do for you: a non-static
// member coroutine captures `this` in its coroutine frame like any other
// closure. If the owning object is destroyed while the coroutine is still
// suspended, resuming it is a use-after-free. Stop a member coroutine
// (CoroutineRunner::stopCoroutine) in the owner's destructor if there's any
// chance it's still running.
//
// What a coroutine can co_yield is intentionally small and closed here:
// - nullptr -- wait one frame
// - a CoroutineHandle -- wait for another coroutine to finish
// - a child Coroutine -- start it and wait for it to finish (defined in
// CoroutineRunner.hpp; needs CoroutineRunner)
// - anything invocable as bool() -- wait until it returns true
//
// That last case is deliberately generic rather than a fixed enum of "kinds
// of waiting." waitForSeconds()/waitUntil()/waitWhile()/waitFor() (see
// coroutines/standard/WaitConditions.h) are just ordinary functions that
// return small callable objects satisfying this same bool() contract --
// this header has no idea they exist, and doesn't need to. Anyone is free
// to write their own wait conditions the same way, without touching this
// file or CoroutineRunner.hpp.
// ============================================================================
#include <coroutine>
#include <type_traits>
#include <utility>
#include <acrux/coroutines/CoroutineHandle.h>
#include <acrux/coroutines/Predicate.h>
namespace acrux::coroutines
{
struct Coroutine
{
struct promise_type
{
// Note: get_return_object, initial_suspend, final_suspend,
// unhandled_exception, return_void, and yield_value are compiler
// hook names mandated by the coroutine machinery and cannot be
// renamed to match the library's camelCase convention.
enum class WaitKind : uint8_t
{
NextFrame,
Condition,
NestedHandle
};
WaitKind m_waitKind = WaitKind::NextFrame;
CoroutineHandle m_nestedHandle = INVALID_COROUTINE_HANDLE;
Predicate m_condition;
CoroutineHandle m_assignedId = INVALID_COROUTINE_HANDLE;
Coroutine get_return_object()
{
return Coroutine{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { std::terminate(); }
void return_void() noexcept {}
// co_yield nullptr -- wait exactly one frame.
std::suspend_always yield_value(std::nullptr_t) noexcept
{
m_waitKind = WaitKind::NextFrame;
return {};
}
// co_yield someHandle -- wait for another coroutine to finish.
std::suspend_always yield_value(CoroutineHandle handle) noexcept
{
m_waitKind = WaitKind::NestedHandle;
m_nestedHandle = handle;
return {};
}
// co_yield myObject.myFunc(...) -- defined in CoroutineRunner.hpp,
// see the file-level comment above for why.
std::suspend_always yield_value(Coroutine&& childTask);
// co_yield <anything invocable as bool()> -- wait until it
// returns true. This is the extension point every wait-condition
// helper (waitForSeconds, waitUntil, waitFor, ...) is built on;
// see coroutines/standard/WaitConditions.h for the reference
// implementations, or write your own the same way.
template <typename F, typename = std::enable_if_t<std::is_invocable_r_v<bool, const F&>>>
std::suspend_always yield_value(F condition)
{
m_waitKind = WaitKind::Condition;
m_condition = Predicate(std::move(condition));
return {};
}
};
std::coroutine_handle<promise_type> m_handle;
Coroutine() noexcept : m_handle(nullptr) {}
explicit Coroutine(std::coroutine_handle<promise_type> h) noexcept : m_handle(h) {}
~Coroutine() { if (m_handle) m_handle.destroy(); }
Coroutine(const Coroutine&) = delete;
Coroutine& operator=(const Coroutine&) = delete;
Coroutine(Coroutine&& other) noexcept : m_handle(other.m_handle) { other.m_handle = nullptr; }
Coroutine& operator=(Coroutine&& other) noexcept
{
if (this != &other)
{
if (m_handle) m_handle.destroy();
m_handle = other.m_handle;
other.m_handle = nullptr;
}
return *this;
}
};
}
+113
View File
@@ -0,0 +1,113 @@
#pragma once
// ============================================================================
// CoroutineHandle.h — an opaque, cheap-to-copy VALUE TYPE identifying a
// coroutine registered with a CoroutineRunner.
//
// Internally it's still just a packed 32-bit slot index + 32-bit generation
// counter (so CoroutineRunner can find/validate it in O(1), no ABA bugs),
// but it's wrapped in a real type instead of a bare uint64_t so that:
//
// - you can call handle.isRunning() / handle.stop() directly on it,
// instead of always having to go through CoroutineRunner or a free
// function that takes it by reference;
// - handle.stop() always correctly invalidates the handle it was called
// on — there's no reference to forget to take, and no way to
// accidentally stop a copy and leave the original looking "still
// running";
// - `if (handle)` reads naturally (explicit operator bool: valid <=> not
// INVALID_COROUTINE_HANDLE);
// - it can't accidentally be compared against / mixed up with an
// unrelated uint64_t (e.g. a frame counter or an entity id).
//
// It's still a small trivially-copyable value (one uint64_t) — pass it
// around, store it, return it by value freely.
// ============================================================================
#include <cstdint>
#include <functional>
namespace acrux::coroutines
{
class CoroutineRunner; // needs friend access to construct/decode handles
class CoroutineHandle
{
public:
CoroutineHandle() noexcept = default;
// Explicit so a handle can't silently decay into an integer/bool in
// unrelated contexts — only `if (handle)` / `while (!handle)` etc.
explicit operator bool() const noexcept { return m_id != kInvalidId; }
friend bool operator==(const CoroutineHandle& a, const CoroutineHandle& b) noexcept { return a.m_id == b.m_id; }
friend bool operator!=(const CoroutineHandle& a, const CoroutineHandle& b) noexcept { return !(a == b); }
// Equivalent to CoroutineRunner::isCoroutineRunning(*this).
// Declared here, defined in CoroutineRunner.h once CoroutineRunner
// is a complete type.
bool isRunning() const;
// Equivalent to CoroutineRunner::stopCoroutine(*this) — stops the
// coroutine AND resets *this to INVALID_COROUTINE_HANDLE. Declared
// here, defined in CoroutineRunner.h.
void stop();
// The handle's raw packed id (index + generation), exposed so a
// CoroutineHandle can be used as a key in a hash map/set -- e.g. by a
// CoroutineRunner subclass tracking its own per-coroutine data (see
// CoroutineRunner.h). Not meant to be decoded; treat it as opaque.
uint64_t id() const noexcept { return m_id; }
private:
friend class CoroutineRunner;
static constexpr uint64_t kInvalidId = 0;
explicit CoroutineHandle(uint64_t id) noexcept : m_id(id) {}
uint64_t raw() const noexcept { return m_id; }
uint64_t m_id = kInvalidId;
};
inline constexpr CoroutineHandle INVALID_COROUTINE_HANDLE{};
}
// Implementation detail: only CoroutineRunner needs these, and only to
// build/decode the raw id packed inside a CoroutineHandle. Kept out of
// acrux::coroutines so it can't leak into the public API by accident.
namespace detail::acrux::coroutines
{
constexpr uint32_t IndexBits = 32;
// index+1 is stored so that (index == 0, generation == 0) never collides
// with an all-zero (invalid) id.
inline uint64_t packHandle(uint32_t index, uint32_t generation) noexcept
{
return (static_cast<uint64_t>(generation) << IndexBits) |
(static_cast<uint64_t>(index) + 1u);
}
inline uint32_t unpackIndex(uint64_t raw) noexcept
{
return static_cast<uint32_t>(raw & 0xFFFFFFFFull) - 1u;
}
inline uint32_t unpackGeneration(uint64_t raw) noexcept
{
return static_cast<uint32_t>(raw >> IndexBits);
}
}
// Lets CoroutineHandle be used directly as a key in std::unordered_map /
// std::unordered_set -- intended for a CoroutineRunner subclass that keeps
// its own side table of per-coroutine data (see CoroutineRunner.h).
template <>
struct std::hash<acrux::coroutines::CoroutineHandle>
{
size_t operator()(const acrux::coroutines::CoroutineHandle& handle) const noexcept
{
return std::hash<uint64_t>{}(handle.id());
}
};
+399
View File
@@ -0,0 +1,399 @@
#pragma once
// ============================================================================
// CoroutineRunner.hpp — the engine: a generational slot map of running
// coroutines.
//
// - CoroutineHandle packs a 32-bit slot index + a 32-bit generation.
// - startCoroutine / stopCoroutine / isCoroutineRunning are O(1) in the
// common case (index straight into a vector, generation check to reject
// stale/reused handles — no ABA bugs).
// - Freed slots go on a free list and get reused before growing.
// - update() reuses a persistent scratch buffer for finished slots instead
// of allocating one every frame.
// - getActiveCount() is tracked incrementally (O(1)) instead of
// re-counting.
//
// Growing the backing store (i.e. adding a coroutine when there is no free
// slot to reuse) has to be deferred to the start of the next update(),
// because update() holds a reference into the slot array while it resumes
// coroutines, and those coroutines are allowed to start new ones. A
// std::vector resize while that reference is alive would dangle it. Reusing
// an already-existing free slot has no such restriction (no reallocation),
// so that path is handled immediately instead of staged.
//
// EXTENDING THIS: CoroutineRunner is not `final` specifically so it can be
// derived from. A subclass can add its own overload(s) of startCoroutine
// that accept extra data -- an EntityHandle in a game engine, or whatever
// your program needs -- and track that data itself, keyed by CoroutineHandle
// (which is hashable; see CoroutineHandle::id()/std::hash specialization in
// CoroutineHandle.h). onCoroutineStarted(handle, parent)/onCoroutineFinished(handle)
// are called at the right points to keep that side table in sync
// automatically -- `parent` lets a subclass propagate its data from a
// coroutine to any child it starts (via `co_yield childTask()` or a direct
// startCoroutine() call from within its body). This base class has no
// opinion on what "extra data" means -- that's entirely up to the derived
// class. See coroutines/example.md for a worked example.
// ============================================================================
#include <vector>
#include <utility>
#include <cstdint>
#include <cstddef>
#include <cassert>
#include <acrux/coroutines/CoroutineHandle.h>
#include <acrux/coroutines/Coroutine.h>
namespace acrux::coroutines
{
class CoroutineRunner
{
private:
struct Slot
{
Coroutine m_task;
uint32_t m_generation = 0;
bool m_alive = false;
};
std::vector<Slot> m_slots;
std::vector<uint32_t> m_freeIndices;
struct PendingTask
{
uint32_t m_index;
Coroutine m_task;
CoroutineHandle m_parent; // whichever coroutine was resuming when this was started, or invalid
};
// Tasks that need a brand-new slot (no free slot to reuse) get staged
// here and only actually inserted into `m_slots` at the top of update(),
// because that's the only point at which growing `m_slots` is safe.
std::vector<PendingTask> m_pendingTasks;
uint32_t m_logicalSize = 0; // m_slots.size() + slots reserved for pending growth
std::vector<uint32_t> m_deadIndices; // scratch buffer, reused every frame
size_t m_liveCount = 0;
// Set for the duration of a single h.resume() call in update() (see
// step 2 below), otherwise INVALID_COROUTINE_HANDLE. Lets
// startCoroutine() record which coroutine, if any, was actually
// running when it was called -- whether via `co_yield childTask()`
// or a direct startCoroutine() call made from inside a coroutine's
// body -- so onCoroutineStarted() can tell a subclass "this one was
// started by that one" and propagate whatever it needs to.
CoroutineHandle m_currentlyResuming = INVALID_COROUTINE_HANDLE;
bool shouldResume(Coroutine::promise_type& promise) const
{
switch (promise.m_waitKind)
{
case Coroutine::promise_type::WaitKind::NextFrame:
return true;
case Coroutine::promise_type::WaitKind::Condition:
return promise.m_condition();
case Coroutine::promise_type::WaitKind::NestedHandle:
return !isCoroutineRunning(promise.m_nestedHandle);
}
return true;
}
protected:
// Called once a coroutine becomes live in the slot map -- either
// immediately (startCoroutine() reusing a free slot) or at the start
// of the next update() (a newly-grown slot). Not called for a task
// that never becomes live (e.g. stopped again before its first
// update()).
//
// `parent` is whichever coroutine was actually resuming at the
// moment `handle` was started -- set whether it was started via
// `co_yield childTask()` or a plain `startCoroutine()` call made
// from inside a coroutine's body -- or INVALID_COROUTINE_HANDLE if
// it was started from outside any coroutine (e.g. from main()).
// A subclass can use this to propagate its own per-coroutine data
// from parent to child automatically; see coroutines/example.md.
//
// Override to hook "a coroutine started" in a subclass; the default
// implementation does nothing.
virtual void onCoroutineStarted(CoroutineHandle handle, CoroutineHandle parent) { (void)handle; (void)parent; }
// Called when a coroutine's slot is reclaimed, whether it finished
// naturally (in update()) or was stopped explicitly
// (stopCoroutine()). Only called for a handle that previously
// triggered onCoroutineStarted(). Override to clean up any
// per-coroutine data a subclass is tracking; the default
// implementation does nothing.
virtual void onCoroutineFinished(CoroutineHandle handle) { (void)handle; }
public:
static CoroutineRunner* m_instance;
CoroutineRunner() { m_instance = this; }
virtual ~CoroutineRunner() { if (m_instance == this) m_instance = nullptr; }
CoroutineRunner(const CoroutineRunner&) = delete;
CoroutineRunner& operator=(const CoroutineRunner&) = delete;
// O(1): reuses a free slot in place if one exists (no reallocation, safe
// even while update() is mid-iteration). Only falls back to the staging
// queue when the backing store actually needs to grow.
CoroutineHandle startCoroutine(Coroutine task)
{
if (!task.m_handle) return INVALID_COROUTINE_HANDLE;
if (!m_freeIndices.empty())
{
uint32_t index = m_freeIndices.back();
m_freeIndices.pop_back();
CoroutineHandle newHandle(detail::acrux::coroutines::packHandle(index, m_slots[index].m_generation));
task.m_handle.promise().m_assignedId = newHandle;
m_slots[index].m_task = std::move(task);
m_slots[index].m_alive = true;
++m_liveCount;
onCoroutineStarted(newHandle, m_currentlyResuming);
return newHandle;
}
uint32_t index = m_logicalSize++;
CoroutineHandle newHandle(detail::acrux::coroutines::packHandle(index, 0));
task.m_handle.promise().m_assignedId = newHandle;
m_pendingTasks.push_back(PendingTask{ index, std::move(task), m_currentlyResuming });
return newHandle;
}
void startSingleCoroutine(CoroutineHandle& slotHandle, Coroutine task)
{
stopCoroutine(slotHandle);
slotHandle = startCoroutine(std::move(task));
}
// O(1): decode the slot index directly; generation check rejects stale
// handles from a slot that has since been reused (no ABA bugs). Falls
// back to a short linear scan only for handles started earlier this
// same frame and not yet drained into `m_slots`.
bool isCoroutineRunning(CoroutineHandle handle) const
{
if (handle == INVALID_COROUTINE_HANDLE) return false;
uint32_t index = detail::acrux::coroutines::unpackIndex(handle.raw());
uint32_t generation = detail::acrux::coroutines::unpackGeneration(handle.raw());
if (index < m_slots.size() && m_slots[index].m_alive && m_slots[index].m_generation == generation)
{
return true;
}
for (const auto& pending : m_pendingTasks)
{
if (pending.m_index == index && pending.m_task.m_handle && pending.m_task.m_handle.promise().m_assignedId == handle)
{
return true;
}
}
return false;
}
// O(1) in the common case; same fallback as isCoroutineRunning for
// handles stopped in the same frame they were started.
void stopCoroutine(CoroutineHandle& handle)
{
if (handle == INVALID_COROUTINE_HANDLE) return;
uint32_t index = detail::acrux::coroutines::unpackIndex(handle.raw());
uint32_t generation = detail::acrux::coroutines::unpackGeneration(handle.raw());
if (index < m_slots.size() && m_slots[index].m_alive && m_slots[index].m_generation == generation)
{
m_slots[index].m_task = Coroutine{};
m_slots[index].m_alive = false;
++m_slots[index].m_generation;
m_freeIndices.push_back(index);
--m_liveCount;
onCoroutineFinished(handle);
handle = INVALID_COROUTINE_HANDLE;
return;
}
for (auto it = m_pendingTasks.begin(); it != m_pendingTasks.end(); ++it)
{
if (it->m_index == index && it->m_task.m_handle && it->m_task.m_handle.promise().m_assignedId == handle)
{
m_pendingTasks.erase(it);
break;
}
}
handle = INVALID_COROUTINE_HANDLE;
}
void update()
{
// Step 1: grow the backing store exactly once (if needed) and drain
// staged insertions into it. This is the only place `m_slots`
// grows, so it's safe to do it here, before any references into it
// are taken below.
if (!m_pendingTasks.empty())
{
if (m_logicalSize > m_slots.size())
{
m_slots.resize(m_logicalSize);
}
for (auto& pending : m_pendingTasks)
{
CoroutineHandle handle = pending.m_task.m_handle.promise().m_assignedId;
m_slots[pending.m_index].m_task = std::move(pending.m_task);
m_slots[pending.m_index].m_alive = true;
++m_liveCount;
onCoroutineStarted(handle, pending.m_parent);
}
m_pendingTasks.clear();
}
// Step 2: tick every live slot. New coroutines started from within
// a resumed coroutine go through startCoroutine, which either lands
// in an existing free slot immediately or gets staged for next
// frame — either way `m_slots` itself never reallocates during this
// loop. m_currentlyResuming is set for the duration of each
// h.resume() call so that a nested startCoroutine() (from
// `co_yield childTask()` or a direct call in the coroutine's body)
// can see who's resuming it right now.
m_deadIndices.clear();
const size_t size = m_slots.size();
for (size_t i = 0; i < size; ++i)
{
Slot& slot = m_slots[i];
if (!slot.m_alive) continue;
auto h = slot.m_task.m_handle;
if (!h || h.done())
{
m_deadIndices.push_back(static_cast<uint32_t>(i));
continue;
}
if (shouldResume(h.promise()))
{
m_currentlyResuming = h.promise().m_assignedId;
h.resume();
m_currentlyResuming = INVALID_COROUTINE_HANDLE;
}
if (!h || h.done())
{
m_deadIndices.push_back(static_cast<uint32_t>(i));
}
}
// Step 3: reclaim finished slots.
for (uint32_t index : m_deadIndices)
{
Slot& slot = m_slots[index];
if (!slot.m_alive) continue; // may have been explicitly stopped already this frame
// An alive slot always has a valid coroutine handle -- startCoroutine()
// rejects an empty Coroutine before a slot is ever marked alive.
CoroutineHandle handle = slot.m_task.m_handle.promise().m_assignedId;
slot.m_task = Coroutine{};
slot.m_alive = false;
++slot.m_generation;
m_freeIndices.push_back(index);
--m_liveCount;
onCoroutineFinished(handle);
}
}
size_t getActiveCount() const { return m_liveCount + m_pendingTasks.size(); }
};
inline CoroutineRunner* CoroutineRunner::m_instance = nullptr;
// ----------------------------------------------------------------------------
// Out-of-line definition of promise_type::yield_value(Coroutine&&). Needs
// CoroutineRunner to be a complete type, so it lives here rather than inline
// inside Coroutine.hpp (see the comment at the top of that file).
// ----------------------------------------------------------------------------
inline std::suspend_always Coroutine::promise_type::yield_value(Coroutine&& childTask)
{
assert(CoroutineRunner::m_instance &&
"co_yield of a child Coroutine requires an active CoroutineRunner "
"(construct one before starting/resuming coroutines).");
CoroutineHandle childHandle = CoroutineRunner::m_instance->startCoroutine(std::move(childTask));
m_waitKind = WaitKind::NestedHandle;
m_nestedHandle = childHandle;
return {};
}
// Free-function convenience wrapper around CoroutineRunner::startCoroutine,
// using the active runner (CoroutineRunner::m_instance) — the same
// singleton that co_yield of a child Coroutine relies on internally.
//
// Starts `task` immediately and returns its handle WITHOUT suspending the
// caller, so it can be called from anywhere: main(), a plain function, or
// from inside another running coroutine. This is the "fire it, keep the
// handle, wait later (or never)" entry point:
//
// CoroutineHandle bg = startCoroutine(controller.fadeOut(delta));
// // ... keep going, do other work ...
// co_yield waitFor(bg); // wait on it whenever you're ready (optional)
//
// (Contrast with `co_yield controller.fadeOut(delta);`, which starts AND
// immediately blocks the caller until it finishes.)
//
// This always calls CoroutineRunner::startCoroutine specifically -- if the
// active runner is actually a subclass with its own extended
// startCoroutine() overload(s), call those directly on the runner instance
// instead of through this free function.
inline CoroutineHandle startCoroutine(Coroutine task)
{
assert(CoroutineRunner::m_instance &&
"startCoroutine() requires an active CoroutineRunner "
"(construct one before starting/resuming coroutines).");
return CoroutineRunner::m_instance->startCoroutine(std::move(task));
}
// Free-function convenience wrapper around CoroutineRunner::stopCoroutine,
// using the active runner (CoroutineRunner::m_instance).
//
// Takes `handle` by REFERENCE, same as the member function — it stops the
// coroutine AND resets `handle` to INVALID_COROUTINE_HANDLE, so a stale
// handle can never accidentally be reused or re-stopped:
//
// CoroutineHandle bg = startCoroutine(controller.fadeOut(delta));
// ...
// stopCoroutine(bg); // bg is now INVALID_COROUTINE_HANDLE
// stopCoroutine(bg); // safe no-op, doesn't crash or double-free
inline void stopCoroutine(CoroutineHandle& handle)
{
handle.stop();
}
// ----------------------------------------------------------------------------
// Out-of-line definitions of CoroutineHandle::isRunning()/stop(). Needs
// CoroutineRunner to be a complete type, so they live here rather than
// inline inside CoroutineHandle.h (see the comment at the top of that
// file). This is what lets you write `buster.stop();` /
// `buster.isRunning()` directly on a handle instead of going through
// CoroutineRunner or a free function.
// ----------------------------------------------------------------------------
inline bool CoroutineHandle::isRunning() const
{
assert(CoroutineRunner::m_instance &&
"CoroutineHandle::isRunning() requires an active CoroutineRunner "
"(construct one before starting/resuming coroutines).");
return CoroutineRunner::m_instance->isCoroutineRunning(*this);
}
inline void CoroutineHandle::stop()
{
assert(CoroutineRunner::m_instance &&
"CoroutineHandle::stop() requires an active CoroutineRunner "
"(construct one before starting/resuming coroutines).");
CoroutineRunner::m_instance->stopCoroutine(*this);
}
}
+117
View File
@@ -0,0 +1,117 @@
#pragma once
// ============================================================================
// Predicate.hpp — small-buffer-optimized type-erased bool() callable.
//
// Stores any callable (lambda, functor) up to kBufferSize bytes INLINE — no
// heap allocation — which is exactly what you need for the common case of a
// lambda capturing `this` and/or a couple of coroutine-local values, e.g.:
//
// WaitUntil([this]{ return this->targetInRange; })
// WaitUntil([hp = enemy->hp]{ return hp() <= 0; })
// ============================================================================
#include <cstddef>
#include <new>
#include <type_traits>
#include <utility>
#include <cassert>
namespace acrux::coroutines
{
class Predicate
{
public:
Predicate() noexcept = default;
template <typename F, typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, Predicate>>>
Predicate(F&& f)
{
using Fn = std::decay_t<F>;
static_assert(sizeof(Fn) <= BufferSize,
"Predicate: captured callable is too large for the inline buffer. "
"Capture by pointer/reference (e.g. [this]) instead of by value, "
"or increase Predicate::kBufferSize.");
static_assert(alignof(Fn) <= alignof(std::max_align_t),
"Predicate: captured callable's alignment exceeds max_align_t.");
::new (static_cast<void*>(&m_storage)) Fn(std::forward<F>(f));
m_vtable = &VTableFor<Fn>::table;
}
Predicate(const Predicate&) = delete;
Predicate& operator=(const Predicate&) = delete;
Predicate(Predicate&& other) noexcept { moveFrom(other); }
Predicate& operator=(Predicate&& other) noexcept
{
if (this != &other)
{
destroy();
moveFrom(other);
}
return *this;
}
~Predicate() { destroy(); }
bool operator()() const
{
assert(m_vtable && "Predicate invoked while empty");
return m_vtable->invoke(&m_storage);
}
explicit operator bool() const noexcept { return m_vtable != nullptr; }
private:
static constexpr size_t BufferSize = 32;
struct VTable
{
bool (*invoke)(const void*);
void (*move)(void* dst, void* src);
void (*destroy)(void*);
};
template <typename Fn>
struct VTableFor
{
static bool invoke(const void* p)
{
return (*reinterpret_cast<const Fn*>(p))();
}
static void move(void* dst, void* src)
{
::new (dst) Fn(std::move(*reinterpret_cast<Fn*>(src)));
reinterpret_cast<Fn*>(src)->~Fn();
}
static void destroy(void* p)
{
reinterpret_cast<Fn*>(p)->~Fn();
}
static inline const VTable table{ &invoke, &move, &destroy };
};
void moveFrom(Predicate& other) noexcept
{
m_vtable = other.m_vtable;
if (m_vtable)
{
m_vtable->move(&m_storage, &other.m_storage);
other.m_vtable = nullptr;
}
}
void destroy() noexcept
{
if (m_vtable)
{
m_vtable->destroy(&m_storage);
m_vtable = nullptr;
}
}
alignas(std::max_align_t) unsigned char m_storage[BufferSize];
const VTable* m_vtable = nullptr;
};
}
@@ -0,0 +1,128 @@
#pragma once
// ============================================================================
// WaitConditions.hpp — waitForSeconds / waitUntil / waitWhile / waitFor.
//
// This file is NOT part of the coroutine engine. Everything here is built
// entirely on top of the public core API (Coroutine's generic "co_yield
// anything invocable as bool()" support, and CoroutineHandle::isRunning()) --
// Coroutine.h and CoroutineRunner.h have no idea any of this exists.
//
// Each helper below just returns a small callable object with a
// `bool operator()() const`. co_yield-ing that object works because of
// Coroutine::promise_type's generic yield_value<F> overload, not because of
// anything specific to these types. Writing your own wait condition is the
// same one-line pattern: define a small callable, co_yield it (optionally
// through a helper function like the ones below, for a readable call site).
// ============================================================================
#include <chrono>
#include <type_traits>
#include <utility>
#include <acrux/coroutines/CoroutineHandle.h>
namespace acrux::coroutines::standard
{
namespace detail
{
// co_yield waitForSeconds(2.5f) -- resumes once `seconds` of real time
// has elapsed. The clock starts on this condition's FIRST evaluation
// (the first time CoroutineRunner::update() checks it after the
// coroutine suspends), not when waitForSeconds() itself was called.
class SecondsCondition
{
public:
explicit SecondsCondition(float seconds) noexcept : m_durationSeconds(seconds) {}
bool operator()() const
{
auto now = std::chrono::steady_clock::now();
if (!m_started)
{
m_startTime = now;
m_started = true;
return false;
}
std::chrono::duration<float> elapsed = now - m_startTime;
return elapsed.count() >= m_durationSeconds;
}
private:
float m_durationSeconds;
mutable std::chrono::steady_clock::time_point m_startTime{};
mutable bool m_started = false;
};
// co_yield waitFor(handle) -- resumes once `handle` is no longer
// running. Implemented purely through CoroutineHandle's public API
// (isRunning()), so it works the same as co_yield-ing the handle
// directly (a core feature); this exists only so a call site reading
// "waitFor" lines up with the other wait* helpers.
class HandleCondition
{
public:
explicit HandleCondition(CoroutineHandle handle) noexcept : m_handle(handle) {}
bool operator()() const { return !m_handle.isRunning(); }
private:
CoroutineHandle m_handle;
};
// co_yield waitWhile(predicate) -- resumes once `predicate()` returns
// false. A thin wrapper negating whatever predicate was passed in.
template <typename F>
class NegatedCondition
{
public:
explicit NegatedCondition(F predicate) : m_predicate(std::move(predicate)) {}
bool operator()() const { return !m_predicate(); }
private:
F m_predicate;
};
}
inline detail::SecondsCondition waitForSeconds(float seconds)
{
return detail::SecondsCondition(seconds);
}
// Waits for a coroutine that was started earlier (e.g. via the free
// function startCoroutine() in CoroutineRunner.h) WITHOUT yielding at
// that time. Equivalent to `co_yield handle` directly — provided here
// so waiting on a handle reads the same way as the other wait* helpers:
//
// CoroutineHandle bg = startCoroutine(controller.fadeOut(delta)); // fire, don't wait yet
// ... do other stuff, possibly across several co_yields ...
// co_yield waitFor(bg); // now block until it's done
inline detail::HandleCondition waitFor(CoroutineHandle handle)
{
return detail::HandleCondition(handle);
}
// Accepts any callable convertible to bool() — including lambdas that
// capture `this` or a member coroutine's arguments/locals by value or
// reference. This is what makes waitUntil/waitWhile usable from
// non-static member coroutines. Returned by value; co_yield can also
// accept `predicate` directly without this wrapper, since Coroutine's
// core yield_value already accepts any bool()-invocable object -- this
// exists purely so the call site reads "waitUntil".
template <typename F>
inline std::decay_t<F> waitUntil(F&& predicate)
{
static_assert(std::is_invocable_r_v<bool, std::decay_t<F>&>,
"waitUntil requires a callable convertible to bool().");
return std::forward<F>(predicate);
}
template <typename F>
inline detail::NegatedCondition<std::decay_t<F>> waitWhile(F&& predicate)
{
static_assert(std::is_invocable_r_v<bool, std::decay_t<F>&>,
"waitWhile requires a callable convertible to bool().");
return detail::NegatedCondition<std::decay_t<F>>(std::forward<F>(predicate));
}
}
+251
View File
@@ -0,0 +1,251 @@
#include <iostream>
#include <unordered_map>
#include <cassert>
#include <acrux/coroutines/Coroutine.h>
#include <acrux/coroutines/CoroutineRunner.h>
#include <acrux/coroutines/standard/WaitConditions.h>
using namespace acrux::coroutines;
using namespace acrux::coroutines::standard;
#define yield co_yield
#define exit co_return
struct AlphaController
{
float alpha = 1;
Coroutine fadeIn(float delta)
{
std::cout << "alpha: " << alpha << '\n';
while(alpha < 1)
{
alpha += delta;
if(alpha > 1) alpha = 1;
std::cout << "alpha: " << alpha << '\n';
yield waitForSeconds(0.15);
}
}
Coroutine fadeOut(float delta)
{
std::cout << "alpha: " << alpha << '\n';
while(alpha > 0)
{
alpha -= delta;
if(alpha < 0) alpha = 0;
std::cout << "alpha: " << alpha << '\n';
yield waitForSeconds(0.15);
}
}
};
Coroutine blink(AlphaController& controller, float delta, float waitSeconds = 1)
{
yield controller.fadeOut(delta);
yield waitForSeconds(waitSeconds);
yield controller.fadeIn(delta);
}
Coroutine typewrite(const std::string& text, float delay = 0.03)
{
for(char c : text)
{
std::cout << c;
std::cout.flush();
yield waitForSeconds(delay);
}
}
Coroutine writeAndWait(const std::string& text)
{
yield typewrite(text);
std::cin.get();
}
Coroutine rudeBuster()
{
yield waitForSeconds(2);
std::cout << "(RudeBuster Hit)\n";
}
Coroutine attack()
{
std::cout << "(Attacking)\n";
yield waitForSeconds(2);
std::srand(std::time(nullptr));
bool cancel = (std::rand() % 100) < 50;
if(cancel)
{
std::cout << "(Miss)\n";
exit;
}
std::cout << "(Hit)\n";
}
Coroutine myCutscene()
{
yield writeAndWait("* Susie used RUDE BUSTER!");
//yield rudeBuster();
//yield waitForSeconds(1);
CoroutineHandle buster = startCoroutine(rudeBuster());
yield writeAndWait("* Ralsei cast HEAL PLAYER.");
//yield waitForSeconds(1);
buster.stop();
yield attack();
//stopCoroutine(buster);
//yield buster;
yield writeAndWait("* You won!\n* Got 0 EXP and 249 D$.");
}
// ---------------------------------------------------------------------------
// Demonstrates deriving from CoroutineRunner to attach extra data to each
// coroutine it starts, keyed by CoroutineHandle -- e.g. "which entity does
// this coroutine belong to?" in a game engine. CoroutineRunner itself knows
// nothing about this; it only provides the extension points
// (onCoroutineStarted/onCoroutineFinished) and a hashable CoroutineHandle.
// ---------------------------------------------------------------------------
using EntityId = int;
class GameCoroutineRunner : public CoroutineRunner
{
public:
// Without this, declaring our own startCoroutine(task, owner) below would
// hide CoroutineRunner::startCoroutine(task) entirely -- C++ overload
// resolution doesn't look at base class overloads once a derived class
// declares any overload with the same name.
using CoroutineRunner::startCoroutine;
// A new overload alongside the base class's plain startCoroutine(task),
// which remains available and untouched.
CoroutineHandle startCoroutine(Coroutine task, EntityId owner)
{
CoroutineHandle handle = CoroutineRunner::startCoroutine(std::move(task));
if (handle) m_owners[handle] = owner;
return handle;
}
EntityId ownerOf(CoroutineHandle handle) const
{
auto it = m_owners.find(handle);
return it != m_owners.end() ? it->second : -1;
}
size_t trackedCount() const { return m_owners.size(); }
protected:
// If this coroutine was started by another one we're tracking an owner
// for -- whether via `co_yield childTask()` or a direct startCoroutine()
// call made from inside that coroutine's body -- inherit that owner
// automatically. The explicit-owner startCoroutine(task, owner) overload
// above still runs its own m_owners[handle] = owner afterward, so an
// explicit owner always wins over an inherited one.
void onCoroutineStarted(CoroutineHandle handle, CoroutineHandle parent) override
{
auto it = m_owners.find(parent);
if (it != m_owners.end())
{
m_owners[handle] = it->second;
}
}
void onCoroutineFinished(CoroutineHandle handle) override
{
m_owners.erase(handle);
}
private:
std::unordered_map<CoroutineHandle, EntityId> m_owners;
};
Coroutine countToThree()
{
co_yield nullptr;
co_yield nullptr;
co_yield nullptr;
}
// Starts a child coroutine two different ways -- co_yield, and a direct
// startCoroutine() call -- both of which should hand the child the same
// owner as this coroutine, with no explicit owner passed at either call site.
CoroutineHandle g_directChildHandle;
Coroutine parentTask()
{
co_yield countToThree(); // child #1: via co_yield, waits for it to finish
g_directChildHandle = startCoroutine(countToThree()); // child #2: fire-and-forget
co_yield nullptr;
}
void testExtensionMechanism()
{
GameCoroutineRunner runner;
CoroutineHandle a = runner.startCoroutine(countToThree(), /* owner */ 42);
CoroutineHandle b = runner.startCoroutine(countToThree()); // base overload, no owner
assert(runner.ownerOf(a) == 42);
assert(runner.ownerOf(b) == -1); // never registered
assert(runner.trackedCount() == 1);
while (a.isRunning() || b.isRunning())
{
runner.update();
}
// Both coroutines have finished; onCoroutineFinished should have
// cleaned up the side table automatically, with no leftover entries.
assert(runner.trackedCount() == 0);
// A coroutine started with an owner. Its children -- one started via
// co_yield, one via a direct startCoroutine() call from inside its
// body -- should both inherit that same owner automatically, without
// either call site ever passing it explicitly.
CoroutineHandle parent = runner.startCoroutine(parentTask(), /* owner */ 7);
assert(runner.trackedCount() == 1); // just `parent` so far
runner.update(); // runs parentTask up to `co_yield countToThree()`,
// which starts child #1 on this same tick
// The only way trackedCount can have grown is if onCoroutineStarted
// found `parent` in the owner table and propagated it to child #1.
assert(runner.trackedCount() == 2);
while (parent.isRunning())
{
runner.update();
}
// child #2 (started via a direct startCoroutine() call in parentTask's
// body, after child #1 finished) should have inherited the same owner.
// parentTask doesn't wait on child #2, so it's still running/tracked here.
assert(runner.ownerOf(g_directChildHandle) == 7);
// Drain everything so the next test starts from a clean runner.
while (g_directChildHandle.isRunning())
{
runner.update();
}
assert(runner.trackedCount() == 0);
std::cout << "[PASS] testExtensionMechanism\n";
}
int main()
{
testExtensionMechanism();
CoroutineRunner engine;
AlphaController alphaController;
engine.startCoroutine(myCutscene());
while(engine.getActiveCount() > 0)
{
engine.update();
}
return 0;
}