Add project files
This commit is contained in:
commit
c92f6a1865
15 files changed
+1672
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user