28 Commits

Author SHA1 Message Date
YourWishes 8f8fa8f8d1 Scene.set() JS lifecycle, UI sprite caching, emulator test scripts
- Add Scene.set(module) so JerryScript can switch scenes via
  require()'d {init, update, dispose} modules; wire it into
  engineUpdate() and rework overworldscene.js/init.js to the new shape.
- Fix scriptManagerExecFile() never pushing its own file's directory
  onto the require() dir stack, breaking relative require() calls from
  a top-level entry script (e.g. init.js -> ./overworldscene.js).
- Cache sprite geometry across frames for uislider/uitab/uiframe
  (dialogs/textbox/settings) and give uitextbox a per-page glyph cache
  instead of one spriteBatchBuffer call per visible character. uiconsole
  drops its alloc/free vertex buffer for a fixed-size one. uifps skips
  rebuilding its label when the text hasn't changed.
- Add PSP_OPTIMIZATION_PLAN.md and a handful of UI/spritebatch unit
  tests covering the new caching logic.
- Add Dolphin/PPSSPP emulator smoke-test scripts (+ Docker variants and
  CI jobs) for GameCube/Wii/PSP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:21:57 -05:00
YourWishes 07f98c119a Script stuff 2026-07-31 09:40:53 -05:00
YourWishes d49194fc4d Embed font into binary 2026-07-30 15:18:07 -05:00
YourWishes e61914bad4 Finish JerryScript Entity/Scene wiring; remove JSON serialize/deserialize
Register Entity, the generic Component wrapper, and Scene as JerryScript
classes (modulelist.c ties them together, plus their .d.ts stubs), so
scenes/entities can be built from script going forward instead of the
JSON scene format.

With scripting now the intended authoring path, drop the JSON
serialize/deserialize machinery entirely: componentdefinition_t's
serialize/deserialize callbacks, every component's *Serialize/
*Deserialize function, entitySerialize/entityDeserialize,
sceneSerialize/sceneDeserialize, and the "prefabs/<name>.json"/
"scenes/<name>.json" asset fallback in entityPrefabResolveAndApply/
scenePrefabResolveAndApply (C-coded prefabs only now). physicsshape.c
and its test are removed outright since they only existed for this.
game.c's test scene now comes from the existing overworldSceneCreate()
C builder instead of loading the now-deleted assets/scenes/test.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:19:08 -05:00
YourWishes 015d90519f WIP: start JerryScript integration (core engine + Component wrapper)
Ports the core scripting engine (scriptmanager, scriptproto, modulebase,
moduleplatform) and a generic Component JS wrapper from the old we-ball
branch, adapted to the current entitymanager_t/multi-scene architecture.

Not yet buildable: moduleentity.c, modulescene, modulelist.h/.c, and the
CMakeLists.txt for the new module subdirectories are still missing, and
engine.c isn't wired up yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:06:34 -05:00
YourWishes 7b4f0abfb8 Cutscene progress 2026-07-24 15:57:13 -05:00
YourWishes 0667a44e14 Restored cutscene code 2026-07-23 09:15:47 -05:00
YourWishes 0852b8463b ANIM 2026-07-21 18:55:23 -05:00
YourWishes 1436fda858 Some animation work 2026-07-21 09:29:14 -05:00
YourWishes 0826908068 First round of animation improvements 2026-07-20 16:07:38 -05:00
YourWishes 7ceb9e571d Scene prefabs 2026-07-20 15:11:38 -05:00
YourWishes 38c2f6b6f9 Add prefab support 2026-07-20 14:44:14 -05:00
YourWishes b9f06fef7a Add area and flag on physics 2026-07-20 12:31:14 -05:00
YourWishes 373f1c1010 test 2026-07-19 22:12:53 -05:00
YourWishes e80d693d85 More ui optimization 2026-07-19 17:39:38 -05:00
YourWishes ef60ddf6a8 UI Optim 2026-07-19 17:31:29 -05:00
YourWishes de0ff2e263 Fix collide issue on PSP 2026-07-19 14:51:57 -05:00
YourWishes 13a435e9c1 Test 2026-07-19 14:05:46 -05:00
YourWishes 725338cd5a Game code start 2026-07-19 11:49:13 -05:00
YourWishes acd14f46a8 Optimize PSP slightly 2026-07-19 11:15:22 -05:00
YourWishes ef78f023ed yeah whatever 2026-07-18 23:07:45 -05:00
YourWishes 85b6109792 Fixed save crash 2026-07-18 21:17:24 -05:00
YourWishes 6c5738c863 Testing physics 2026-07-18 20:53:22 -05:00
YourWishes 1174e471f6 Time fixes 2026-07-18 20:31:42 -05:00
YourWishes a618ff46fe Physics 2026-07-18 20:01:44 -05:00
YourWishes 1fa5cd316e Restore ECS 2026-07-18 19:05:51 -05:00
YourWishes 11e5d37563 Cleanup more. 2026-07-18 17:38:25 -05:00
YourWishes 9f86c20129 remove rpg 2026-07-18 17:15:45 -05:00
438 changed files with 19367 additions and 10150 deletions
+71
View File
@@ -33,3 +33,74 @@ jobs:
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
# Emulator smoke tests: boot the built disc/EBOOT for a fixed window and
# confirm the emulator doesn't crash. Not yet verified against a real
# runner (no CI run has exercised these) -- continue-on-error so a
# flaky/broken emulator step doesn't block the required Linux test job.
run-tests-gamecube-dolphin:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso \
dolphin-emu xvfb
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube ISO and boot it in Dolphin
run: ./scripts/test-gamecube-dolphin.sh
run-tests-wii-dolphin:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ghcr.io/extremscorner/libogc2:latest
steps:
- name: Install Node.js
run: apt-get update && apt-get install -y nodejs
- name: Checkout repository
uses: actions/checkout@v4
- name: Install additional dependencies
run: |
apt-get install -y \
python3-pip python3-polib python3-pil \
python3-dotenv python3-pyqt5 python3-opengl xorriso \
dolphin-emu xvfb
dkp-pacman -Syu --noconfirm
dkp-pacman -S --needed --noconfirm \
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii ISO and boot it in Dolphin
run: ./scripts/test-wii-dolphin.sh
run-tests-psp-ppsspp:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pspdev
uses: ./.github/actions/setup-pspdev
- name: Install PPSSPPHeadless build dependencies
run: |
sudo apt-get update
sudo apt-get install -y git cmake ninja-build libsdl2-dev zlib1g-dev
- name: Build PPSSPPHeadless
run: |
git clone --recursive --depth 1 https://github.com/hrydgard/ppsspp.git /tmp/ppsspp
cmake -S /tmp/ppsspp -B /tmp/ppsspp/build -DCMAKE_BUILD_TYPE=Release
cmake --build /tmp/ppsspp/build --target PPSSPPHeadless -- -j$(nproc)
echo "PPSSPP_HEADLESS_BIN=/tmp/ppsspp/build/PPSSPPHeadless" >> "$GITHUB_ENV"
- name: Build PSP EBOOT and boot it in PPSSPPHeadless
run: ./scripts/test-psp-ppsspp.sh
+104 -8
View File
@@ -1,5 +1,9 @@
# Dusk — Claude Code rules
See `STATUS.md` for a periodically-refreshed inventory of subsystem
maturity, test coverage gaps, and known open issues — check it before
assuming a subsystem is fully wired up or before picking a next task.
## File headers
Every C, H, and JS file starts with:
@@ -188,21 +192,110 @@ simply left undefined — the core guards calls with `#ifdef`.
## Adding a new entity component
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
`entityMyCompDispose()`.
2. Add the include to `src/dusk/entity/componentlist.h` header block.
3. Add a row to `src/dusk/entity/componentlist.h`:
`entityMyCompDispose()`, `entityMyCompRender()`.
2. Add the include to `src/dusk/entity/componentlist.h` header block
(or `src/duskrpg/entity/gamecomponentlist.h` for a game-specific
component, appended after the engine's inbuilt ones).
3. Add a row:
```c
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
```
This auto-generates the enum, union field, and definition entry.
Params are `(enumName, type, field, init, dispose, render)` — pass
`NULL` for any callback the component doesn't need. This
auto-generates the enum, union field, and definition entry.
4. If JS-facing, create the script module and `.d.ts` (see below).
Entities/components/scenes have no JSON serialize/deserialize path —
that was removed in favor of building scenes from C-coded prefabs
(below) or from JerryScript (`Entity`/`Component`/`Scene`, see
"Adding a new script (JS) module").
---
## Adding a new entity/scene prefab
Entity prefabs (`src/dusk/entity/entityprefab.h`) and scene prefabs
(`src/dusk/scene/sceneprefab.h`) follow the same pattern:
1. Write an apply function: `errorret_t entityPrefabXxxApply(mgr,
entityId)` (or `errorret_t scenePrefabXxxApply(sceneId)`), building
up the entity/scene with the normal component/entity APIs.
2. Add an entry to the sentinel-terminated `ENTITY_PREFABS[]` (in
`src/dusk/entity/entityprefablist.h`, or a game-specific list it
includes) or `SCENE_PREFABS[]`:
```c
{ .name = "MY_PREFAB", .extends = "", .apply = entityPrefabXxxApply }
```
`extends` names another prefab to apply first (recurses through
`entityPrefabResolveAndApply`/`scenePrefabResolveAndApply`), or `""`
for none. Do not add an enum or count field — the array is iterated
until `.name[0] == '\0'`.
3. `entityPrefabResolveAndApply`/`scenePrefabResolveAndApply` only
resolve names against the C-coded registry above — there is no JSON
asset fallback. Throws if no prefab with that name is registered.
---
## Adding a new cutscene item type
1. Create `src/duskrpg/cutscene/item/<category>/cutsceneMyItem.h/.c`
with a data struct (e.g. `cutscenemyitem_t`) and
`cutsceneMyItemStart(item, data)` / `cutsceneMyItemUpdate(item,
data)` (the latter returns `true` once the item has completed). Add
a matching `cutscenemyitemdata_t` runtime-data struct only if the
item needs per-run state across ticks (most don't).
2. Add `CUTSCENE_ITEM_TYPE_MY_ITEM` to the enum and a union member to
`cutsceneitem_t` (and `cutsceneitemdata_t` if it has runtime data) in
`src/duskrpg/cutscene/item/cutsceneitem.h`.
3. Register the `{ start, update }` pair in `CUTSCENE_ITEM_CALLBACKS[]`
in `cutsceneitem.c`.
4. Add an authoring macro to `src/duskrpg/cutscene/cutscene.h`:
```c
#define CUTSCENE_MY_ITEM(ARGS...) \
{ .type = CUTSCENE_ITEM_TYPE_MY_ITEM, .myItem = { ARGS } }
```
used inside a `CUTSCENE(NAME, SIZE, PAUSE_TYPE, ...)` block.
---
## Save system
Save data lives under `src/dusk/save/` (`save.h`/`savefile.h`/
`saveplatform.h`). Slots are fixed-count (`SAVE_FILE_COUNT_MAX`), each
holding a yyjson document persisted with its byte size and a CRC32
checksum. `saveLoad`/`saveSave` read/write the JSON fresh every call —
callers own the returned/passed `yyjson_doc`/`yyjson_mut_doc` and must
free it themselves. Actual file I/O goes through platform-specific
`saveplatform_t`/stream hooks (one implementation per platform under
`src/dusk<platform>/save/`) — do not add direct filesystem calls to the
core `save.c`, extend the platform stream hooks instead.
## Network system
`src/dusk/network/` (`network.h`) is a connection-state layer only — a
`networkstate_t` state machine (`DISCONNECTED`/`CONNECTING`/`CONNECTED`/
`DISCONNECTING`) plus an HTTP client (`network/http/`) used for one-off
requests. It is not a multiplayer/replication protocol — that layer
doesn't exist yet (see `ROADMAP.md` items on the socket server/client
and packet handlers). Platform-specific connection logic (e.g. PSP's
`sceNetApctl` polling, GameCube/Wii's `if_config()`) lives under
`src/dusk<platform>/network/`, wired through `networkplatform.h` macros
the same way display/input are. Whenever this eventually grows a
multiplayer protocol, apply `ROADMAP.md`'s principle: never trust
incoming packet data — validate defensively with `errorret_t`/
`errorThrow()`, not asserts.
## Adding a new script (JS) module
Dusk embeds JerryScript (`src/dusk/script/`, fetched via
`cmake/modules/Findjerryscript.cmake`). Today only `Entity`, the
generic `Component` wrapper, and `Scene` are registered (see
`src/dusk/script/module/modulelist.c`) — no per-component-type typed
wrappers exist yet (e.g. no `.position` on a `POSITION` component);
`entity.add(TYPE)`/`entity.getComponent(TYPE)` always return the
generic `Component`.
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
- Use `moduleBaseFunction(name)` to define JS-callable functions.
- Use `moduleBaseFunction(name)` to define JS-callable functions
these are the one exception to "no `static` in `.c` files": the
macro itself expands to a `static jerry_value_t name(...)`
JerryScript external-handler trampoline, never called by name
from other C files, so it isn't declared in the `.h`.
- Register props/funcs in `moduleMyModInit()` with
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
`scriptProtoDefineStaticFunc`.
@@ -210,9 +303,12 @@ simply left undefined — the core guards calls with `#ifdef`.
`src/dusk/script/module/modulelist.c` and call
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
`moduleListDispose()`).
3. For component modules also register in
`src/dusk/script/module/entity/component/modulecomponentlist.c`
so `entity.add()` returns the typed wrapper.
3. For a component module that adds a *typed* wrapper for a specific
component type, create
`src/dusk/script/module/entity/component/modulecomponentlist.c` (it
doesn't exist yet — the first such module creates it) so
`entity.add()` can return the typed wrapper instead of the generic
`Component`.
4. Create `types/<category>/mymod.d.ts` and add a
`/// <reference path="..." />` line to `types/index.d.ts`.
+260
View File
@@ -0,0 +1,260 @@
# PSP Optimization Plan
A survey of concrete memory and CPU (especially floating-point) optimization
opportunities for the PSP target, done 2026-07-31 on branch `ac2` at commit
`e61914ba` + this session's scripting work. Point-in-time findings, not a
substitute for reading the referenced source before acting on it.
## Why this exists
The PSP (Allegrex MIPS CPU, 222-333MHz, single core, 32-64MB main RAM, 2MB
eDRAM, 16KB I-cache / 16KB D-cache, no virtual memory or memory protection)
is the tightest-constrained target this engine ships to. Two rules guide
everything below, both already correctly applied in one place in this
codebase (`entityposition_t` caching its transform matrices instead of
recomputing them from position/rotation/scale every read — see "Already
correct" below):
- **Memory is finite and cannot be compacted.** No VM means a fragmented
heap after a few minutes of play can fail an allocation even with
"enough" total free bytes. Prefer static/pool allocation over
malloc/free churn; prefer trading memory for CPU only when the memory
cost is bounded and paid once.
- **CPU is finite and floating-point math is not free**, especially
trig/sqrt on the plain scalar FPU. Prefer caching a computed result
behind a dirty flag over recomputing it unconditionally; prefer
avoiding a sqrt via squared-distance comparison wherever only a
yes/no or ordering result is needed.
## Priority 1 — Entity/component memory: a union tax paid on every slot, times 4 scenes
**The single biggest finding.** `entitymanager_t` (`src/dusk/entity/entitymanager.h:11-15`)
is a flat, statically-sized struct:
```c
typedef struct entitymanager_t {
entity_t entities[ENTITY_COUNT_MAX]; // 64
component_t components[ENTITY_COUNT_MAX * ENTITY_COMPONENT_COUNT_MAX]; // 64*16 = 1024
componentid_t entitiesWithComponent[COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX];
} entitymanager_t;
```
`component_t` (`src/dusk/entity/component.h:69-72`) holds a **tagged union**
(`componentdata_t`) sized to its largest variant. That variant is
`entityrenderable_t`'s spritebatch payload (`entityrenderable.h:25-66`):
`spritebatchsprite_t sprites[64]` at 40 bytes each (`vec3 min + vec3 max +
vec2 uvMin + vec2 uvMax`) = **2560 bytes**, versus ~28-300 bytes for every
other component type (camera, physics, animation, position, trigger).
Because the union is embedded by value in a flat array — not behind a
pointer, not sparse — **every one of the 1024 component slots in every
entity manager costs ~2580 bytes, whether it holds a 28-byte camera or
nothing at all.** `1024 * 2580 ≈ 2.52MB` per `entitymanager_t`, confirmed
by the engine's own startup log every run: `Entity manager size: 2684992
bytes (2622.06 KB)`.
`scene_t` (`src/dusk/scene/scene.h:14-18`) embeds `entitymanager_t` by
value too, and `SCENE_COUNT_MAX = 4` (`scene/scenebase.h:11`) with
`SCENE_MANAGER` declared as a plain global (`scene/scene.c:20`) — so this
~2.52MB exists as static BSS from process start for **all 4 scene slots**,
used or not. **Total: ~10.1MB reserved permanently for entity storage
alone** — 16-31% of PSP main RAM — before a single texture, mesh, or audio
asset is loaded.
**Options to fix (roughly increasing effort/risk):**
1. Pull `entityrenderablespritebatch_t` out of the component union entirely
— store spritebatch entities via a pointer/handle into a separate,
smaller fixed pool sized to how many spritebatch-renderable entities
actually coexist (almost certainly far fewer than 64 per entity, and
far fewer entities need spritebatch at all vs. mesh/material
rendering). This alone would shrink the union's dominant variant from
~2560 bytes to whatever the next-largest variant is (~300 bytes,
`entitytrigger_t`) — an ~88% reduction, dropping the ~10.1MB down to
roughly ~1.2MB.
2. Reduce `SCENE_COUNT_MAX` from 4 if 4 concurrent scenes were never a
deliberate requirement (check with the project owner — this may just
be a round-number default nobody revisited), or make scene storage
pointer-based/lazily allocated so unused scene slots cost ~0 instead
of a full `entitymanager_t`.
3. Reduce `ENTITY_COMPONENT_COUNT_MAX` (16) if entities realistically use
far fewer distinct component types simultaneously — check actual
usage across `entityprefablist.h`/`gameprefablist.h` prefabs.
Do (1) first — it's the highest-leverage, most contained change (touches
`entityrenderable.h`'s data layout and its render/dispose paths, not the
general entity/component system), and re-measure via the same startup
log line before deciding whether (2)/(3) are still worth doing.
## Priority 2 — VFPU is completely unused; all vec/mat math is scalar
The PSP's Allegrex CPU has a **VFPU** (vector floating-point unit) capable
of fast SIMD-style 4-wide float ops and hardware-accelerated matrix
operations, exposed by pspsdk's `pspvfpu`/GU "Geometry Utility" (`gum_*`)
helpers. This engine links `pspvfpu` (`cmake/targets/psp.cmake`) but
**never calls into it** — confirmed via a zero-hit grep for
`vfpu|pspmath|gum_|vcst|gu_matrix` across `src/duskpsp/` and `src/duskgl/`.
All vector/matrix math instead goes through cglm (`glm_vec3_*`,
`glm_mat4_*`), which auto-detects SIMD only for x86 (`CGLM_SSE2`/`AVX`) or
ARM (`CGLM_NEON`) — neither applies to MIPS, and no `CGLM_*` macros are
set anywhere in this repo (confirmed zero hits). **Every `glm_mat4_mul`,
every position/rotation rebuild, every physics vector op runs on the
plain scalar MIPS FPU with the VFPU sitting idle.**
This is the single largest *available* CPU win identified in this survey
— larger than any specific hot-path fix below, because it's a multiplier
on all of them. Concretely: route `entityposition.c`'s matrix
rebuild/multiply path and `physicsworld.c`'s per-body vector math through
`pspvfpu`/`gum_*` on the PSP build specifically (behind `#ifdef DUSK_PSP`,
matching the project's existing platform-guard convention), while keeping
cglm as the portable fallback for Linux/Knulli/GameCube/Wii. This is a
genuinely large effort (new platform-specific math backend, careful
correctness verification since VFPU has its own quirks around
pipelining/hazards) — scope it as its own project, not a quick pass.
## Priority 3 — UI/spritebatch rebuilds and redraws every vertex, every frame
Already flagged in `STATUS.md`/`ROADMAP.md` (item 4/5) as a known open
issue; this session's research adds concrete numbers. `meshvertex_t`
(`display/mesh/meshvertex.h:14-17`) is `{ float uv[2]; float pos[3]; }` =
**20 bytes/vertex**, no packing. `SPRITEBATCH_SPRITES_MAX = 512`,
`SPRITEBATCH_FLUSH_COUNT = 16` → 32 sprites/flush = 192 vertices = **3,840
bytes rebuilt and redrawn per flush**, with a flush forced on every
shader/material change (`spritebatch.c:38-50`) and used unconditionally
by every widget except `uiconsole.c` (`uitab.c:56`, `uislider.c:183/199/231`,
final flush in `ui.c:51`). A UI screen with ~50-100 sprites and 2-3
material changes costs an estimated **8-15KB of vertex rebuild + GU
submission every single frame**, regardless of whether the UI changed
since the last frame.
Confirmed on the PSP legacy-GL path specifically: there's no GPU buffer
re-upload cost (`meshFlushGL` is a literal no-op comment: "we use the
glClientState stuff" — `meshgl.c:91-93`; `meshDrawGL` just points
`glVertexPointer` at the CPU-side array each call), so the real cost is
(a) the CPU-side per-sprite vertex rewrite every frame and (b) GU
re-transforming the full vertex stream from RAM on every draw with no
skip for unchanged geometry.
**Fix direction** (already captured in `ROADMAP.md`'s debt backlog item 5):
extend `uiconsole.c`'s cached-mesh, dirty-flag-gated rebuild pattern to
the rest of the widget tree. This plan adds one refinement: also consider
packing `meshvertex_t` down (next item) since it multiplies this cost.
## Priority 4 — All-float vertex format wastes bandwidth and T&L cost
`meshvertex_t` uses two `float` fields (20 bytes) for every mesh and
sprite vertex, mesh-wide, with no normal or per-vertex color (color is
already handled at the material level, so that part is already optimal).
GU natively supports fixed-point 16-bit positions/UVs
(`GU_VERTEX_16BIT`/`GU_TEXTURE_16BIT`), which would roughly halve
per-vertex size and the corresponding vertex-fetch/transform cost, at the
cost of position precision (fine for UI/sprite work and most world
geometry at this engine's scale; worth checking against the largest
world coordinates actually used before committing). Pair with Priority 3
so the packing benefit compounds with the dirty-tracking benefit instead
of just reducing the cost of a rebuild that still happens every frame.
## Priority 5 — Running gameplay logic in JerryScript has a real per-frame cost on PSP
This is new since last session's work, not a pre-existing issue: `assets/
scripts/overworldscene.js`'s `update()` is now called every frame via
`scriptManagerCallGlobal("update")` (wired into `engine.c`'s
`engineUpdate()`), replacing what used to be a direct `cosf`/`sinf` C
callback (`entityUpdateAdd`). Per call, `scriptManagerCallGlobal`
(`scriptmanager.c:91-144`) does: a global-object property lookup (key is
cached, but the `jerry_object_get` dispatch still runs), full JS
interpreter call dispatch (frame setup, argument marshaling) for what's a
two-line function, and an unconditional `jerry_value_is_promise` check
every call even though `update()` is synchronous. Inside, `Math.cos`/
`Math.sin` run as JS builtin calls rather than direct libm calls.
Additionally: this project's JerryScript fork is already patched to use
32-bit float internally (`JERRY_NUMBER_TYPE_FLOAT64=0`,
`Findjerryscript.cmake`) — a deliberate, already-correct optimization for
this exact concern — but the *public* engine API (`jerry_value_as_number()`)
still returns `double`, so every native↔JS boundary crossing (every
`moduleBaseArgFloat`, every `moduleBaseVec3ToObject`) still pays a
float→double→float round trip. Also note `JERRY_MATH` is off, so
`Math.sin`/`cos` fall through to whatever generic libm the platform
provides rather than JerryScript's own fdlibm implementation — worth
checking whether turning it on changes anything measurable on PSP.
**This is one `update()` call for one scene-level script today — cheap in
absolute terms.** It becomes a real problem only if the pattern scales:
giving many individual entities their own per-frame JS update callback
would multiply all of the above per entity. **Recommendation: keep
high-frequency, hot per-entity logic (movement, camera math, physics
response) in native C update callbacks (`entityUpdateAdd`, the existing
mechanism), and reserve JS for one-time setup, infrequent/event-driven
logic, and coarse-grained per-scene orchestration** — which is exactly
what `require()` and the typed component wrappers built this session are
suited for, not a per-entity-per-frame hot path. This is a design
guideline to apply going forward, not a regression to fix in the current
`overworldscene.js` (one scene-level `update()` call per frame is fine).
## Priority 6 — No pooling/arena allocator; plain malloc/free everywhere
`memoryAllocate`/`memoryFree` (`util/memory.c`) are direct `malloc`/`free`
passthroughs (`memoryAlign``memalign`, `memoryReallocate`/`memoryResize`
`realloc`), with the only extra behavior being a global allocation-count
tracker used solely for test leak detection. No pool, arena, free-list,
or size-class allocator exists anywhere.
This survey found **no rogue per-frame heap allocations** in the hot
paths checked (render dispatch in `entityrenderable.c`, all of
`physics/*.c`) — so this is not an active bug today. But it's a
structural risk for a no-VM platform over a long play session: any future
code that does frequent small alloc/free (dynamic lists, string
building, temp buffers) will fragment the 32-64MB heap with no OS-level
recovery mechanism. **Recommendation: before adding any new subsystem
that allocates/frees frequently at runtime (not just at load time),
default to a fixed-size pool or arena for it**, following the same
"static, bounded" philosophy already used for `ENTITY_COUNT_MAX`/
`SCENE_COUNT_MAX`/`ASSET_ENTRY_COUNT_MAX` elsewhere in the engine, rather
than reaching for `memoryAllocate` per-instance.
## Already correct — don't touch without new evidence
- **`entityposition_t`'s matrix caching** (the pattern the project owner
called out as the reason for writing this plan). Verified: a full
"dirty" recompute chain (decompose + rebuild local + rebuild world) is
~10 trig calls (`asinf`/`cosf`/`atan2f`) plus at most one 4x4 matrix
multiply, and `entityPositionEnsurePRS`/`EnsureLocal`/`EnsureWorld`
(`entityposition.c:596-669`) all early-return on a flag check, only
doing that work when something actually changed. The cache is correct
and clearly worth its ~128-byte-per-entity matrix storage cost.
- **Physics narrow-phase sqrt avoidance.** All three `sqrtf` call sites
in `physicstest.c`/`physicsshapemesh.c` already sit behind a
squared-distance early-reject and only compute the real (linear)
distance once, when actually needed for penetration depth — no further
"use squared distance instead" opportunity was found here.
- **No allocations in the render/physics hot loop** (Priority 6) — this
is good and worth preserving as new code is added to those files.
- **Asset decompression already runs off the main thread.** Assets are
DEFLATE-compressed (not stored) in `dusk.dsk`, so loading pays a real
CPU cost for decompression — but every `*LoaderAsync` function
(`asset/loader/*/*.c`) runs via `ASSET.loadThread`
(`assertNotMainThread` in `asset.c:365`), so this cost is already kept
off the main thread. Low priority to change; if load-time CPU cost
becomes a measured problem later, revisit stored-vs-compressed as a
build-time flag rather than assuming compression is free.
## Suggested approach
1. **Measure before changing.** None of the above have been profiled on
real PSP hardware in this pass — this is a source-level survey, not a
profile. Before investing in Priority 1 or 2 especially, confirm with
an actual PSP build/run (or at minimum the existing "Entity manager
size" startup log plus a frame-time counter) that these are the real
bottlenecks, not just the largest numbers on paper.
2. **Priority 1 first** — it's the most contained (one component's data
layout), has the clearest before/after metric (the startup log line),
and doesn't require new platform-specific code.
3. **Priority 3 next** (extend the console's dirty-tracking pattern to
the rest of the UI) — already scoped in `ROADMAP.md`, no new design
needed, just implementation.
4. **Priority 2 (VFPU) as a dedicated project**, not a quick pass — it's
the largest potential win but touches core math plumbing and needs
careful correctness verification on real hardware.
5. Treat Priority 5 as a standing design guideline for all future
scripting work, not a one-time fix.
+36 -1
View File
@@ -1,6 +1,8 @@
# Dusk Roadmap
Tracking upcoming milestones for the engine.
Tracking upcoming milestones for the engine. See `PSP_OPTIMIZATION_PLAN.md`
for a memory/CPU optimization survey specifically targeting the PSP build
(milestone 4 below is its Priority 3).
## Upcoming milestones
@@ -44,6 +46,39 @@ Tracking upcoming milestones for the engine.
or is disconnected, so clients can spawn or remove the
corresponding `clientplayer` entity in the world.
## Infrastructure / debt backlog
Found during a full-codebase inventory pass (2026-07-30, see `STATUS.md`
for the full survey). These aren't new feature milestones so much as
loose ends worth closing, roughly in order of how cheap/safe they are to
fix:
1. Re-enable `test/item` (currently commented out in
`test/CMakeLists.txt`) -- confirm it still passes and turn it back on.
2. Restore `itemgive.c/h` in `src/duskrpg/item/CMakeLists.txt` -- the
textbox UI dependency it was waiting on (`ui/textbox/`) is already
back.
3. Decide the fate of the `vita` target: `scripts/build-vita.sh` and
`docker/vita/` reference `-DDUSK_TARGET_SYSTEM=vita`, but no
`cmake/targets/vita.cmake` exists, so the build is currently broken.
Either implement it or remove the dangling scripts/Dockerfile.
4. Add per-PR CI build coverage for at least one non-Linux target (PSP,
Knulli, GameCube, Wii currently only build on tag push, so
regressions there are invisible until a release).
5. Tackle milestone 4 above (poor UI rendering performance) with a
concrete lead: extend `uiconsole.c`'s cached-mesh pattern (rebuild
only on dirty) to the general widget framework
(`uiframe.c`/`uilabel.c`/buttons/menus), which currently rebuilds and
re-uploads geometry via `spriteBatchBuffer`/`meshFlush` every frame.
6. Revisit the GameCube/Wii networking static-IP workaround in
`networkdolphin.c` -- it's standing in for an unresolved suspected
memory-corruption bug in `if_config()`'s DHCP path.
7. Decide whether the PSP dialog-based network connect UI needs to come
back -- it was removed entirely (not fixed) when the original
dialog-tearing bug proved hard to resolve.
8. Backfill unit tests for the biggest untested surfaces: `ui/` (whole
widget framework), `save/`, `script/` (JerryScript bindings), `event/`.
## Principles
- Never trust the network implicitly. Neither side (server or client)
+102
View File
@@ -0,0 +1,102 @@
# Dusk Engine - Status Snapshot
This is a point-in-time inventory of the codebase's maturity, test coverage,
and known gaps. It is not auto-maintained -- re-survey periodically (or
whenever picking a new roadmap milestone) rather than trusting it blindly.
See `ROADMAP.md` for the ordered feature milestones this status feeds into,
and `CLAUDE.md` for coding conventions.
Last surveyed: 2026-07-30, at commit `e61914ba`.
## Core engine (`src/dusk/`)
| Subsystem | Maturity | Notes |
|------------|------------------------------------|-------|
| asset | Mature, fully wired | Model loading's "async" path is actually synchronous (`assetmodelloader.h`) -- only real stub found in core. |
| script | Mature for its current scope | Registered modules: `modulePlatform`, `moduleComponent`, `moduleEntity`, `moduleScene` only. No typed per-component JS wrappers (all components go through the generic `Component`). No unit tests. |
| entity | Mature | Engine-level prefab lists are empty sentinels; all real prefabs live in `duskrpg`. |
| scene | Mature | Same prefab-delegation pattern as entity. No JSON serialize/deserialize (removed by design). |
| save | Mature, but undocumented | Slot-based, yyjson + CRC32, platform stream hooks. Not covered in `CLAUDE.md`, no tests. |
| network | Connection-state layer only | HTTP client + connection state machine; no multiplayer/replication protocol (expected -- that's roadmap items 8-17). Not covered in `CLAUDE.md`, has HTTP tests only. |
| physics | Mature, recently churned | Recent revert/re-disable of "old ent code" suggests component wiring around physics isn't fully settled. Well tested. |
| animation | Early/mid-stage | Keyframes + easing only, no blend trees or state machines. Terse commit history ("ANIM") suggests still iterating. |
| display | Most mature/battle-tested | Backbone of the engine; dominated by platform optimization commits. |
| ui | Actively churning | Widget framework has had features added and ripped out repeatedly (story/battle UI added then removed). Rendering path is the likely root cause of roadmap item 4 (see below). No tests at all. |
| console | Small, finished for its scope | Already has the cached-mesh optimization pattern the rest of `ui/` lacks. |
| event | Clean, small, finished | Pub/sub, rebuilt to replace the old input system. |
| game | Intentionally header-only | Real implementation lives in `duskrpg/game/game.c`. |
### UI rendering performance (roadmap item 4)
Confirmed root-cause candidate: `src/dusk/ui/debug/uiconsole.c` caches a
persistent mesh and only rebuilds on dirty/scroll change. The rest of the
widget framework (`uiframe.c`, `uilabel.c`, buttons, menus, settings
screens) still goes through `spriteBatchBuffer`/`spriteBatchFlush` and
re-uploads vertex data via `meshFlush` every frame, with a full flush
forced on every shader/material change. This is almost certainly what
"tanks framerate" on PSP. Fix direction: extend the console's cached-mesh
pattern to the general widget path (rebuild only on dirty, not every
frame).
## Game layer (`src/duskrpg/`)
Actively maintained, not orphaned (despite an old "remove rpg" commit deep
in history) -- last touched the same day as this survey.
- **cutscene/** -- actively developed, matches `CLAUDE.md`'s documented
recipe exactly. 16 registered item types.
- **entity/, scene/** -- overworld player/camera/interactable components
and prefabs, active.
- **item/** -- `item.c`/`inventory.c`/`backpack.c` built via a working
`item.json` -> `itemdef.h` codegen pipeline. `itemgive.c/h` exist on
disk but are explicitly excluded from the CMake build pending textbox
UI restoration -- **that UI (`ui/textbox/`) is already restored**, so
this looks like an overdue follow-up, not a real blocker.
- **input/** -- headers only, no implementation. Contains the one
genuine TODO found in the whole `duskrpg` tree: `// TODO: Wiimote, USB
Keyboard, probably more.`
- **ui/** -- textbox restored and built; `uitestlabel.c/h` is an
intentional smoke-test scaffold, not dead code.
## Platform layers
| Platform | Status |
|--------------|--------|
| duskgl / dusksdl2 | Complete, shared by Linux/Knulli/PSP, no gaps found. |
| dusklinux | Complete, well-trodden. |
| duskpsp | Complete for current design. The historical dialog/tearing bug (see memory `project_psp_dialog_tearing` etc.) was **not fixed -- the dialog-based connect UI was removed entirely** (commit `d7982599`, "Simplified PSP network") in favor of a silent profile-based connect. Revisit if the dialog UX is still wanted. |
| duskdolphin (GameCube/Wii) | Functional, not a stub -- real GX-based display/mesh/shader/texture. Wii input uses only the GameCube `PAD_*` library; no WPAD/Wiimote support (`inputdolphin.h` has `#error "Wii not implemented"` gated behind macros that are never defined, so currently dormant, not a live build break). `networkdolphin.c` hardcodes a static IP as an explicit temporary workaround for a suspected DHCP-related memory-corruption bug in `if_config()` -- root cause still open. |
| vita | **Referenced by `scripts/build-vita.sh` and `docker/vita/` but no `cmake/targets/vita.cmake` exists** -- the build target is broken/unfinished at the CMake level. |
### CI coverage gap
`.github/workflows/test.yml` only builds+tests Linux, on PRs to `main`.
`build.yml` builds all other platforms (PSP, Knulli, GameCube, Wii +
ISO variants) but **only on tag push** (release time). Vita and Dolphin
aren't in CI at all. Net effect: regressions on 4+ platforms can land
silently until a release tag is cut.
## Test coverage gaps
Core `src/dusk/` subsystems with **zero** unit tests: `console`,
`engine`, `event`, `game`, `input`, `log`, `save`, `script` (+ all JS
module bindings), `system`, `ui` (entire widget framework).
`src/duskrpg/` has almost no test coverage: `test/item/test_inventory.c`
exists but **`add_subdirectory(item)` is commented out in
`test/CMakeLists.txt`**, so even that one test never runs. `cutscene`,
`entity`, `game`, `input`, `scene`, `ui` under `duskrpg` have no tests at
all.
No stale tests were found referencing deleted systems (old JSON
serialize/deserialize, old event/cutscene modules) -- the test suite is
internally consistent with current code, just incomplete in coverage.
## Build/tooling gaps worth knowing about
- `assetsraw/` -> `assets/` (chunk JSON -> `.dcf`) is a manual/editor-only
step (`tools.asset.chunk`), not part of the CMake build -- committed
`.dcf` files can silently drift from their raw source.
- Several Python tool packages exist but aren't wired into any build:
`tools/color/csv/`, `tools/asset/chunk_json/`, `tools/asset/dmf/`,
`tools/asset/tiles/`, `tools/input/csv/`. Some may be superseded by
`tools/color.py`/`tools/item.py`; worth a pass to confirm which are
live vs leftover.
+9
View File
@@ -0,0 +1,9 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Entry point run once at startup (see game.c). Swap which scene module
// gets passed to Scene.set() here to change what the game boots into.
var overworldScene = require('./overworldscene.js');
Scene.set(overworldScene);
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Radius must stay well outside the floor's footprint (a 20x20 plane has a
// corner-to-center distance of 10*sqrt(2) =~ 14.1) -- orbiting inside that
// puts parts of the floor's own geometry near/behind the camera's view
// direction, which the PSP's legacy GU pipeline can't clip properly and
// drops the whole triangle instead of clipping it.
var CAMERA_ORBIT_RADIUS = 18.0;
var CAMERA_ORBIT_HEIGHT = 10.0;
var CAMERA_ORBIT_SPEED = 0.5;
var cameraOrbitAngle = 0.0;
var cameraPosition = null;
// Orbits the camera around the world origin at a fixed radius/height/
// speed, always looking back at the origin. Called once up front (so the
// very first rendered frame is already positioned correctly) and then
// once per frame via update() below.
function updateCameraOrbit() {
cameraOrbitAngle += Time.delta * CAMERA_ORBIT_SPEED;
var eyeX = Math.cos(cameraOrbitAngle) * CAMERA_ORBIT_RADIUS;
var eyeY = CAMERA_ORBIT_HEIGHT;
var eyeZ = Math.sin(cameraOrbitAngle) * CAMERA_ORBIT_RADIUS;
cameraPosition.lookAt(eyeX, eyeY, eyeZ, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
}
module.exports = {
// Called once by Scene.set(), right after it creates and activates the
// scene this module owns.
init: function() {
// Camera, orbiting the origin (see updateCameraOrbit above).
var camera = new Entity();
cameraPosition = camera.add(POSITION);
camera.add(CAMERA);
updateCameraOrbit();
// Static ground plane. Physics ignores the entity's position
// component -- the shape's own normal/distance fully define the
// plane in world space.
var plane = new Entity();
var planePosition = plane.add(POSITION);
planePosition.setLocalPosition(-10.0, 0.0, -10.0);
planePosition.setLocalScale(20.0, 1.0, 20.0);
var planePhysics = plane.add(PHYSICS);
planePhysics.setBodyType(PHYSICS_BODY_STATIC);
planePhysics.setShape(PHYSICS_SHAPE_PLANE, 0.0, 1.0, 0.0, 0.0);
var planeRenderable = plane.add(RENDERABLE);
planeRenderable.setMesh(0, MESH_PLANE);
planeRenderable.setColor(128, 128, 128, 255);
// Player: dynamic capsule body, moved relative to the camera by
// PLAYER's own update callback (see entityplayer.c).
var player = new Entity();
var playerPosition = player.add(POSITION);
playerPosition.setLocalPosition(0.0, 2.0, 0.0);
var playerPhysics = player.add(PHYSICS);
playerPhysics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5);
var playerRenderable = player.add(RENDERABLE);
playerRenderable.setMesh(0, MESH_CAPSULE);
playerRenderable.setColor(0, 0, 255, 255);
player.add(PLAYER);
},
// Called once per engine frame while this module is the active scene
// (see Scene.set(), engineUpdate() -> moduleSceneUpdateCurrent()).
update: function() {
if(cameraPosition) updateCameraOrbit();
},
// Called once by Scene.set() when this module is replaced by another,
// right before the scene it owns is destroyed.
dispose: function() {
cameraPosition = null;
}
};
+96
View File
@@ -0,0 +1,96 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Turn things off we don't need
set(JERRY_CMDLINE OFF CACHE BOOL "" FORCE)
set(JERRY_EXT ON CACHE BOOL "" FORCE)
set(JERRY_DEBUGGER OFF CACHE BOOL "" FORCE)
set(JERRY_BUILTIN_DATE OFF CACHE BOOL "" FORCE)
set(ENABLE_LTO OFF CACHE BOOL "" FORCE)
# Fetch Jerry
include(FetchContent)
FetchContent_Declare(
jerryscript
GIT_REPOSITORY https://git.wish.moe/YourWishes/jerryscript
GIT_TAG float32-fix
)
FetchContent_MakeAvailable(jerryscript)
# Mark found
set(jerryscript_FOUND ON)
# Define targets
if(TARGET jerryscript-core)
set(JERRY_CORE_TARGET jerryscript-core)
elseif(TARGET jerry-core)
set(JERRY_CORE_TARGET jerry-core)
endif()
if(TARGET jerryscript-ext)
set(JERRY_EXT_TARGET jerryscript-ext)
elseif(TARGET jerry-ext)
set(JERRY_EXT_TARGET jerry-ext)
endif()
if(TARGET jerryscript-port-default)
set(JERRY_PORT_TARGET jerryscript-port-default)
elseif(TARGET jerry-port-default)
set(JERRY_PORT_TARGET jerry-port-default)
elseif(TARGET jerryscript-port)
set(JERRY_PORT_TARGET jerryscript-port)
elseif(TARGET jerry-port)
set(JERRY_PORT_TARGET jerry-port)
endif()
if(NOT JERRY_CORE_TARGET)
message(FATAL_ERROR "JerryScript core target not found")
endif()
if(NOT JERRY_EXT_TARGET)
message(FATAL_ERROR "JerryScript ext target not found")
endif()
if(NOT JERRY_PORT_TARGET)
message(FATAL_ERROR "JerryScript port target not found")
endif()
foreach(tgt IN ITEMS
${JERRY_CORE_TARGET}
${JERRY_EXT_TARGET}
${JERRY_PORT_TARGET}
)
if(TARGET ${tgt})
set_property(TARGET ${tgt} PROPERTY INTERPROCEDURAL_OPTIMIZATION OFF)
target_compile_definitions(${JERRY_CORE_TARGET} PRIVATE
JERRY_NUMBER_TYPE_FLOAT64=0
JERRY_BUILTIN_DATE=0
)
endif()
endforeach()
# Export include dirs through the targets
target_include_directories(${JERRY_CORE_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-core/include
)
target_include_directories(${JERRY_EXT_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-ext/include
)
target_include_directories(${JERRY_PORT_TARGET} INTERFACE
${jerryscript_SOURCE_DIR}/jerry-port/default/include
)
# Suppress JerryScript-only warning
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(${JERRY_CORE_TARGET} PRIVATE
-Wno-error
)
endif()
add_library(jerryscript::core ALIAS ${JERRY_CORE_TARGET})
add_library(jerryscript::ext ALIAS ${JERRY_EXT_TARGET})
add_library(jerryscript::port ALIAS ${JERRY_PORT_TARGET})
+1 -1
View File
@@ -6,7 +6,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
# Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
# bba
bba
)
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
+4 -7
View File
@@ -1,3 +1,7 @@
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
set(CMAKE_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
@@ -55,16 +59,9 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_DISPLAY_WIDTH=480
DUSK_DISPLAY_HEIGHT=272
DUSK_THREAD_PTHREAD
DUSK_TIME_DYNAMIC
DUSK_DISPLAY_OVERSCAN=6
)
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_ASSERTIONS_FAKED
)
endif()
# Postbuild, create .pbp file for PSP.
create_pbp_file(
TARGET "${DUSK_BINARY_TARGET_NAME}"
+7
View File
@@ -0,0 +1,7 @@
FROM ghcr.io/extremscorner/libogc2
WORKDIR /workdir
RUN apt update && \
dkp-pacman -Syu --noconfirm && \
apt install -y python3 python3-pip python3-polib python3-pil python3-dotenv python3-pyqt5 python3-opengl xorriso dolphin-emu xvfb && \
dkp-pacman -S --needed --noconfirm gamecube-sdl2 ppc-liblzma ppc-libzip libogc2 gamecube-tools ppc-libmad ppc-zlib-ng ppc-liblzma ppc-bzip2 ppc-zstd
VOLUME ["/workdir"]
+28
View File
@@ -0,0 +1,28 @@
FROM pspdev/pspdev:latest
WORKDIR /workdir
RUN apk add --no-cache \
python3 \
py3-pip \
py3-dotenv \
git \
cmake \
make \
g++ \
sdl2-dev \
zlib-dev \
linux-headers
# PPSSPP has no Alpine/Linux distro package -- build the "PPSSPPHeadless"
# target (the CLI/no-window build PPSSPP's own CI uses for automated runs)
# from source instead. The target name and exact CMake options have moved
# around across PPSSPP versions -- if this breaks, check
# https://github.com/hrydgard/ppsspp's CMakeLists.txt for the current name.
RUN git clone --recursive --depth 1 \
https://github.com/hrydgard/ppsspp.git /opt/ppsspp && \
cd /opt/ppsspp && \
cmake -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build --target PPSSPPHeadless -- -j$(nproc) && \
cp build/PPSSPPHeadless /usr/local/bin/PPSSPPHeadless && \
rm -rf /opt/ppsspp
VOLUME ["/workdir"]
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin-test -f docker/dolphin-test/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin-test /bin/bash -c "./scripts/test-gamecube-dolphin.sh"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -e
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
DOLPHIN_BIN="${DOLPHIN_BIN:-dolphin-emu}"
if ! command -v "$DOLPHIN_BIN" >/dev/null 2>&1; then
echo "$DOLPHIN_BIN not found. Install Dolphin Emulator (e.g. 'apt install dolphin-emu')"
echo "or set DOLPHIN_BIN to its path."
exit 1
fi
if ! command -v xvfb-run >/dev/null 2>&1; then
echo "xvfb-run not found. Install it (e.g. 'apt install xvfb') -- Dolphin's"
echo "Qt frontend needs a display even in batch mode."
exit 1
fi
./scripts/build-gamecube-iso.sh
DUSK_TEST_DOLPHIN_SECONDS="${DUSK_TEST_DOLPHIN_SECONDS:-20}"
ISO="build-gamecube-iso/Dusk-NTSC-U.iso"
echo "Booting $ISO in Dolphin for up to ${DUSK_TEST_DOLPHIN_SECONDS}s (batch mode, headless via Xvfb)..."
# Dolphin's batch mode (-b -e) runs the disc until told to stop -- there's
# no "the game exited cleanly" signal to wait for, so we bound it with
# timeout and treat "still running when the clock ran out" as success.
set +e
xvfb-run -a timeout "${DUSK_TEST_DOLPHIN_SECONDS}" "$DOLPHIN_BIN" -b -e "$ISO"
status=$?
set -e
# timeout returns 124 when it had to kill Dolphin after the duration
# elapsed -- that means the disc booted and kept running, the expected
# smoke-test-passed outcome, not a failure. Any other non-zero status
# means Dolphin crashed or refused to boot the disc.
if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
echo "Dolphin exited with unexpected status $status -- treating as a failure."
exit "$status"
fi
echo "GameCube smoke test passed (Dolphin ran $ISO for ${DUSK_TEST_DOLPHIN_SECONDS}s without crashing)."
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
docker build -t dusk-psp-test -f docker/psp-test/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-psp-test /bin/bash -c "./scripts/test-psp-ppsspp.sh"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -e
if [ -z "$PSPDEV" ]; then
echo "PSPDEV environment variable is not set. Please set it to the path of your PSP development environment."
exit 1
fi
# PPSSPP doesn't ship a standard Linux package -- build its "PPSSPPHeadless"
# target from source (https://github.com/hrydgard/ppsspp) and either put it
# on PATH or point PPSSPP_HEADLESS_BIN at it. See docker/psp-test/Dockerfile
# for a from-scratch build.
PPSSPP_HEADLESS_BIN="${PPSSPP_HEADLESS_BIN:-PPSSPPHeadless}"
if ! command -v "$PPSSPP_HEADLESS_BIN" >/dev/null 2>&1; then
echo "$PPSSPP_HEADLESS_BIN not found. Build PPSSPP's 'PPSSPPHeadless' target"
echo "from source or set PPSSPP_HEADLESS_BIN to its path."
exit 1
fi
./scripts/build-psp.sh
DUSK_TEST_PPSSPP_SECONDS="${DUSK_TEST_PPSSPP_SECONDS:-20}"
EBOOT="build-psp/EBOOT.PBP"
echo "Booting $EBOOT in PPSSPPHeadless (timeout ${DUSK_TEST_PPSSPP_SECONDS}s)..."
# PPSSPPHeadless is purpose-built for automated/CI runs -- it renders
# without a window and is expected to exit on its own once --timeout
# elapses, unlike Dolphin's batch mode which has to be killed externally.
# Check `"$PPSSPP_HEADLESS_BIN" --help` if these flags don't match your
# PPSSPP checkout -- the headless CLI has changed across versions.
set +e
"$PPSSPP_HEADLESS_BIN" --timeout="${DUSK_TEST_PPSSPP_SECONDS}" "$EBOOT"
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "PPSSPPHeadless exited with status $status"
exit "$status"
fi
echo "PSP smoke test passed (PPSSPPHeadless ran EBOOT.PBP for ${DUSK_TEST_PPSSPP_SECONDS}s without crashing)."
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
docker build -t dusk-dolphin-test -f docker/dolphin-test/Dockerfile .
docker run --rm -v "$(pwd):/workdir" dusk-dolphin-test /bin/bash -c "./scripts/test-wii-dolphin.sh"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -e
if [ -z "$DEVKITPRO" ]; then
echo "DEVKITPRO environment variable is not set. Please set it to the path of your DEVKITPRO installation."
exit 1
fi
DOLPHIN_BIN="${DOLPHIN_BIN:-dolphin-emu}"
if ! command -v "$DOLPHIN_BIN" >/dev/null 2>&1; then
echo "$DOLPHIN_BIN not found. Install Dolphin Emulator (e.g. 'apt install dolphin-emu')"
echo "or set DOLPHIN_BIN to its path."
exit 1
fi
if ! command -v xvfb-run >/dev/null 2>&1; then
echo "xvfb-run not found. Install it (e.g. 'apt install xvfb') -- Dolphin's"
echo "Qt frontend needs a display even in batch mode."
exit 1
fi
./scripts/build-wii-iso.sh
DUSK_TEST_DOLPHIN_SECONDS="${DUSK_TEST_DOLPHIN_SECONDS:-20}"
ISO="build-wii-iso/Dusk-NTSC-U.iso"
echo "Booting $ISO in Dolphin for up to ${DUSK_TEST_DOLPHIN_SECONDS}s (batch mode, headless via Xvfb)..."
# Dolphin's batch mode (-b -e) runs the disc until told to stop -- there's
# no "the game exited cleanly" signal to wait for, so we bound it with
# timeout and treat "still running when the clock ran out" as success.
set +e
xvfb-run -a timeout "${DUSK_TEST_DOLPHIN_SECONDS}" "$DOLPHIN_BIN" -b -e "$ISO"
status=$?
set -e
# timeout returns 124 when it had to kill Dolphin after the duration
# elapsed -- that means the disc booted and kept running, the expected
# smoke-test-passed outcome, not a failure. Any other non-zero status
# means Dolphin crashed or refused to boot the disc.
if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
echo "Dolphin exited with unexpected status $status -- treating as a failure."
exit "$status"
fi
echo "Wii smoke test passed (Dolphin ran $ISO for ${DUSK_TEST_DOLPHIN_SECONDS}s without crashing)."
+1
View File
@@ -4,6 +4,7 @@
# https://opensource.org/licenses/MIT
add_subdirectory(dusk)
add_subdirectory(duskrpg)
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
add_subdirectory(dusklinux)
+13 -1
View File
@@ -32,6 +32,15 @@ if(NOT yyjson_FOUND)
endif()
endif()
if(NOT jerryscript_FOUND)
find_package(jerryscript REQUIRED)
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
jerryscript::core
jerryscript::ext
jerryscript::port
)
endif()
if(DUSK_BACKTRACE)
target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
@@ -58,17 +67,20 @@ add_subdirectory(assert)
add_subdirectory(asset)
add_subdirectory(console)
add_subdirectory(display)
add_subdirectory(entity)
add_subdirectory(log)
add_subdirectory(engine)
add_subdirectory(error)
add_subdirectory(game)
add_subdirectory(input)
add_subdirectory(locale)
add_subdirectory(rpg)
add_subdirectory(scene)
add_subdirectory(system)
add_subdirectory(time)
add_subdirectory(ui)
add_subdirectory(network)
add_subdirectory(physics)
add_subdirectory(save)
add_subdirectory(script)
add_subdirectory(util)
add_subdirectory(thread)
+2
View File
@@ -6,5 +6,7 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
easing.c
keyframe.c
keyframeset.c
animation.c
)
+54 -32
View File
@@ -5,48 +5,70 @@
#include "animation.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h"
#include "time/time.h"
void animationInit(
animation_t *anim,
keyframe_t *keyframes,
uint16_t keyframeCount
keyframe_t **tracks,
uint16_t *trackCounts,
uint16_t trackCount
) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
anim->keyframes = keyframes;
anim->keyframeCount = keyframeCount;
keyframeSetInit(
&anim->keyframes, tracks, trackCounts, NULL, NULL, trackCount, NULL
);
anim->time = 0.0f;
anim->speed = 1.0f;
anim->loop = false;
anim->playing = false;
anim->eventTime = 0.0f;
anim->eventCallback = NULL;
anim->eventUser = NULL;
}
float_t animationGetValue(animation_t *anim, const float_t time) {
void animationUpdate(animation_t *anim) {
assertNotNull(anim, "Animation pointer cannot be null.");
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null.");
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
assertTrue(time >= 0, "Time must be non-negative.");
keyframe_t *start;
keyframe_t *end;
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1;
keyframe_t *current = anim->keyframes;
start = current;
if(!anim->playing) return;
do {
if(current->time > time) {
end = current;
break;
}
start = current;
current++;
float_t duration = keyframeSetGetDuration(&anim->keyframes);
float_t prevTime = anim->time;
anim->time += TIME.delta * anim->speed;
if(current > last) {
end = start;
break;
}
} while(true);
// Loops that overshoot duration wrap back into [0, duration) -- an event
// near the end must still be checked across that wrap, as two segments:
// (prevTime, duration] then [0, newTime].
bool_t wrapped = anim->loop && duration > 0.0f && anim->time >= duration;
float_t t = (time - start->time) / (end->time - start->time);
return mathLerp(start->value, end->value, easingApply(start->easing, t));
}
if(anim->loop) {
if(duration > 0.0f) anim->time = mathModFloat(anim->time, duration);
} else if(anim->time >= duration) {
anim->time = duration;
anim->playing = false;
}
if(!anim->eventCallback) return;
bool_t crossed = wrapped
? (prevTime < anim->eventTime || anim->time >= anim->eventTime)
: (prevTime < anim->eventTime && anim->time >= anim->eventTime);
if(crossed) anim->eventCallback(anim, anim->eventUser);
}
void animationSetEvent(
animation_t *anim,
const float_t time,
const animationeventcallback_t callback,
void *user
) {
assertNotNull(anim, "Animation pointer cannot be null.");
anim->eventTime = time;
anim->eventCallback = callback;
anim->eventUser = user;
}
float_t animationGetValue(animation_t *anim, const uint16_t trackIndex) {
assertNotNull(anim, "Animation pointer cannot be null.");
return keyframeSetGetValue(&anim->keyframes, trackIndex, anim->time);
}
+84 -16
View File
@@ -4,31 +4,99 @@
// https://opensource.org/licenses/MIT
#pragma once
#include "keyframe.h"
#include "keyframeset.h"
typedef struct {
keyframe_t *keyframes;
uint16_t keyframeCount;
typedef struct animation_t animation_t;
/**
* Callback fired once when animationUpdate() advances time past
* eventTime (see animationSetEvent()).
*
* @param anim The animation whose event fired.
* @param user The user pointer passed to animationSetEvent().
*/
typedef void (*animationeventcallback_t)(animation_t *anim, void *user);
typedef struct animation_t {
/** The animation's tracks/channels and their raw keyframe data. */
keyframeset_t keyframes;
/** Current playback position, in seconds. */
float_t time;
/** Playback rate multiplier; 1.0 = normal speed. */
float_t speed;
/** True if animationUpdate() should wrap time back to 0 on reaching the
* final keyframe, rather than holding there and clearing playing. */
bool_t loop;
/** True while animationUpdate() should advance time each call. */
bool_t playing;
/** Time (seconds) at which eventCallback fires; see animationSetEvent(). */
float_t eventTime;
/** Callback fired once when time advances past eventTime, or NULL. */
animationeventcallback_t eventCallback;
/** User pointer passed to eventCallback unchanged. */
void *eventUser;
} animation_t;
/**
* Initializes an animation.
*
* Initializes an animation: time 0, speed 1.0, not looping, not playing.
* See keyframeSetInit() -- tracks/trackCounts and the keyframe_t arrays
* they point to are not copied, and must outlive this animation.
*
* @param anim The animation to initialize.
* @param keyframes The keyframes to use for the animation.
* @param keyframeCount The number of keyframes in the animation.
* @param tracks Array of trackCount keyframe_t arrays.
* @param trackCounts Array of trackCount keyframe counts, matching tracks.
* @param trackCount The number of tracks/channels in this animation.
*/
void animationInit(
animation_t *anim,
keyframe_t *keyframes,
uint16_t keyframeCount
keyframe_t **tracks,
uint16_t *trackCounts,
uint16_t trackCount
);
/**
* Gets the value of the animation at a given time.
*
* @param anim The animation to get the value from.
* @param time The time at which to get the value, in seconds.
* @return The value of the animation at the given time.
* Advances an animation's time by TIME.delta * speed. No-op if not
* playing. Duration is the latest of every track's final keyframe time
* (see keyframeSetGetDuration()). If loop is set, time wraps back into
* [0, duration) on reaching it; otherwise time is clamped there and
* playing is cleared.
*
* If eventCallback is set, it fires once when this call advances time
* from at or before eventTime to after it (checked across the loop wrap
* point too, so an event near the end of a looping animation still fires
* every loop).
*
* @param anim The animation to update.
*/
float_t animationGetValue(animation_t *anim, const float_t time);
void animationUpdate(animation_t *anim);
/**
* Sets (or clears, with callback NULL) the single time-triggered event
* fired by animationUpdate().
*
* @param anim The animation to set the event on.
* @param time The time, in seconds, at which callback fires.
* @param callback The callback to invoke, or NULL to clear any event.
* @param user Arbitrary pointer forwarded to callback unchanged.
*/
void animationSetEvent(
animation_t *anim,
const float_t time,
const animationeventcallback_t callback,
void *user
);
/**
* Gets the value of one of the animation's tracks at its current
* playback time.
*
* @param anim The animation to get the value from.
* @param trackIndex The track to evaluate, in [0, trackCount).
* @return The interpolated value of that track at the current time.
*/
float_t animationGetValue(animation_t *anim, const uint16_t trackIndex);
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#include "keyframe.h"
#include "assert/assert.h"
#include "util/math.h"
float_t keyframeGetValue(
keyframe_t *keyframes,
const uint16_t keyframeCount,
const float_t time
) {
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
assertTrue(time >= 0, "Time must be non-negative.");
keyframe_t *first = keyframes;
keyframe_t *last = keyframes + keyframeCount - 1;
// Clamp to the boundary keyframes' values directly rather than
// interpolating -- start == end at either boundary would otherwise
// divide by zero.
if(time <= first->time) return first->value;
if(time >= last->time) return last->value;
keyframe_t *start = first;
keyframe_t *end;
keyframe_t *current = first;
do {
if(current->time > time) {
end = current;
break;
}
start = current;
current++;
} while(true);
float_t t = (time - start->time) / (end->time - start->time);
return mathLerp(start->value, end->value, easingApply(start->easing, t));
}
+15 -1
View File
@@ -10,4 +10,18 @@ typedef struct {
float_t time;
float_t value;
easingtype_t easing;
} keyframe_t;
} keyframe_t;
/**
* Gets the eased, interpolated value of a keyframe array at a given time.
*
* @param keyframes The keyframes to evaluate, ascending by time.
* @param keyframeCount The number of keyframes.
* @param time The time at which to get the value, in seconds.
* @return The interpolated value at the given time.
*/
float_t keyframeGetValue(
keyframe_t *keyframes,
const uint16_t keyframeCount,
const float_t time
);
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#include "keyframeset.h"
#include "assert/assert.h"
void keyframeSetInit(
keyframeset_t *set,
keyframe_t **tracks,
uint16_t *trackCounts,
keyframesetcallback_t *callbacks,
void **users,
uint16_t trackCount,
void *user
) {
assertNotNull(set, "Keyframe set pointer cannot be null.");
assertNotNull(tracks, "Tracks pointer cannot be null.");
assertNotNull(trackCounts, "Track counts pointer cannot be null.");
assertTrue(trackCount > 0, "Track count must be more than 0.");
set->tracks = tracks;
set->trackCounts = trackCounts;
set->trackCount = trackCount;
set->callbacks = callbacks;
set->users = users;
set->user = user;
}
float_t keyframeSetGetValue(
keyframeset_t *set,
const uint16_t trackIndex,
const float_t time
) {
assertNotNull(set, "Keyframe set pointer cannot be null.");
assertTrue(trackIndex < set->trackCount, "Track index out of bounds.");
return keyframeGetValue(
set->tracks[trackIndex], set->trackCounts[trackIndex], time
);
}
void keyframeSetGetValues(
keyframeset_t *set,
const float_t time,
float_t *outValues
) {
assertNotNull(set, "Keyframe set pointer cannot be null.");
assertNotNull(outValues, "Output values pointer cannot be null.");
for(uint16_t i = 0; i < set->trackCount; i++) {
outValues[i] = keyframeGetValue(set->tracks[i], set->trackCounts[i], time);
}
}
float_t keyframeSetGetDuration(keyframeset_t *set) {
assertNotNull(set, "Keyframe set pointer cannot be null.");
float_t duration = 0.0f;
for(uint16_t i = 0; i < set->trackCount; i++) {
float_t trackDuration = set->tracks[i][set->trackCounts[i] - 1].time;
if(trackDuration > duration) duration = trackDuration;
}
return duration;
}
void keyframeSetFireCallback(keyframeset_t *set, const uint16_t trackIndex) {
assertNotNull(set, "Keyframe set pointer cannot be null.");
assertTrue(trackIndex < set->trackCount, "Track index out of bounds.");
if(!set->callbacks || !set->callbacks[trackIndex]) return;
set->callbacks[trackIndex](
set, trackIndex, set->users ? set->users[trackIndex] : NULL
);
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
#pragma once
#include "keyframe.h"
typedef struct keyframeset_t keyframeset_t;
/**
* Callback associated with one track of a keyframe set (see
* keyframeSetFireCallback()).
*
* @param set The keyframe set the track belongs to.
* @param trackIndex The track this callback is registered for.
* @param user The per-track user pointer passed to keyframeSetInit().
*/
typedef void (*keyframesetcallback_t)(
keyframeset_t *set,
const uint16_t trackIndex,
void *user
);
/**
* A group of N parallel keyframe tracks (e.g. one per animated channel --
* position.x/y/z, bone rotations, etc.) sharing a single timeline. Each
* track is independent: its own keyframe array and count, evaluated at
* whatever time is asked for.
*/
typedef struct keyframeset_t {
/** Caller-owned array of trackCount keyframe_t arrays. */
keyframe_t **tracks;
/** Caller-owned array of trackCount counts, one per entry in tracks. */
uint16_t *trackCounts;
uint16_t trackCount;
/** Caller-owned array of trackCount callbacks, one per track. Entries
* (or the whole array) may be NULL for tracks with no callback. */
keyframesetcallback_t *callbacks;
/** Caller-owned array of trackCount user pointers, one per track,
* passed to that track's callbacks[] entry. May be NULL. */
void **users;
/** Extra user data for the set as a whole, not tied to any one track. */
void *user;
} keyframeset_t;
/**
* Initializes a keyframe set. None of the passed-in arrays (or the
* keyframe_t arrays tracks points to) are copied -- they must outlive
* this keyframeset_t.
*
* @param set The keyframe set to initialize.
* @param tracks Array of trackCount keyframe_t arrays.
* @param trackCounts Array of trackCount keyframe counts, matching tracks.
* @param callbacks Array of trackCount callbacks, one per track, or NULL
* if no track has one.
* @param users Array of trackCount user pointers, matching callbacks, or
* NULL.
* @param trackCount The number of tracks.
* @param user Extra user data for the set as a whole; may be NULL.
*/
void keyframeSetInit(
keyframeset_t *set,
keyframe_t **tracks,
uint16_t *trackCounts,
keyframesetcallback_t *callbacks,
void **users,
uint16_t trackCount,
void *user
);
/**
* Gets the value of a single track at a given time.
*
* @param set The keyframe set to evaluate.
* @param trackIndex The track to evaluate, in [0, set->trackCount).
* @param time The time at which to get the value, in seconds.
* @return The interpolated value of that track at the given time.
*/
float_t keyframeSetGetValue(
keyframeset_t *set,
const uint16_t trackIndex,
const float_t time
);
/**
* Gets the value of every track at a given time.
*
* @param set The keyframe set to evaluate.
* @param time The time at which to get the values, in seconds.
* @param outValues Destination array of at least set->trackCount floats.
*/
void keyframeSetGetValues(
keyframeset_t *set,
const float_t time,
float_t *outValues
);
/**
* Gets the set's duration: the latest final-keyframe time across every
* track, i.e. how long it takes for every track to finish.
*
* @param set The keyframe set to measure.
* @return The set's duration, in seconds.
*/
float_t keyframeSetGetDuration(keyframeset_t *set);
/**
* Invokes a track's callback, if it (and its callbacks array) is set.
* No-op otherwise.
*
* @param set The keyframe set the track belongs to.
* @param trackIndex The track whose callback to invoke.
*/
void keyframeSetFireCallback(keyframeset_t *set, const uint16_t trackIndex);
+7
View File
@@ -8,6 +8,13 @@
#pragma once
#include "dusk.h"
// Release-style CMake configs (Release, RelWithDebInfo, MinSizeRel) define
// NDEBUG automatically; fake assertions there rather than requiring every
// platform's CMakeLists to set DUSK_ASSERTIONS_FAKED itself.
#if defined(NDEBUG) && !defined(DUSK_ASSERTIONS_FAKED)
#define DUSK_ASSERTIONS_FAKED
#endif
#ifdef DUSK_TEST_ASSERT
#include <cmocka.h>
+2 -2
View File
@@ -15,5 +15,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(display)
add_subdirectory(locale)
add_subdirectory(json)
add_subdirectory(chunk)
add_subdirectory(dmf)
add_subdirectory(dmf)
add_subdirectory(animation)
@@ -5,5 +5,5 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutsceneitemgive.c
assetanimationloader.c
)
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetanimationloader.h"
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
errorret_t assetAnimationLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Async loader should not be on main thread.");
if(
loading->loading.animation.state != ASSET_ANIMATION_LOADING_STATE_READ_FILE
) {
errorOk();
}
assertNull(loading->loading.animation.buffer, "Buffer already defined?");
assetfile_t *file = &loading->loading.animation.file;
assetLoaderErrorChain(
loading, assetFileInit(file, loading->entry->name, NULL, NULL)
);
if(file->size > ASSET_ANIMATION_FILE_SIZE_MAX) {
assetLoaderErrorThrow(
loading, "Animation JSON exceeds maximum allowed size"
);
}
uint8_t *buffer;
size_t size;
assetLoaderErrorChain(loading, assetFileReadEntire(file, &buffer, &size));
assetLoaderErrorChain(loading, assetFileDispose(file));
loading->loading.animation.buffer = buffer;
loading->loading.animation.size = size;
loading->loading.animation.state = ASSET_ANIMATION_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetAnimationLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_ANIMATION, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
switch(loading->loading.animation.state) {
case ASSET_ANIMATION_LOADING_STATE_INITIAL:
loading->loading.animation.state =
ASSET_ANIMATION_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
break;
case ASSET_ANIMATION_LOADING_STATE_PARSE:
break;
default:
errorOk();
}
uint8_t *buffer = loading->loading.animation.buffer;
assertNotNull(buffer, "Animation buffer should have been loaded by now.");
yyjson_doc *doc = yyjson_read(
(char *)buffer,
loading->loading.animation.size,
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
);
memoryFree(buffer);
loading->loading.animation.buffer = NULL;
if(!doc) assetLoaderErrorThrow(loading, "Failed to parse animation JSON");
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *channelsVal = yyjson_obj_get(root, "channels");
if(!channelsVal || !yyjson_is_arr(channelsVal)) {
yyjson_doc_free(doc);
assetLoaderErrorThrow(loading, "Animation JSON missing 'channels' array");
}
uint16_t channelCount = (uint16_t)yyjson_arr_size(channelsVal);
if(channelCount == 0) {
yyjson_doc_free(doc);
assetLoaderErrorThrow(loading, "Animation must have at least one channel");
}
keyframe_t **tracks = memoryAllocate(channelCount * sizeof(keyframe_t *));
uint16_t *trackCounts = memoryAllocate(channelCount * sizeof(uint16_t));
size_t idx, max;
yyjson_val *channelJson;
uint16_t parsed = 0;
yyjson_arr_foreach(channelsVal, idx, max, channelJson) {
keyframe_t *keyframes;
uint16_t count;
errorret_t ret =
assetAnimationParseChannel(channelJson, &keyframes, &count);
if(errorIsNotOk(ret)) {
assetAnimationFreeChannels(tracks, parsed);
memoryFree(trackCounts);
yyjson_doc_free(doc);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(ret);
}
tracks[idx] = keyframes;
trackCounts[idx] = count;
parsed++;
}
animationInit(
&loading->entry->data.animation, tracks, trackCounts, channelCount
);
yyjson_val *loopVal = yyjson_obj_get(root, "loop");
if(loopVal) loading->entry->data.animation.loop = yyjson_get_bool(loopVal);
yyjson_val *speedVal = yyjson_obj_get(root, "speed");
if(speedVal) {
loading->entry->data.animation.speed = (float_t)yyjson_get_num(speedVal);
}
yyjson_doc_free(doc);
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetAnimationDispose(assetentry_t *entry) {
assertNotNull(entry, "Asset entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_ANIMATION, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
// A load that failed before animationInit() ran (e.g. a parse error)
// never populated these -- nothing to free in that case.
keyframeset_t *set = &entry->data.animation.keyframes;
if(set->tracks) {
assetAnimationFreeChannels(set->tracks, set->trackCount);
memoryFree(set->trackCounts);
set->tracks = NULL;
set->trackCounts = NULL;
set->trackCount = 0;
}
errorOk();
}
errorret_t assetAnimationParseEasing(
const char_t *name,
easingtype_t *outEasing
) {
if(stringEquals(name, "LINEAR")) *outEasing = EASING_LINEAR;
else if(stringEquals(name, "IN_SINE")) *outEasing = EASING_IN_SINE;
else if(stringEquals(name, "OUT_SINE")) *outEasing = EASING_OUT_SINE;
else if(stringEquals(name, "IN_OUT_SINE")) *outEasing = EASING_IN_OUT_SINE;
else if(stringEquals(name, "IN_QUAD")) *outEasing = EASING_IN_QUAD;
else if(stringEquals(name, "OUT_QUAD")) *outEasing = EASING_OUT_QUAD;
else if(stringEquals(name, "IN_OUT_QUAD")) *outEasing = EASING_IN_OUT_QUAD;
else if(stringEquals(name, "IN_CUBIC")) *outEasing = EASING_IN_CUBIC;
else if(stringEquals(name, "OUT_CUBIC")) *outEasing = EASING_OUT_CUBIC;
else if(stringEquals(name, "IN_OUT_CUBIC")) *outEasing = EASING_IN_OUT_CUBIC;
else if(stringEquals(name, "IN_QUART")) *outEasing = EASING_IN_QUART;
else if(stringEquals(name, "OUT_QUART")) *outEasing = EASING_OUT_QUART;
else if(stringEquals(name, "IN_OUT_QUART")) *outEasing = EASING_IN_OUT_QUART;
else if(stringEquals(name, "IN_BACK")) *outEasing = EASING_IN_BACK;
else if(stringEquals(name, "OUT_BACK")) *outEasing = EASING_OUT_BACK;
else if(stringEquals(name, "IN_OUT_BACK")) *outEasing = EASING_IN_OUT_BACK;
else errorThrow("Unknown easing type '%s'", name);
errorOk();
}
errorret_t assetAnimationParseChannel(
yyjson_val *channelJson,
keyframe_t **outKeyframes,
uint16_t *outCount
) {
if(!yyjson_is_arr(channelJson)) {
errorThrow("Animation channel must be an array");
}
size_t count = yyjson_arr_size(channelJson);
if(count == 0) {
errorThrow("Animation channel must have at least one keyframe");
}
keyframe_t *keyframes = memoryAllocate(count * sizeof(keyframe_t));
size_t idx, max;
yyjson_val *keyframeJson;
yyjson_arr_foreach(channelJson, idx, max, keyframeJson) {
yyjson_val *timeVal = yyjson_obj_get(keyframeJson, "time");
yyjson_val *valueVal = yyjson_obj_get(keyframeJson, "value");
if(!timeVal || !valueVal) {
memoryFree(keyframes);
errorThrow("Animation keyframe missing 'time' or 'value'");
}
keyframes[idx].time = (float_t)yyjson_get_num(timeVal);
keyframes[idx].value = (float_t)yyjson_get_num(valueVal);
yyjson_val *easingVal = yyjson_obj_get(keyframeJson, "easing");
if(!easingVal) {
keyframes[idx].easing = EASING_LINEAR;
continue;
}
errorret_t ret = assetAnimationParseEasing(
yyjson_get_str(easingVal), &keyframes[idx].easing
);
if(errorIsNotOk(ret)) {
memoryFree(keyframes);
errorChain(ret);
}
}
*outKeyframes = keyframes;
*outCount = (uint16_t)count;
errorOk();
}
void assetAnimationFreeChannels(keyframe_t **tracks, const uint16_t count) {
for(uint16_t i = 0; i < count; i++) memoryFree(tracks[i]);
memoryFree(tracks);
}
@@ -0,0 +1,101 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "asset/assetfile.h"
#include "animation/animation.h"
#include "yyjson.h"
#define ASSET_ANIMATION_FILE_SIZE_MAX 1024*256
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
/**
* JSON animation file format:
* {
* "loop": false,
* "speed": 1.0,
* "channels": [
* [
* { "time": 0.0, "value": 0.0, "easing": "LINEAR" },
* { "time": 1.0, "value": 10.0 }
* ]
* ]
* }
*
* "loop" and "speed" are optional, defaulting to false and 1.0 (see
* animationInit()). "channels" is required and must have at least one
* entry; each entry is itself a non-empty array of keyframes, ascending
* by "time", sharing one timeline with every other channel (see
* keyframeset_t). Each keyframe requires "time" and "value"; "easing" is
* optional and defaults to "LINEAR" (see easingtype_t for the full set of
* names, e.g. "IN_QUAD", "OUT_BACK", "IN_OUT_CUBIC").
*/
typedef enum {
ASSET_ANIMATION_LOADING_STATE_INITIAL,
ASSET_ANIMATION_LOADING_STATE_READ_FILE,
ASSET_ANIMATION_LOADING_STATE_PARSE,
ASSET_ANIMATION_LOADING_STATE_DONE
} assetanimationloadingstate_t;
typedef struct {
assetfile_t file;
assetanimationloadingstate_t state;
uint8_t *buffer;
size_t size;
} assetanimationloaderloading_t;
typedef animation_t assetanimationoutput_t;
errorret_t assetAnimationLoaderAsync(assetloading_t *loading);
errorret_t assetAnimationLoaderSync(assetloading_t *loading);
errorret_t assetAnimationDispose(assetentry_t *entry);
/**
* Internal. Maps a JSON easing name (e.g. "IN_OUT_QUAD") to its
* easingtype_t.
*
* @param name The easing name to parse.
* @param outEasing Destination for the parsed easing type.
* @return Error state; fails if name doesn't match any easingtype_t.
*/
errorret_t assetAnimationParseEasing(
const char_t *name,
easingtype_t *outEasing
);
/**
* Internal. Parses one "channels" array entry into a heap-allocated
* keyframe_t array (memoryAllocate) -- freed later by
* assetAnimationFreeChannels() (on a parse failure) or
* assetAnimationDispose() (once loaded).
*
* @param channelJson The channel's JSON array of keyframe objects.
* @param outKeyframes Destination for the newly allocated keyframe array.
* @param outCount Destination for the number of keyframes parsed.
* @return Error state.
*/
errorret_t assetAnimationParseChannel(
yyjson_val *channelJson,
keyframe_t **outKeyframes,
uint16_t *outCount
);
/**
* Internal. Frees the first count entries of tracks (each a
* memoryAllocate'd keyframe_t array from assetAnimationParseChannel()),
* then frees tracks itself. Used both to unwind a partially-parsed
* channel list on failure and to free a fully-loaded animation's
* channels in assetAnimationDispose().
*
* @param tracks The channel array to free.
* @param count The number of entries in tracks to free.
*/
void assetAnimationFreeChannels(keyframe_t **tracks, const uint16_t count);
+4 -4
View File
@@ -46,9 +46,9 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.dispose = assetJsonDispose
},
[ASSET_LOADER_TYPE_CHUNK] = {
.loadSync = assetChunkLoaderSync,
.loadAsync = assetChunkLoaderAsync,
.dispose = assetChunkDispose
[ASSET_LOADER_TYPE_ANIMATION] = {
.loadSync = assetAnimationLoaderSync,
.loadAsync = assetAnimationLoaderAsync,
.dispose = assetAnimationDispose
},
};
+4 -5
View File
@@ -12,7 +12,7 @@
#include "asset/loader/display/assettilesetloader.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/animation/assetanimationloader.h"
typedef enum {
ASSET_LOADER_TYPE_NULL,
@@ -23,7 +23,7 @@ typedef enum {
ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_ANIMATION,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
@@ -35,7 +35,7 @@ typedef union {
assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk;
assetanimationloaderloading_t animation;
} assetloaderloading_t;
typedef union {
@@ -45,7 +45,7 @@ typedef union {
assettilesetoutput_t tileset;
assetlocaleoutput_t locale;
assetjsonoutput_t json;
assetchunkoutput_t chunk;
assetanimationoutput_t animation;
} assetloaderoutput_t;
typedef union {
@@ -53,7 +53,6 @@ typedef union {
assettilesetloaderinput_t tileset;
assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
} assetloaderinput_t;
typedef struct assetloading_s assetloading_t;
@@ -1,181 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetchunkloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/endian.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
errorOk();
}
assertNull(loading->loading.chunk.data, "Data already defined?");
assetfile_t *file = &loading->loading.chunk.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
uint8_t *data = memoryAllocate(file->size);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
assertTrue(
file->lastRead == file->size,
"Failed to read entire chunk file."
);
loading->loading.chunk.data = data;
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetChunkLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetchunkoutput_t *out = &loading->entry->data.chunk;
switch(loading->loading.chunk.state) {
case ASSET_CHUNK_LOADING_STATE_INITIAL:
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
case ASSET_CHUNK_LOADING_STATE_PARSE:
break;
case ASSET_CHUNK_LOADING_STATE_LOAD_MODELS:
while(loading->loading.chunk.modelIndex < out->meshCount) {
uint8_t m = loading->loading.chunk.modelIndex;
if(out->modelEntries[m] == NULL) {
out->modelEntries[m] = assetLock(
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
);
assertNotNull(
out->modelEntries[m], "Failed to lock model."
);
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
if(out->modelEntries[m]->state == ASSET_ENTRY_STATE_ERROR) {
assetLoaderErrorThrow(
loading, "Model failed to load: %s", out->modelNames[m]
);
}
if(out->modelEntries[m]->state != ASSET_ENTRY_STATE_LOADED) {
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
loading->loading.chunk.modelIndex++;
}
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
default:
errorOk();
}
uint8_t *data = loading->loading.chunk.data;
assertNotNull(data, "Chunk data should have been loaded by now.");
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'F') {
memoryFree(data);
assetLoaderErrorThrow(loading, "Invalid chunk file header");
}
uint32_t version = endianLittleToHost32(*(uint32_t *)(data + 4));
if(version != ASSET_CHUNK_FILE_VERSION) {
memoryFree(data);
assetLoaderErrorThrow(
loading, "Unsupported chunk file version %u", version
);
}
size_t offset = 8;
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
out->tiles = memoryAllocate(tileSize);
memoryCopy(out->tiles, data + offset, tileSize);
offset += tileSize;
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
*shape = endianLittleToHost32(*shape);
}
out->meshCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->meshCount <= CHUNK_MESH_COUNT_MAX,
"Chunk mesh count exceeds maximum."
);
for(uint8_t m = 0; m < out->meshCount; m++) {
uint8_t nameLen = 0;
while(
data[offset + nameLen] != '\0' &&
nameLen < CHUNK_MESH_NAME_MAX - 1
) {
nameLen++;
}
memoryCopy(out->modelNames[m], data + offset, nameLen);
out->modelNames[m][nameLen] = '\0';
offset += nameLen + 1;
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
offset += sizeof(vec3);
out->meshOffsets[m][0] = endianLittleToHostFloat(out->meshOffsets[m][0]);
out->meshOffsets[m][1] = endianLittleToHostFloat(out->meshOffsets[m][1]);
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
}
memoryFree(data);
loading->loading.chunk.data = NULL;
if(out->meshCount == 0) {
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS;
loading->loading.chunk.modelIndex = 0;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetChunkDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetchunkoutput_t *out = &entry->data.chunk;
if(out->tiles != NULL) {
memoryFree(out->tiles);
out->tiles = NULL;
}
for(uint8_t m = 0; m < out->meshCount; m++) {
if(out->modelEntries[m] == NULL) continue;
assetUnlockEntry(out->modelEntries[m]);
out->modelEntries[m] = NULL;
}
errorOk();
}
@@ -1,70 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/overworld/chunk.h"
#define ASSET_CHUNK_FILE_VERSION 4
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetchunkloaderinput_t;
typedef enum {
ASSET_CHUNK_LOADING_STATE_INITIAL,
ASSET_CHUNK_LOADING_STATE_READ_FILE,
ASSET_CHUNK_LOADING_STATE_PARSE,
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
ASSET_CHUNK_LOADING_STATE_DONE
} assetchunkloadingstate_t;
typedef struct {
assetfile_t file;
assetchunkloadingstate_t state;
uint8_t *data;
uint8_t modelIndex;
} assetchunkloaderloading_t;
typedef struct {
tile_t *tiles;
uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
} assetchunkoutput_t;
/**
* Asynchronous loader for chunk assets. Reads the raw DCF file bytes into
* the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetChunkLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for chunk assets. Validates the DCF binary previously
* read by the async phase and populates the output assetchunkoutput_t with
* tile data and model paths.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetChunkLoaderSync(assetloading_t *loading);
/**
* Disposer for chunk assets.
*
* @param entry Asset entry containing the chunk data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetChunkDispose(assetentry_t *entry);
@@ -127,14 +127,6 @@ errorret_t assetMeshLoaderSync(assetloading_t *loading) {
errorChain(ret);
}
#if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
// VBO owns the data now; CPU copy is no longer needed. The platform
// mesh object itself still needs meshDispose later - tracked via
// out->meshInitialized, independent of the CPU buffer's lifetime.
memoryFree(out->vertices);
out->vertices = NULL;
#endif
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
+1 -1
View File
@@ -56,7 +56,7 @@ void consoleUpdate(void) {
if(TIME.dynamicUpdate) return;
#endif
if(inputPressed(INPUT_ACTION_CONSOLE)) {
if(inputPressed(INPUT_BIND_CONSOLE)) {
CONSOLE.visible = !CONSOLE.visible;
}
}
+2 -2
View File
@@ -12,8 +12,8 @@ mesh_t CUBE_MESH_SIMPLE;
meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT];
errorret_t cubeInit() {
vec3 min = { 0.0f, 0.0f, 0.0f };
vec3 max = { 1.0f, 1.0f, 1.0f };
vec3 min = { -0.5f, -0.5f, -0.5f };
vec3 max = { 0.5f, 0.5f, 0.5f };
cubeBuffer(CUBE_MESH_SIMPLE_VERTICES, min, max);
errorChain(meshInit(
&CUBE_MESH_SIMPLE,
+3 -1
View File
@@ -17,7 +17,9 @@ extern mesh_t CUBE_MESH_SIMPLE;
extern meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT];
/**
* Initializes the simple unit cube mesh (0,0,0) to (1,1,1).
* Initializes the simple unit cube mesh, centered at (0,0,0), spanning
* (-0.5,-0.5,-0.5) to (0.5,0.5,0.5) -- matching the centered convention
* physics shapes and the sphere mesh use (position = center).
*
* @return Error for initialization of the cube mesh.
*/
+13 -7
View File
@@ -59,7 +59,8 @@ void planeBuffer(
switch(axis) {
case PLANE_AXIS_XY: {
/* Flat in XY at z = min[2]; spans X and Y. */
// Flat in XY at z = min[2]; spans X and Y.
// +Z normal: CCW when viewed from +Z (matches cube.c's front face).
const float_t z = min[2];
PLANE_VERT(0, min[0], min[1], z, u0, v0)
PLANE_VERT(1, max[0], min[1], z, u1, v0)
@@ -71,19 +72,24 @@ void planeBuffer(
}
case PLANE_AXIS_XZ: {
/* Flat in XZ at y = min[1]; spans X and Z. */
// Flat in XZ at y = min[1]; spans X and Z.
// +Y normal: CCW when viewed from +Y (matches cube.c's top face).
// X and Z swap handedness relative to XY/YZ (right-hand rule with Y
// up), so the corner order here is deliberately not a straight copy
// of the XY/YZ pattern -- copying it as-is would flip this to -Y.
const float_t y = min[1];
PLANE_VERT(0, min[0], y, min[2], u0, v0)
PLANE_VERT(1, max[0], y, min[2], u1, v0)
PLANE_VERT(2, max[0], y, max[2], u1, v1)
PLANE_VERT(1, max[0], y, max[2], u1, v1)
PLANE_VERT(2, max[0], y, min[2], u1, v0)
PLANE_VERT(3, min[0], y, min[2], u0, v0)
PLANE_VERT(4, max[0], y, max[2], u1, v1)
PLANE_VERT(5, min[0], y, max[2], u0, v1)
PLANE_VERT(4, min[0], y, max[2], u0, v1)
PLANE_VERT(5, max[0], y, max[2], u1, v1)
break;
}
case PLANE_AXIS_YZ: {
/* Flat in YZ at x = min[0]; spans Y and Z. */
// Flat in YZ at x = min[0]; spans Y and Z.
// +X normal: CCW when viewed from +X (matches cube.c's right face).
const float_t x = min[0];
PLANE_VERT(0, x, min[1], min[2], u0, v0)
PLANE_VERT(1, x, max[1], min[2], u1, v0)
@@ -39,3 +39,16 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition(
sprite.uvMax[1] = uv[3];
return sprite;
}
spritebatchsprite_t spriteBatchSpriteTranslate(
const spritebatchsprite_t *sprite,
const float_t x,
const float_t y
) {
spritebatchsprite_t out = *sprite;
out.min[0] += x;
out.min[1] += y;
out.max[0] += x;
out.max[1] += y;
return out;
}
@@ -38,3 +38,19 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition(
const float_t width,
const float_t height
);
/**
* Returns a copy of sprite translated by (x, y). Used to reposition a
* sprite cached relative to origin (0,0) at draw time, instead of
* re-deriving its geometry from scratch every frame.
*
* @param sprite The cached sprite, relative to origin (0,0).
* @param x X offset to translate by.
* @param y Y offset to translate by.
* @returns The translated sprite.
*/
spritebatchsprite_t spriteBatchSpriteTranslate(
const spritebatchsprite_t *sprite,
const float_t x,
const float_t y
);
+1
View File
@@ -6,5 +6,6 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
font.c
text.c
)
+168
View File
@@ -0,0 +1,168 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "font.h"
#include "util/memory.h"
#include "util/math.h"
#include "display/color.h"
font_t FONT_DEFAULT;
static texture_t FONT_DEFAULT_TEXTURE;
static tileset_t FONT_DEFAULT_TILESET;
const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
FONT_DEFAULT_TILE_HEIGHT
] = {
{ 0x00, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x10, 0x00, 0x00 }, // !
{ 0x00, 0x14, 0x14, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // "
{ 0x00, 0x14, 0x14, 0x3E, 0x14, 0x3E, 0x14, 0x14, 0x00, 0x00 }, // #
{ 0x00, 0x08, 0x1E, 0x28, 0x1C, 0x0A, 0x3C, 0x08, 0x00, 0x00 }, // $
{ 0x00, 0x00, 0x22, 0x24, 0x08, 0x12, 0x22, 0x00, 0x00, 0x00 }, // %
{ 0x00, 0x08, 0x14, 0x14, 0x1A, 0x24, 0x24, 0x1A, 0x00, 0x00 }, // &
{ 0x00, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '
{ 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, 0x08, 0x04, 0x00, 0x00 }, // (
{ 0x00, 0x10, 0x08, 0x08, 0x08, 0x08, 0x08, 0x10, 0x00, 0x00 }, // )
{ 0x00, 0x00, 0x08, 0x2A, 0x1C, 0x2A, 0x08, 0x00, 0x00, 0x00 }, // *
{ 0x00, 0x00, 0x08, 0x08, 0x3E, 0x08, 0x08, 0x00, 0x00, 0x00 }, // +
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x20, 0x00 }, // ,
{ 0x00, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00 }, // -
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x00, 0x00 }, // .
{ 0x00, 0x04, 0x04, 0x08, 0x08, 0x08, 0x10, 0x10, 0x00, 0x00 }, // /
{ 0x00, 0x1C, 0x22, 0x26, 0x2A, 0x32, 0x22, 0x1C, 0x00, 0x00 }, // 0
{ 0x00, 0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // 1
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // 2
{ 0x00, 0x1C, 0x22, 0x02, 0x0C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 3
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x02, 0x02, 0x02, 0x00, 0x00 }, // 4
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 5
{ 0x00, 0x1C, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 6
{ 0x00, 0x3E, 0x02, 0x02, 0x04, 0x08, 0x08, 0x08, 0x00, 0x00 }, // 7
{ 0x00, 0x1C, 0x22, 0x22, 0x1C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 8
{ 0x00, 0x1C, 0x22, 0x22, 0x1E, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 9
{ 0x00, 0x00, 0x10, 0x10, 0x00, 0x10, 0x10, 0x00, 0x00, 0x00 }, // :
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ;
{ 0x00, 0x04, 0x08, 0x10, 0x20, 0x10, 0x08, 0x04, 0x00, 0x00 }, // <
{ 0x00, 0x00, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00 }, // =
{ 0x00, 0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00 }, // >
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x00, 0x08, 0x00, 0x00 }, // ?
{ 0x00, 0x1C, 0x26, 0x2A, 0x2A, 0x26, 0x20, 0x1C, 0x00, 0x00 }, // @
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x00, 0x00 }, // A
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // B
{ 0x00, 0x1C, 0x22, 0x20, 0x20, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // C
{ 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // D
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // E
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // F
{ 0x00, 0x1C, 0x22, 0x20, 0x2E, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // G
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x22, 0x00, 0x00 }, // H
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // I
{ 0x00, 0x02, 0x02, 0x02, 0x02, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // J
{ 0x00, 0x22, 0x24, 0x28, 0x30, 0x28, 0x24, 0x22, 0x00, 0x00 }, // K
{ 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // L
{ 0x00, 0x22, 0x36, 0x2A, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // M
{ 0x00, 0x22, 0x22, 0x32, 0x2A, 0x26, 0x22, 0x22, 0x00, 0x00 }, // N
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // O
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // P
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x06, 0x00 }, // Q
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x22, 0x00, 0x00 }, // R
{ 0x00, 0x1C, 0x22, 0x20, 0x1C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // S
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // T
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // U
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x14, 0x14, 0x08, 0x00, 0x00 }, // V
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x2A, 0x36, 0x22, 0x00, 0x00 }, // W
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x14, 0x22, 0x22, 0x00, 0x00 }, // X
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // Y
{ 0x00, 0x3E, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3E, 0x00, 0x00 }, // Z
{ 0x00, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0C, 0x00, 0x00 }, // [
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // backslash (not drawn in source font)
{ 0x00, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x18, 0x00, 0x00 }, // ]
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ^ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // _ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ` (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // a
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // b
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // c
{ 0x00, 0x02, 0x02, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // d
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x3E, 0x20, 0x1C, 0x00, 0x00 }, // e
{ 0x00, 0x0C, 0x12, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x00, 0x00 }, // f
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // g
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // h
{ 0x00, 0x08, 0x00, 0x18, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // i
{ 0x00, 0x02, 0x00, 0x06, 0x02, 0x02, 0x02, 0x02, 0x22, 0x1C }, // j
{ 0x00, 0x20, 0x20, 0x22, 0x24, 0x38, 0x24, 0x22, 0x00, 0x00 }, // k
{ 0x00, 0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // l
{ 0x00, 0x00, 0x00, 0x3C, 0x2A, 0x2A, 0x2A, 0x2A, 0x00, 0x00 }, // m
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // n
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // o
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x20, 0x20 }, // p
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x02 }, // q
{ 0x00, 0x00, 0x00, 0x2C, 0x32, 0x20, 0x20, 0x20, 0x00, 0x00 }, // r
{ 0x00, 0x00, 0x00, 0x1E, 0x20, 0x1C, 0x02, 0x3C, 0x00, 0x00 }, // s
{ 0x00, 0x10, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // t
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // u
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x14, 0x08, 0x00, 0x00 }, // v
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x2A, 0x2A, 0x14, 0x00, 0x00 }, // w
{ 0x00, 0x00, 0x00, 0x22, 0x14, 0x08, 0x14, 0x22, 0x00, 0x00 }, // x
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // y
{ 0x00, 0x00, 0x00, 0x3E, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // z
{ 0x00, 0x04, 0x08, 0x08, 0x10, 0x08, 0x08, 0x04, 0x00, 0x00 }, // {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // | (not drawn in source font)
{ 0x00, 0x10, 0x08, 0x08, 0x04, 0x08, 0x08, 0x10, 0x00, 0x00 }, // }
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ~ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
};
errorret_t fontInitDefault(void) {
const int32_t width = (int32_t)mathNextPowTwo(
FONT_DEFAULT_COLUMNS * FONT_DEFAULT_TILE_WIDTH
);
const int32_t height = (int32_t)mathNextPowTwo(
FONT_DEFAULT_ROWS * FONT_DEFAULT_TILE_HEIGHT
);
color_t *pixels = memoryAllocate(sizeof(color_t) * width * height);
memoryZero(pixels, sizeof(color_t) * width * height);
for(uint16_t i = 0; i < FONT_DEFAULT_TILE_COUNT; i++) {
const uint16_t tileX = (i % FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_WIDTH;
const uint16_t tileY = (i / FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_HEIGHT;
for(uint8_t row = 0; row < FONT_DEFAULT_TILE_HEIGHT; row++) {
const uint8_t bits = FONT_DEFAULT_GLYPHS[i][row];
for(uint8_t col = 0; col < FONT_DEFAULT_TILE_WIDTH; col++) {
if(!((bits >> (FONT_DEFAULT_TILE_WIDTH - 1 - col)) & 1)) continue;
pixels[((tileY + row) * width) + (tileX + col)] = COLOR_WHITE;
}
}
}
FONT_DEFAULT_TILESET.tileWidth = FONT_DEFAULT_TILE_WIDTH;
FONT_DEFAULT_TILESET.tileHeight = FONT_DEFAULT_TILE_HEIGHT;
FONT_DEFAULT_TILESET.columns = FONT_DEFAULT_COLUMNS;
FONT_DEFAULT_TILESET.rows = FONT_DEFAULT_ROWS;
FONT_DEFAULT_TILESET.tileCount = FONT_DEFAULT_TILE_COUNT;
FONT_DEFAULT_TILESET.uv[0] = (float_t)FONT_DEFAULT_TILE_WIDTH / (float_t)width;
FONT_DEFAULT_TILESET.uv[1] = (float_t)FONT_DEFAULT_TILE_HEIGHT / (float_t)height;
const texturedata_t data = { .rgbaColors = pixels };
errorret_t textureResult = textureInit(
&FONT_DEFAULT_TEXTURE, width, height, TEXTURE_FORMAT_RGBA, data
);
memoryFree(pixels);
errorChain(textureResult);
FONT_DEFAULT.texture = &FONT_DEFAULT_TEXTURE;
FONT_DEFAULT.tileset = &FONT_DEFAULT_TILESET;
errorOk();
}
errorret_t fontDisposeDefault(void) {
errorChain(textureDispose(&FONT_DEFAULT_TEXTURE));
FONT_DEFAULT.texture = NULL;
FONT_DEFAULT.tileset = NULL;
errorOk();
}
+51
View File
@@ -6,6 +6,7 @@
*/
#pragma once
#include "error/error.h"
#include "display/texture/texture.h"
#include "display/texture/tileset.h"
@@ -13,3 +14,53 @@ typedef struct {
texture_t *texture;
tileset_t *tileset;
} font_t;
/**
* Pixel width/height of a single default-font glyph tile. Matches the
* glyph grid baked into the (now retired) ui/minogram.png + .dtf asset
* pair this data was extracted from.
*/
#define FONT_DEFAULT_TILE_WIDTH 6
#define FONT_DEFAULT_TILE_HEIGHT 10
/** Grid layout of the generated default-font texture, in tiles. */
#define FONT_DEFAULT_COLUMNS 16
#define FONT_DEFAULT_ROWS 6
/**
* Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS *
* FONT_DEFAULT_ROWS), covering the printable ASCII range starting at
* TEXT_CHAR_START ('!') plus a couple of unused trailing tiles.
*/
#define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS)
extern font_t FONT_DEFAULT;
/**
* Hard coded bitmap data for the built-in default font, extracted
* pixel-for-pixel from the original ui/minogram.png glyph atlas (alpha
* >= 128 counts as set). Indexed [glyph][row], where glyph 0 corresponds
* to TEXT_CHAR_START ('!') and glyphs run consecutively through the
* printable ASCII range. Each row byte holds FONT_DEFAULT_TILE_WIDTH bit
* flags, one per pixel column: bit (FONT_DEFAULT_TILE_WIDTH - 1) is the
* leftmost pixel and bit 0 is the rightmost; 1 means the pixel is set,
* 0 means it is not.
*/
extern const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
FONT_DEFAULT_TILE_HEIGHT
];
/**
* Builds the built-in default font (texture + tileset) directly from
* FONT_DEFAULT_GLYPHS, without going through the asset system.
*
* @return Either an error or success result.
*/
errorret_t fontInitDefault(void);
/**
* Disposes of the built-in default font created by fontInitDefault.
*
* @return Either an error or success result.
*/
errorret_t fontDisposeDefault(void);
+85 -21
View File
@@ -9,34 +9,15 @@
#include "assert/assert.h"
#include "util/memory.h"
#include "display/spritebatch/spritebatch.h"
#include "asset/asset.h"
#include "asset/loader/display/assettextureloader.h"
#include "asset/loader/display/assettilesetloader.h"
#include "display/shader/shaderunlit.h"
font_t FONT_DEFAULT;
errorret_t textInit(void) {
assetloaderinput_t input = { .texture = TEXTURE_FORMAT_RGBA };
assetentry_t *entryTexture = assetLock(
"ui/minogram.png", ASSET_LOADER_TYPE_TEXTURE, &input
);
assetentry_t *entryTileset = assetLock(
"ui/minogram.dtf", ASSET_LOADER_TYPE_TILESET, NULL
);
errorChain(assetRequireLoaded(entryTexture));
errorChain(assetRequireLoaded(entryTileset));
FONT_DEFAULT.texture = &entryTexture->data.texture;
FONT_DEFAULT.tileset = &entryTileset->data.tileset;
errorChain(fontInitDefault());
errorOk();
}
errorret_t textDispose(void) {
FONT_DEFAULT.texture = NULL;
FONT_DEFAULT.tileset = NULL;
assetUnlock("ui/minogram.png");
assetUnlock("ui/minogram.dtf");
errorChain(fontDisposeDefault());
errorOk();
}
@@ -116,6 +97,89 @@ errorret_t textDraw(
errorOk();
}
uint32_t textBuildSpriteCache(
const char_t *text,
const font_t *font,
spritebatchsprite_t *sprites,
const uint32_t spritesMax,
int32_t *outWidth,
int32_t *outHeight
) {
assertNotNull(text, "Text cannot be NULL");
assertNotNull(font, "Font cannot be NULL");
assertNotNull(sprites, "Sprites cannot be NULL");
assertNotNull(outWidth, "Output width cannot be NULL");
assertNotNull(outHeight, "Output height cannot be NULL");
uint32_t count = 0;
float_t posX = 0.0f;
float_t posY = 0.0f;
int32_t width = 0;
int32_t lineWidth = 0;
int32_t height = font->tileset->tileHeight;
char_t c;
int32_t i = 0;
while((c = text[i++]) != '\0') {
if(c == '\n') {
if(lineWidth > width) width = lineWidth;
lineWidth = 0;
posX = 0.0f;
posY += font->tileset->tileHeight;
height += font->tileset->tileHeight;
continue;
}
if(c == ' ') {
posX += font->tileset->tileWidth;
lineWidth += font->tileset->tileWidth;
continue;
}
assertTrue(count < spritesMax, "Text produces too many sprites");
sprites[count++] = textGetSprite((vec2){ posX, posY }, c, font);
posX += font->tileset->tileWidth;
lineWidth += font->tileset->tileWidth;
}
if(lineWidth > width) width = lineWidth;
*outWidth = width;
*outHeight = height;
return count;
}
errorret_t textDrawSpriteCache(
const spritebatchsprite_t *sprites,
const uint32_t spriteCount,
spritebatchsprite_t *scratch,
const float_t x,
const float_t y,
const color_t color,
texture_t *texture
) {
assertNotNull(scratch, "Scratch buffer cannot be NULL");
assertNotNull(texture, "Texture cannot be NULL");
if(spriteCount == 0) errorOk();
for(uint32_t i = 0; i < spriteCount; i++) {
scratch[i] = sprites[i];
scratch[i].min[0] += x;
scratch[i].min[1] += y;
scratch[i].max[0] += x;
scratch[i].max[1] += y;
}
shadermaterial_t material = {
.unlit = {
.color = color,
.texture = texture
}
};
errorChain(spriteBatchBuffer(scratch, spriteCount, &SHADER_UNLIT, material));
errorOk();
}
void textMeasure(
const char_t *text,
const font_t *font,
+48 -2
View File
@@ -12,8 +12,6 @@
#define TEXT_CHAR_START '!'
extern font_t FONT_DEFAULT;
/**
* Initializes the text system.
*
@@ -60,6 +58,54 @@ errorret_t textDraw(
font_t *font
);
/**
* Builds a cache of sprites (glyph geometry + UVs, relative to origin
* 0,0) for a string of text. Callers that redraw the same text every
* frame (e.g. UI labels/widgets) should build this once and reuse it
* via textDrawSpriteCache, instead of re-deriving glyph geometry every
* frame the way textDraw does.
*
* @param text The null-terminated string to build sprites for.
* @param font Font to use for tile lookup.
* @param sprites Destination array to write sprites into.
* @param spritesMax Capacity of the sprites array.
* @param outWidth Pointer to store the measured width in pixels.
* @param outHeight Pointer to store the measured height in pixels.
* @return The number of sprites written.
*/
uint32_t textBuildSpriteCache(
const char_t *text,
const font_t *font,
spritebatchsprite_t *sprites,
const uint32_t spritesMax,
int32_t *outWidth,
int32_t *outHeight
);
/**
* Draws a previously-built sprite cache (see textBuildSpriteCache) at the
* given position in a single batched draw call.
*
* @param sprites Cached sprites, relative to origin 0,0.
* @param spriteCount Number of sprites in the cache.
* @param scratch Caller-owned scratch buffer, at least spriteCount
* entries, used to translate the cached sprites into position.
* @param x The x-coordinate to draw the text at.
* @param y The y-coordinate to draw the text at.
* @param color The color to draw the text in.
* @param texture The font's texture to sample glyphs from.
* @return Either an error or success result.
*/
errorret_t textDrawSpriteCache(
const spritebatchsprite_t *sprites,
const uint32_t spriteCount,
spritebatchsprite_t *scratch,
const float_t x,
const float_t y,
const color_t color,
texture_t *texture
);
/**
* Measures the width and height of the given text string when rendered.
*
+13 -82
View File
@@ -10,18 +10,20 @@
#include "time/time.h"
#include "input/input.h"
#include "locale/localemanager.h"
#include "rpg/rpg.h"
#include "display/display.h"
#include "scene/scene.h"
#include "asset/asset.h"
#include "script/scriptmanager.h"
#include "script/module/scene/modulescene.h"
#include "ui/ui.h"
#include "assert/assert.h"
#include "network/network.h"
#include "network/http/networkhttprequest.h"
#include "game/game.h"
#include "system/system.h"
#include "console/console.h"
#include "save/save.h"
#include "save/savesettings.h"
#include "log/log.h"
engine_t ENGINE;
@@ -39,21 +41,15 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorChain(systemInit());
errorChain(inputInit());
errorChain(assetInit());
errorChain(scriptManagerInit());
errorChain(saveInit());
errorChain(saveSettingsLoad());
errorChain(localeManagerInit());
errorChain(displayInit());
errorChain(uiInit());
errorChain(rpgInit());
errorChain(networkInit());
errorChain(sceneInit());
networkRequestConnection(
engineNetworkOnConnected,
engineNetworkOnFailed,
engineNetworkOnDisconnect,
NULL
);
errorChain(gameInit());
consolePrint("Engine initialized");
@@ -63,7 +59,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
consolePrint("Assertions real");
#endif
sceneSet(SCENE_TYPE_OVERWORLD);
// sceneSet(SCENE_TYPE_OVERWORLD);
errorOk();
}
@@ -73,19 +69,13 @@ errorret_t engineUpdate(void) {
errorChain(networkUpdate());
timeUpdate();
if(
ENGINE.networkDisconnectTestPending &&
TIME.time >= ENGINE.networkDisconnectTestAt
) {
ENGINE.networkDisconnectTestPending = false;
networkRequestDisconnection(engineNetworkDisconnectTestOnComplete, NULL);
}
const systemdialogtype_t dialogType = systemGetActiveDialogType();
if(dialogType == SYSTEM_DIALOG_TYPE_NONE) {
inputUpdate();
consoleUpdate();
errorChain(rpgUpdate());
errorChain(gameUpdate());
errorChain(moduleSceneUpdateCurrent());
errorChain(sceneUpdate());
errorChain(assetUpdate());
errorChain(uiUpdate());
@@ -95,7 +85,7 @@ errorret_t engineUpdate(void) {
errorChain(displayUpdate());
if(
dialogType == SYSTEM_DIALOG_TYPE_NONE &&
inputPressed(INPUT_ACTION_RAGEQUIT)
inputPressed(INPUT_BIND_RAGEQUIT)
) {
ENGINE.running = false;
}
@@ -107,75 +97,16 @@ void engineExit(void) {
}
errorret_t engineDispose(void) {
errorChain(gameDispose());
errorChain(sceneDispose());
errorChain(networkDispose());
errorChain(rpgDispose());
localeManagerDispose();
errorChain(uiDispose());
consoleDispose();
errorChain(displayDispose());
errorChain(saveDispose());
errorChain(scriptManagerDispose());
errorChain(assetDispose());
errorOk();
}
void engineHttpTestOnComplete(void *params, void *user) {
networkhttprequest_t *request = (networkhttprequest_t *)params;
const networkhttpresponse_t *response = &request->response;
consolePrint("frogfind.com response status: %d", response->status);
for(uint32_t i = 0; i < response->headers.count; i++) {
const networkhttpheader_t *header = &response->headers.headers[i];
consolePrint("frogfind.com header: %s: %s", header->name, header->value);
}
consolePrint("frogfind.com body length: %d", (int32_t)response->bodyLength);
const size_t chunkMax = CONSOLE_LINE_MAX - 32;
for(size_t offset = 0; offset < response->bodyLength; offset += chunkMax) {
size_t chunkLength = response->bodyLength - offset;
if(chunkLength > chunkMax) chunkLength = chunkMax;
consolePrint("frogfind.com body: %.*s",
(int32_t)chunkLength, (const char_t *)response->body + offset);
}
}
void engineHttpTestOnError(void *params, void *user) {
consolePrint("frogfind.com request failed");
}
void engineNetworkOnConnected(void *user) {
consolePrint("Network connected");
errorret_t ret = networkHttpRequest(
NETWORK_HTTP_METHOD_GET,
"http://frogfind.com",
NULL, 0,
NULL, 0,
NULL, 0,
engineHttpTestOnComplete,
engineHttpTestOnError,
NULL
);
errorCatch(errorPrint(ret));
ENGINE.networkDisconnectTestAt = TIME.time + 10.0f;
ENGINE.networkDisconnectTestPending = true;
}
void engineNetworkOnFailed(errorret_t error, void *user) {
consolePrint("Network connection failed");
errorCatch(errorPrint(error));
}
void engineNetworkOnDisconnect(errorret_t error, void *user) {
consolePrint("Network disconnected");
errorCatch(errorPrint(error));
}
void engineNetworkDisconnectTestOnComplete(void *user) {
consolePrint("Network disconnect test complete");
}
-53
View File
@@ -16,10 +16,6 @@ typedef struct {
int32_t argc;
const char_t **argv;
const char_t *version;
// Test: disconnects the network 10 seconds after it connects.
bool_t networkDisconnectTestPending;
float_t networkDisconnectTestAt;
} engine_t;
extern engine_t ENGINE;
@@ -41,52 +37,3 @@ errorret_t engineUpdate(void);
* Shuts down the engine.
*/
errorret_t engineDispose(void);
/**
* Logs the response status, headers and body of the frogfind.com test
* request to the console.
*
* @param params The completed networkhttprequest_t.
* @param user Unused.
*/
void engineHttpTestOnComplete(void *params, void *user);
/**
* Logs that the frogfind.com test request failed.
*
* @param params The failed networkhttprequest_t.
* @param user Unused.
*/
void engineHttpTestOnError(void *params, void *user);
/**
* Fires the frogfind.com test request once the network connection is
* up (some platforms, such as PSP, only bring the network stack up
* asynchronously after networkRequestConnection is called).
*
* @param user Unused.
*/
void engineNetworkOnConnected(void *user);
/**
* Logs that the network connection could not be established.
*
* @param error The error describing why the connection failed.
* @param user Unused.
*/
void engineNetworkOnFailed(errorret_t error, void *user);
/**
* Logs that the network connection was lost after having connected.
*
* @param error The error describing why the connection was lost.
* @param user Unused.
*/
void engineNetworkOnDisconnect(errorret_t error, void *user);
/**
* Logs that the 10-second test disconnect completed.
*
* @param user Unused.
*/
void engineNetworkDisconnectTestOnComplete(void *user);
+16
View File
@@ -0,0 +1,16 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
entity.c
entitymanager.c
component.c
entityprefab.c
)
# Subdirs
add_subdirectory(component)
+152
View File
@@ -0,0 +1,152 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitymanager.h"
#include "assert/assert.h"
#include "util/memory.h"
componentdefinition_t COMPONENT_DEFINITIONS[] = {
[COMPONENT_TYPE_NULL] = { 0 },
#define X(enm, type, field, iMethod, dMethod, rMethod) \
[COMPONENT_TYPE_##enm] = { \
.enumName = #enm, \
.name = #field, \
.init = iMethod, \
.dispose = dMethod, \
.render = rMethod \
},
#include "componentlist.h"
#undef X
[COMPONENT_TYPE_COUNT] = { 0 }
};
void componentInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const componenttype_t type
) {
assertNotNull(mgr, "Entity manager cannot be null");
assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB");
assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB");
assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB");
assertTrue(type != COMPONENT_TYPE_NULL, "Cannot initialize null component");
componentindex_t index = componentGetIndex(entityId, componentId);
component_t *cmp = &mgr->components[index];
memoryZero(cmp, sizeof(component_t));
cmp->type = type;
if(COMPONENT_DEFINITIONS[type].init) {
COMPONENT_DEFINITIONS[type].init(mgr, entityId, componentId);
}
}
void * componentGetData(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const componenttype_t type
) {
assertNotNull(mgr, "Entity manager cannot be null");
assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB");
assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB");
assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB");
assertTrue(type != COMPONENT_TYPE_NULL, "Cannot get data of null component");
componentindex_t index = componentGetIndex(entityId, componentId);
component_t *cmp = &mgr->components[index];
assertTrue(cmp->type == type, "Component type mismatch");
return &cmp->data;
}
componentindex_t componentGetIndex(
const entityid_t entityId,
const componentid_t componentId
) {
assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB");
assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB");
return (entityId * ENTITY_COMPONENT_COUNT_MAX) + componentId;
}
entityid_t componentGetEntitiesWithComponent(
entitymanager_t *mgr,
const componenttype_t type,
entityid_t outEntities[ENTITY_COUNT_MAX],
componentid_t outComponents[ENTITY_COUNT_MAX]
) {
assertNotNull(mgr, "Entity manager cannot be null");
assertTrue(type < COMPONENT_TYPE_COUNT, "Component type OOB");
assertTrue(type != COMPONENT_TYPE_NULL, "Cannot check NULL type");
assertNotNull(outEntities, "Output entities array cannot be null");
assertNotNull(outComponents, "Output components array cannot be null");
entityid_t written = 0;
for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) {
componentid_t used = mgr->entitiesWithComponent[
type * ENTITY_COUNT_MAX + i
];
if(used == COMPONENT_ID_INVALID) continue;
assertTrue(
mgr->components[componentGetIndex(i, used)].type == type,
"Component type mismatch in entitiesWithComponent lookup"
);
assertTrue(
(mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0,
"Inactive entity in entitiesWithComponent lookup"
);
assertTrue(
used < ENTITY_COMPONENT_COUNT_MAX,
"Component ID OOB in entitiesWithComponent lookup"
);
assertTrue(
componentGetIndex(i,used) < ENTITY_COUNT_MAX*ENTITY_COMPONENT_COUNT_MAX,
"Component index OOB in entitiesWithComponent lookup"
);
outComponents[written] = used;
outEntities[written++] = i;
}
return written;
}
errorret_t componentRenderAll(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
for(entityid_t eid = 0; eid < ENTITY_COUNT_MAX; eid++) {
if(!(mgr->entities[eid].state & ENTITY_STATE_ACTIVE)) continue;
for(componentid_t cid = 0; cid < ENTITY_COMPONENT_COUNT_MAX; cid++) {
component_t *cmp = &mgr->components[componentGetIndex(eid, cid)];
if(cmp->type == COMPONENT_TYPE_NULL) continue;
if(!COMPONENT_DEFINITIONS[cmp->type].render) continue;
errorChain(COMPONENT_DEFINITIONS[cmp->type].render(mgr, eid, cid));
}
}
errorOk();
}
void componentDispose(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
assertNotNull(mgr, "Entity manager cannot be null");
assertTrue(entityId < ENTITY_COUNT_MAX, "Entity ID OOB");
assertTrue(componentId < ENTITY_COMPONENT_COUNT_MAX, "Component ID OOB");
componentindex_t index = componentGetIndex(entityId, componentId);
component_t *cmp = &mgr->components[index];
if(cmp->type == COMPONENT_TYPE_NULL) return;
if(COMPONENT_DEFINITIONS[cmp->type].dispose) {
COMPONENT_DEFINITIONS[cmp->type].dispose(mgr, entityId, componentId);
}
cmp->type = COMPONENT_TYPE_NULL;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entitybase.h"
#include "error/error.h"
#define X(enumName, type, field, init, dispose, render) \
// do nothing
#include "componentlist.h"
#undef X
typedef union {
#define X(enumName, type, field, init, dispose, render) type field;
#include "componentlist.h"
#undef X
} componentdata_t;
/**
* Callback signature for a component's init/dispose hooks.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
typedef void (*componentcallback_t)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Callback signature for a component's render hook.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return Error state.
*/
typedef errorret_t (*componentcallbackerror_t)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
typedef struct {
const char_t *enumName;
const char_t *name;
componentcallback_t init;
componentcallback_t dispose;
componentcallbackerror_t render;
} componentdefinition_t;
typedef enum {
COMPONENT_TYPE_NULL,
#define X(enumName, type, field, init, dispose, render) \
COMPONENT_TYPE_##enumName,
#include "componentlist.h"
#undef X
COMPONENT_TYPE_COUNT
} componenttype_t;
typedef struct {
componenttype_t type;
componentdata_t data;
} component_t;
extern componentdefinition_t COMPONENT_DEFINITIONS[];
/**
* Initializes a component of the given type for the entity with component ID.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param type The type of the component to initialize.
*/
void componentInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const componenttype_t type
);
/**
* Gets the pointer to the data of a component for the entity with component ID.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param type The type of the component to get, only used for assertion.
* @return A pointer to the component data.
*/
void * componentGetData(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const componenttype_t type
);
/**
* Gets the index of a component for the entity with component ID.
*
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The index of the component in the component array.
*/
componentindex_t componentGetIndex(
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the entity IDs of all entities with a component of the given type.
*
* @param mgr The entity manager to search.
* @param type The type of the component to get entities for.
* @param outEntities An array to write the entity IDs to, must be at least
* ENTITY_COUNT_MAX in size.
* @param outComponents An array to write the component IDs to.
* @return The number of entity IDs written to outEntities.
*/
entityid_t componentGetEntitiesWithComponent(
entitymanager_t *mgr,
const componenttype_t type,
entityid_t outEntities[ENTITY_COUNT_MAX],
componentid_t outComponents[ENTITY_COUNT_MAX]
);
/**
* Disposes of a component for the entity with component ID.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void componentDispose(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Calls the render callback on every active component that defines one.
* Iterates all active entities and all their component slots. No-op for
* components whose definition has render == NULL.
*
* @param mgr The entity manager to render.
* @return Error state.
*/
errorret_t componentRenderAll(entitymanager_t *mgr);
@@ -3,12 +3,8 @@
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
include(dusktest)
# Tests
dusktest(test_rpg.c)
# Subdirs
add_subdirectory(overworld)
add_subdirectory(display)
add_subdirectory(physics)
add_subdirectory(entity)
add_subdirectory(trigger)
add_subdirectory(animation)
@@ -6,5 +6,5 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetchunkloader.c
entityanimation.c
)
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entityanimation.h"
#include "entity/entitymanager.h"
#include "util/memory.h"
void entityAnimationInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
memoryZero(animComp, sizeof(entityanimation_t));
entityUpdateAdd(mgr, entityId, entityAnimationUpdate, componentId, NULL);
}
entityanimation_t *entityAnimationGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
return componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_ANIMATION
);
}
void entityAnimationSetKeyframes(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
keyframe_t **channelTracks,
uint16_t *channelTrackCounts,
const uint16_t channelCount
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animationInit(
&animComp->anim, channelTracks, channelTrackCounts, channelCount
);
}
void entityAnimationPlay(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animComp->anim.time = 0.0f;
animComp->anim.playing = true;
}
void entityAnimationStop(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animComp->anim.playing = false;
}
bool_t entityAnimationIsPlaying(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
return animComp->anim.playing;
}
void entityAnimationSetLoop(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const bool_t loop
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animComp->anim.loop = loop;
}
void entityAnimationSetSpeed(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const float_t speed
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animComp->anim.speed = speed;
}
float_t entityAnimationGetValue(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint16_t channelIndex
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
return animationGetValue(&animComp->anim, channelIndex);
}
void entityAnimationUpdate(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
) {
entityanimation_t *animComp = entityAnimationGet(mgr, entityId, componentId);
animationUpdate(&animComp->anim);
}
@@ -0,0 +1,176 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "animation/animation.h"
typedef struct {
animation_t anim;
} entityanimation_t;
/**
* Initializes the animation component: no keyframes set, and registers
* entityAnimationUpdate as an update callback. Call
* entityAnimationSetKeyframes() before entityAnimationPlay().
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityAnimationInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the underlying animation structure (temporarily) for the given
* entity. Prefer the dedicated getters/setters where possible.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The animation component data for the given entity and
* component ID.
*/
entityanimation_t *entityAnimationGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the entity's keyframes, stopped, at speed 1.0, non-looping. See
* keyframeSetInit() -- channelTracks/channelTrackCounts and the
* keyframe_t arrays they point to are not copied, and must outlive this
* component (e.g. static/const arrays owned by the caller).
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param channelTracks Array of channelCount keyframe_t arrays -- one per
* animated channel (e.g. position.x/y/z), sharing this animation's
* timeline.
* @param channelTrackCounts Array of channelCount keyframe counts,
* matching channelTracks.
* @param channelCount The number of channels.
*/
void entityAnimationSetKeyframes(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
keyframe_t **channelTracks,
uint16_t *channelTrackCounts,
const uint16_t channelCount
);
/**
* Starts (or restarts) playback from time 0.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityAnimationPlay(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Stops playback without resetting the current time.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityAnimationStop(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Checks whether the animation is currently playing.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return True if playing.
*/
bool_t entityAnimationIsPlaying(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets whether the animation loops on reaching its final keyframe, rather
* than stopping there.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param loop True to loop, false to stop at the end.
*/
void entityAnimationSetLoop(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const bool_t loop
);
/**
* Sets the animation's playback rate multiplier.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param speed The new playback rate; 1.0 = normal speed.
*/
void entityAnimationSetSpeed(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const float_t speed
);
/**
* Evaluates one of the animation's channels at its current playback
* time, regardless of whether it's currently playing.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param channelIndex The channel to evaluate, in [0, channelCount) as
* passed to entityAnimationSetKeyframes().
* @return That channel's value at the current time.
*/
float_t entityAnimationGetValue(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint16_t channelIndex
);
/**
* Per-tick update for the animation component: calls animationUpdate()
* (a no-op if not playing). Registered automatically as an update
* callback by entityAnimationInit.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param user Unused.
*/
void entityAnimationUpdate(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
);
@@ -0,0 +1,12 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
entityposition.c
entitycamera.c
entityrenderable.c
)
@@ -0,0 +1,140 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entity/entitymanager.h"
#include "entity/entity.h"
#include "entity/component/display/entityposition.h"
#include "display/screen/screen.h"
void entityCameraInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entitycamera_t *cam = (entitycamera_t *)componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_CAMERA
);
cam->nearClip = 0.1f;
cam->farClip = 5000.0f;
cam->projType = ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE;
cam->perspective.fov = glm_rad(45.0f);
}
void entityCameraGetProjection(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 out
) {
entitycamera_t *cam = (entitycamera_t *)componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_CAMERA
);
if(
cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE ||
cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED
) {
glm_mat4_identity(out);
glm_perspective(
cam->perspective.fov,
SCREEN.aspect,
cam->nearClip,
cam->farClip,
out
);
if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED) {
out[1][1] *= -1.0f;
}
} else if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC) {
glm_mat4_identity(out);
glm_ortho(
cam->orthographic.left,
cam->orthographic.right,
cam->orthographic.top,
cam->orthographic.bottom,
cam->nearClip,
cam->farClip,
out
);
}
}
entityid_t entityCameraGetCurrent(entitymanager_t *mgr) {
entityid_t camEnts[ENTITY_COUNT_MAX];
componentid_t camComps[ENTITY_COUNT_MAX];
entityid_t count = componentGetEntitiesWithComponent(
mgr, COMPONENT_TYPE_CAMERA, camEnts, camComps
);
if(count == 0) return ENTITY_ID_INVALID;
return camEnts[0];
}
void entityCameraGetForward(
entitymanager_t *mgr,
const entityid_t entityId,
vec2 out
) {
componentid_t posComp = entityGetComponent(
mgr, entityId, COMPONENT_TYPE_POSITION
);
mat4 transform;
entityPositionGetTransform(mgr, entityId, posComp, transform);
// transform is an object->world matrix; column 2 is the entity's local Z
// axis expressed in world space. Cameras look down their local -Z.
float_t fx = -transform[2][0];
float_t fz = -transform[2][2];
float_t len = sqrtf(fx * fx + fz * fz);
if(len > 1e-6f) { fx /= len; fz /= len; }
out[0] = fx;
out[1] = fz;
}
void entityCameraGetRight(
entitymanager_t *mgr,
const entityid_t entityId,
vec2 out
) {
componentid_t posComp = entityGetComponent(
mgr, entityId, COMPONENT_TYPE_POSITION
);
mat4 transform;
entityPositionGetTransform(mgr, entityId, posComp, transform);
// transform is an object->world matrix; column 0 is the entity's local X
// (right) axis expressed in world space.
float_t rx = transform[0][0];
float_t rz = transform[0][2];
float_t len = sqrtf(rx * rx + rz * rz);
if(len > 1e-6f) { rx /= len; rz /= len; }
out[0] = rx;
out[1] = rz;
}
void entityCameraLookAtPixelPerfect(
entitymanager_t *mgr,
const entityid_t ent,
const componentid_t posComp,
const componentid_t camComp,
const vec3 point,
const vec3 eyeOffset,
const float_t scale
) {
entitycamera_t *cam = (entitycamera_t *)componentGetData(
mgr, ent, camComp, COMPONENT_TYPE_CAMERA
);
float_t dist = (
(float_t)SCREEN.height / (2.0f * scale * tanf(cam->perspective.fov * 0.5f))
);
vec3 eye = {
point[0] + eyeOffset[0],
point[1] + dist + eyeOffset[1],
point[2] + eyeOffset[2]
};
vec3 up = { 0.0f, 0.0f, -1.0f };
entityPositionLookAt(mgr, ent, posComp, eye, (float_t *)point, up);
}
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "error/error.h"
typedef enum {
ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE,
ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED,
ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC
} entitycameraprojectiontype_t;
typedef struct {
union {
struct {
float_t fov;
} perspective;
struct {
float_t left;
float_t right;
float_t top;
float_t bottom;
} orthographic;
};
float_t nearClip;
float_t farClip;
entitycameraprojectiontype_t projType;
} entitycamera_t;
/**
* Initializes an entity camera component.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityCameraInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Renders out the projection matrix for the given camera.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param out The output projection matrix.
*/
void entityCameraGetProjection(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 out
);
/**
* Returns the entity ID of the first active camera, or ENTITY_ID_INVALID if
* none are active.
*
* @param mgr The entity manager to search.
*/
entityid_t entityCameraGetCurrent(entitymanager_t *mgr);
/**
* Gets the camera's horizontal forward direction (XZ plane) from its
* position component. Automatically finds the position component on the
* entity.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The camera entity ID.
* @param out Output vec2: {forwardX, forwardZ} normalized.
*/
void entityCameraGetForward(
entitymanager_t *mgr,
const entityid_t entityId,
vec2 out
);
/**
* Gets the camera's horizontal right direction (XZ plane) from its position
* component. Automatically finds the position component on the entity.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The camera entity ID.
* @param out Output vec2: {rightX, rightZ} normalized.
*/
void entityCameraGetRight(
entitymanager_t *mgr,
const entityid_t entityId,
vec2 out
);
/**
* Positions the camera to look at a 3D point at a pixel-perfect distance
* derived from the camera's FOV and screen height.
*
* @param mgr The entity manager that owns the entity.
* @param ent The camera entity ID.
* @param posComp The position component ID.
* @param camComp The camera component ID.
* @param point World position to look at.
* @param eyeOffset Offset added to the eye position only (not the target).
* @param scale Pixels per world unit. 1.0 = pixel perfect, 2.0 = 2px per unit.
*/
void entityCameraLookAtPixelPerfect(
entitymanager_t *mgr,
const entityid_t ent,
const componentid_t posComp,
const componentid_t camComp,
const vec3 point,
const vec3 eyeOffset,
const float_t scale
);
@@ -0,0 +1,669 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entity/entitymanager.h"
#include "assert/assert.h"
void entityPositionInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
pos->flags = 0;
pos->parentEntityId = ENTITY_ID_INVALID;
pos->parentComponentId = COMPONENT_ID_INVALID;
pos->childCount = 0;
glm_vec3_zero(pos->position);
glm_vec3_zero(pos->rotation);
glm_vec3_one(pos->scale);
glm_mat4_identity(pos->localTransform);
glm_mat4_identity(pos->worldTransform);
}
void entityPositionLookAt(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 eye,
vec3 target,
vec3 up
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
// glm_lookat() produces a view matrix (world -> eye space). Every other
// setter treats localTransform as this entity's placement in world space
// (eye -> world), so invert it here to keep that meaning consistent --
// callers that need a view matrix (e.g. scene rendering) invert it back.
mat4 view;
glm_lookat(eye, target, up, view);
glm_mat4_inv(view, pos->localTransform);
// localTransform is now authoritative; PRS cache is stale.
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_PRS_DIRTY)
& ~(ENTITY_POSITION_FLAG_ROTATION_DIRTY |
ENTITY_POSITION_FLAG_POSITION_DIRTY);
entityPositionMarkDirty(mgr, pos);
}
void entityPositionGetTransform(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsureWorld(mgr, pos);
glm_mat4_copy(
pos->parentEntityId == ENTITY_ID_INVALID
? pos->localTransform : pos->worldTransform,
dest
);
}
void entityPositionGetLocalTransform(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsureLocal(pos);
glm_mat4_copy(pos->localTransform, dest);
}
void entityPositionGetLocalPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->position, dest);
}
void entityPositionGetWorldPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->position, dest);
return;
}
entityPositionEnsureWorld(mgr, pos);
dest[0] = pos->worldTransform[3][0];
dest[1] = pos->worldTransform[3][1];
dest[2] = pos->worldTransform[3][2];
}
void entityPositionSetWorldPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 position
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
glm_vec3_copy(position, pos->position);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
return;
}
entityposition_t *parent = componentGetData(
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsureWorld(mgr, parent);
mat4 invParent;
glm_mat4_inv(parent->worldTransform, invParent);
vec3 localPos;
glm_mat4_mulv3(invParent, position, 1.0f, localPos);
glm_vec3_copy(localPos, pos->position);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionSetLocalPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 position
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
glm_vec3_copy(position, pos->position);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionGetLocalRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->rotation, dest);
}
void entityPositionGetWorldRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->rotation, dest);
return;
}
entityPositionEnsureWorld(mgr, pos);
const float_t (*wt)[4] = pos->worldTransform;
const float_t sx = sqrtf(
wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2]
);
const float_t sy = sqrtf(
wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2]
);
const float_t sz = sqrtf(
wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2]
);
const float_t r00 = sx > 0.0f ? wt[0][0]/sx : 0.0f;
const float_t r10 = sy > 0.0f ? wt[1][0]/sy : 0.0f;
const float_t r20 = sz > 0.0f ? wt[2][0]/sz : 0.0f;
const float_t r01 = sx > 0.0f ? wt[0][1]/sx : 0.0f;
const float_t r11 = sy > 0.0f ? wt[1][1]/sy : 0.0f;
const float_t r21 = sz > 0.0f ? wt[2][1]/sz : 0.0f;
const float_t r22 = sz > 0.0f ? wt[2][2]/sz : 0.0f;
const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f);
dest[1] = asinf(sinBeta);
const float_t cosBeta = cosf(dest[1]);
if(fabsf(cosBeta) > 1e-6f) {
dest[0] = atan2f(-r21, r22);
dest[2] = atan2f(-r10, r00);
} else {
dest[2] = 0.0f;
dest[0] = (sinBeta > 0.0f) ? atan2f(r01, r11) : -atan2f(r01, r11);
}
}
void entityPositionSetLocalRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 rotation
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
glm_vec3_copy(rotation, pos->rotation);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionSetWorldRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 rotation
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
glm_vec3_copy(rotation, pos->rotation);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
return;
}
entityposition_t *parent = componentGetData(
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsureWorld(mgr, parent);
// Build target world rotation matrix (unit scale) from XYZ euler.
const float_t c0 = cosf(rotation[0]), s0 = sinf(rotation[0]);
const float_t c1 = cosf(rotation[1]), s1 = sinf(rotation[1]);
const float_t c2 = cosf(rotation[2]), s2 = sinf(rotation[2]);
const float_t s0s1 = s0*s1, c0s1 = c0*s1;
// Named wr[col_stored][row_stored] matching cglm column-major layout.
const float_t wr00 = c1*c2;
const float_t wr01 = c0*s2 + s0s1*c2;
const float_t wr02 = s0*s2 - c0s1*c2;
const float_t wr10 = -c1*s2;
const float_t wr11 = c0*c2 - s0s1*s2;
const float_t wr12 = s0*c2 + c0s1*s2;
const float_t wr20 = s1;
const float_t wr21 = -s0*c1;
const float_t wr22 = c0*c1;
// Normalize parent world columns to extract pure rotation.
const float_t (*pt)[4] = parent->worldTransform;
const float_t psx = sqrtf(
pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2]
);
const float_t psy = sqrtf(
pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2]
);
const float_t psz = sqrtf(
pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2]
);
const float_t pr00 = psx > 0.f ? pt[0][0]/psx : 0.f;
const float_t pr01 = psx > 0.f ? pt[0][1]/psx : 0.f;
const float_t pr02 = psx > 0.f ? pt[0][2]/psx : 0.f;
const float_t pr10 = psy > 0.f ? pt[1][0]/psy : 0.f;
const float_t pr11 = psy > 0.f ? pt[1][1]/psy : 0.f;
const float_t pr12 = psy > 0.f ? pt[1][2]/psy : 0.f;
const float_t pr20 = psz > 0.f ? pt[2][0]/psz : 0.f;
const float_t pr21 = psz > 0.f ? pt[2][1]/psz : 0.f;
const float_t pr22 = psz > 0.f ? pt[2][2]/psz : 0.f;
// local_R = parent_R^T * world_R (R^-1 == R^T for orthogonal matrices).
// Compute only the 7 entries of the local rotation matrix needed for XYZ
// euler extraction (stored column-major: [col][row] = math [row][col]).
// sinBeta = stored[2][0] = math[0][2]
// r21/r22 = stored[2][1..2] = math[1..2][2]
// r10/r00 = stored[1][0], stored[0][0] = math[0][1], math[0][0]
// gimbal = stored[0][1], stored[1][1] = math[1][0], math[1][1]
const float_t lr00 = pr00*wr00 + pr01*wr10 + pr02*wr20; // math[0][0]
const float_t lr10 = pr00*wr01 + pr01*wr11 + pr02*wr21; // math[0][1]
const float_t lr20 = pr00*wr02 + pr01*wr12 + pr02*wr22; // [0][2] -> sinBeta
const float_t lr01 = pr10*wr00 + pr11*wr10 + pr12*wr20; // math[1][0]
const float_t lr11 = pr10*wr01 + pr11*wr11 + pr12*wr21; // math[1][1]
const float_t lr21 = pr10*wr02 + pr11*wr12 + pr12*wr22; // [1][2] -> r21
const float_t lr22 = pr20*wr02 + pr21*wr12 + pr22*wr22; // [2][2] -> r22
const float_t sinBeta = glm_clamp(lr20, -1.0f, 1.0f);
pos->rotation[1] = asinf(sinBeta);
const float_t cosBeta = cosf(pos->rotation[1]);
if(fabsf(cosBeta) > 1e-6f) {
pos->rotation[0] = atan2f(-lr21, lr22);
pos->rotation[2] = atan2f(-lr10, lr00);
} else {
pos->rotation[2] = 0.0f;
pos->rotation[0] = (sinBeta > 0.0f)
? atan2f(lr01, lr11) : -atan2f(lr01, lr11);
}
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionGetLocalScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->scale, dest);
}
void entityPositionGetWorldScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
entityPositionEnsurePRS(pos);
glm_vec3_copy(pos->scale, dest);
return;
}
entityPositionEnsureWorld(mgr, pos);
const float_t (*wt)[4] = pos->worldTransform;
dest[0] = sqrtf(wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2]);
dest[1] = sqrtf(wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2]);
dest[2] = sqrtf(wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2]);
}
void entityPositionSetLocalScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 scale
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
glm_vec3_copy(scale, pos->scale);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionSetWorldScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 scale
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
if(pos->parentEntityId == ENTITY_ID_INVALID) {
glm_vec3_copy(scale, pos->scale);
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
return;
}
entityposition_t *parent = componentGetData(
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
);
entityPositionEnsureWorld(mgr, parent);
const float_t (*pt)[4] = parent->worldTransform;
const float_t psx = sqrtf(
pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2]
);
const float_t psy = sqrtf(
pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2]
);
const float_t psz = sqrtf(
pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2]
);
pos->scale[0] = psx > 0.0f ? scale[0] / psx : scale[0];
pos->scale[1] = psy > 0.0f ? scale[1] / psy : scale[1];
pos->scale[2] = psz > 0.0f ? scale[2] / psz : scale[2];
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionSetParent(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityid_t parentEntityId,
const componentid_t parentComponentId
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
// Remove from old parent's child list.
if(pos->parentEntityId != ENTITY_ID_INVALID) {
entityposition_t *oldParent = componentGetData(
mgr, pos->parentEntityId, pos->parentComponentId,
COMPONENT_TYPE_POSITION
);
for(uint8_t i = 0; i < oldParent->childCount; i++) {
if(
oldParent->childEntityIds[i] == entityId &&
oldParent->childComponentIds[i] == componentId
) {
oldParent->childCount--;
for(uint8_t j = i; j < oldParent->childCount; j++) {
oldParent->childEntityIds[j] = oldParent->childEntityIds[j + 1];
oldParent->childComponentIds[j] = oldParent->childComponentIds[j + 1];
}
break;
}
}
}
pos->parentEntityId = parentEntityId;
pos->parentComponentId = parentComponentId;
// Register with new parent.
if(parentEntityId != ENTITY_ID_INVALID) {
entityposition_t *parent = componentGetData(
mgr, parentEntityId, parentComponentId, COMPONENT_TYPE_POSITION
);
if(parent->childCount < ENTITY_POSITION_CHILDREN_MAX) {
parent->childEntityIds[parent->childCount] = entityId;
parent->childComponentIds[parent->childCount] = componentId;
parent->childCount++;
}
}
entityPositionMarkDirty(mgr, pos);
}
entityid_t entityPositionGetParent(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityposition_t *pos = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
return pos->parentEntityId;
}
entityposition_t *entityPositionGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
return componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
);
}
void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos) {
pos->flags = (
pos->flags |
ENTITY_POSITION_FLAG_ROTATION_DIRTY |
ENTITY_POSITION_FLAG_POSITION_DIRTY
) & ~ENTITY_POSITION_FLAG_PRS_DIRTY;
entityPositionMarkDirty(mgr, pos);
}
void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos) {
if(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY) return;
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
for(uint8_t i = 0; i < pos->childCount; i++) {
entityposition_t *child = componentGetData(
mgr, pos->childEntityIds[i], pos->childComponentIds[i],
COMPONENT_TYPE_POSITION
);
entityPositionMarkDirty(mgr, child);
}
}
void entityPositionDisposeDeep(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityposition_t *pos = entityPositionGet(mgr, entityId, componentId);
// Detach from parent so the parent's child list stays consistent.
if(pos->parentEntityId != ENTITY_ID_INVALID) {
entityPositionSetParent(
mgr, entityId, componentId, ENTITY_ID_INVALID, COMPONENT_ID_INVALID
);
}
// Copy the child list before disposing self (entityDispose invalidates
// pos).
uint8_t childCount = pos->childCount;
entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX];
componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX];
for(uint8_t i = 0; i < childCount; i++) {
childEntityIds[i] = pos->childEntityIds[i];
childComponentIds[i] = pos->childComponentIds[i];
// Sever child's parent link so it won't try to modify our disposed
// data.
entityposition_t *child = entityPositionGet(
mgr, childEntityIds[i], childComponentIds[i]
);
child->parentEntityId = ENTITY_ID_INVALID;
child->parentComponentId = COMPONENT_ID_INVALID;
}
entityDispose(mgr, entityId);
for(uint8_t i = 0; i < childCount; i++) {
entityPositionDisposeDeep(mgr, childEntityIds[i], childComponentIds[i]);
}
}
void entityPositionDecompose(entityposition_t *pos) {
// Translation: column 3
pos->position[0] = pos->localTransform[3][0];
pos->position[1] = pos->localTransform[3][1];
pos->position[2] = pos->localTransform[3][2];
// Scale: length of each basis column (xyz only)
pos->scale[0] = sqrtf(
pos->localTransform[0][0] * pos->localTransform[0][0] +
pos->localTransform[0][1] * pos->localTransform[0][1] +
pos->localTransform[0][2] * pos->localTransform[0][2]
);
pos->scale[1] = sqrtf(
pos->localTransform[1][0] * pos->localTransform[1][0] +
pos->localTransform[1][1] * pos->localTransform[1][1] +
pos->localTransform[1][2] * pos->localTransform[1][2]
);
pos->scale[2] = sqrtf(
pos->localTransform[2][0] * pos->localTransform[2][0] +
pos->localTransform[2][1] * pos->localTransform[2][1] +
pos->localTransform[2][2] * pos->localTransform[2][2]
);
// Normalize columns to isolate the rotation matrix (no mat4 needed).
const float_t invS0 = pos->scale[0] > 0.0f ? 1.0f / pos->scale[0] : 0.0f;
const float_t invS1 = pos->scale[1] > 0.0f ? 1.0f / pos->scale[1] : 0.0f;
const float_t invS2 = pos->scale[2] > 0.0f ? 1.0f / pos->scale[2] : 0.0f;
const float_t r00 = pos->localTransform[0][0] * invS0;
const float_t r01 = pos->localTransform[0][1] * invS0;
const float_t r02 = pos->localTransform[0][2] * invS0;
const float_t r10 = pos->localTransform[1][0] * invS1;
const float_t r11 = pos->localTransform[1][1] * invS1;
const float_t r20 = pos->localTransform[2][0] * invS2;
const float_t r21 = pos->localTransform[2][1] * invS2;
const float_t r22 = pos->localTransform[2][2] * invS2;
// Extract XYZ euler angles (R = Rx * Ry * Rz, column-major)
const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f);
pos->rotation[1] = asinf(sinBeta);
const float_t cosBeta = cosf(pos->rotation[1]);
if(fabsf(cosBeta) > 1e-6f) {
pos->rotation[0] = atan2f(-r21, r22);
pos->rotation[2] = atan2f(-r10, r00);
} else {
// Gimbal lock: pin Z to 0, recover X.
pos->rotation[2] = 0.0f;
pos->rotation[0] = (sinBeta > 0.0f)
? atan2f(r01, r11)
: -atan2f(r01, r11);
}
}
void entityPositionEnsurePRS(entityposition_t *pos) {
if(!(pos->flags & ENTITY_POSITION_FLAG_PRS_DIRTY)) return;
entityPositionDecompose(pos);
pos->flags &= ~ENTITY_POSITION_FLAG_PRS_DIRTY;
}
void entityPositionEnsureLocal(entityposition_t *pos) {
const uint8_t dirty = pos->flags & (
ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY
);
if(!dirty) return;
if(dirty & ENTITY_POSITION_FLAG_ROTATION_DIRTY) {
// Rotation or scale changed: rebuild cols 0-2 analytically (XYZ euler).
const float_t c0 = cosf(pos->rotation[0]), s0 = sinf(pos->rotation[0]);
const float_t c1 = cosf(pos->rotation[1]), s1 = sinf(pos->rotation[1]);
const float_t c2 = cosf(pos->rotation[2]), s2 = sinf(pos->rotation[2]);
const float_t s0s1 = s0 * s1;
const float_t c0s1 = c0 * s1;
pos->localTransform[0][0] = c1 * c2 * pos->scale[0];
pos->localTransform[0][1] = (c0 * s2 + s0s1 * c2) * pos->scale[0];
pos->localTransform[0][2] = (s0 * s2 - c0s1 * c2) * pos->scale[0];
pos->localTransform[0][3] = 0.0f;
pos->localTransform[1][0] = -c1 * s2 * pos->scale[1];
pos->localTransform[1][1] = (c0 * c2 - s0s1 * s2) * pos->scale[1];
pos->localTransform[1][2] = (s0 * c2 + c0s1 * s2) * pos->scale[1];
pos->localTransform[1][3] = 0.0f;
pos->localTransform[2][0] = s1 * pos->scale[2];
pos->localTransform[2][1] = -s0 * c1 * pos->scale[2];
pos->localTransform[2][2] = c0 * c1 * pos->scale[2];
pos->localTransform[2][3] = 0.0f;
}
if(dirty & ENTITY_POSITION_FLAG_POSITION_DIRTY) {
// Only position changed: update column 3 only (no trig needed).
pos->localTransform[3][0] = pos->position[0];
pos->localTransform[3][1] = pos->position[1];
pos->localTransform[3][2] = pos->position[2];
pos->localTransform[3][3] = 1.0f;
}
pos->flags &= ~(
ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY
);
}
void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos) {
if(!(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY)) return;
entityPositionEnsureLocal(pos);
if(pos->parentEntityId != ENTITY_ID_INVALID) {
// Parented: world = parent.world x local. worldTransform must be
// written because children (and this node's getters) read it.
entityposition_t *parent = componentGetData(
mgr, pos->parentEntityId, pos->parentComponentId,
COMPONENT_TYPE_POSITION
);
entityPositionEnsureWorld(mgr, parent);
glm_mat4_mul(
parent->worldTransform, pos->localTransform, pos->worldTransform
);
} else if(pos->childCount > 0) {
// Parentless root with children: children need a valid worldTransform
// to multiply against, but world == local, so just copy.
glm_mat4_copy(pos->localTransform, pos->worldTransform);
}
// Parentless leaf: world == local. Getters read localTransform directly;
// no copy needed.
pos->flags &= ~ENTITY_POSITION_FLAG_WORLD_DIRTY;
}
@@ -0,0 +1,460 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "error/error.h"
/** Maximum number of child position components this node can track. */
#define ENTITY_POSITION_CHILDREN_MAX 8
/**
* PRS cache is stale. localTransform was written directly (e.g. lookAt) and
* position/rotation/scale need to be decomposed before they can be read.
*/
#define ENTITY_POSITION_FLAG_PRS_DIRTY (1 << 0)
/**
* Columns 0-2 of localTransform are stale. Rotation or scale changed; the
* basis vectors need to be rebuilt analytically before the matrix can be used.
* Does not imply column 3 (translation) is stale.
*/
#define ENTITY_POSITION_FLAG_ROTATION_DIRTY (1 << 1)
/**
* Column 3 of localTransform is stale. Position changed; only the
* translation column needs to be written. Does not imply columns 0-2 are
* stale.
*/
#define ENTITY_POSITION_FLAG_POSITION_DIRTY (1 << 2)
/**
* worldTransform is stale. Either the local matrix changed or an ancestor
* moved; worldTransform must be recomputed before world data can be read.
*/
#define ENTITY_POSITION_FLAG_WORLD_DIRTY (1 << 3)
typedef struct {
/*
* Hot fields - flag checks, parent/child traversal (markDirty, ensureWorld)
* only touch these. Kept at the front so they share the first cache line.
*/
/** ENTITY_POSITION_FLAG_* bitmask; describes which caches are stale. */
uint8_t flags;
/** Entity ID of the parent node, or ENTITY_ID_INVALID if none. */
entityid_t parentEntityId;
/** Component ID of the parent position, or COMPONENT_ID_INVALID if none. */
componentid_t parentComponentId;
/** Number of currently registered children. */
uint8_t childCount;
/** Entity IDs of child nodes. */
entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX];
/** Component IDs of child position components. */
componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX];
/*
* Warm fields - read/written by PRS getters/setters.
* Accessed more often than the matrices but less often than flags.
*/
/** Cached local position (XYZ). Stale when PRS_DIRTY is set. */
vec3 position;
/** Cached local rotation (XYZ euler, radians). Stale when PRS_DIRTY. */
vec3 rotation;
/** Cached local scale (XYZ). Stale when PRS_DIRTY is set. */
vec3 scale;
/*
* Cold fields - only touched when actually rebuilding transforms.
*/
/** Local transform matrix, rebuilt lazily from position/rotation/scale. */
mat4 localTransform;
/** World transform matrix, recomputed lazily from the parent chain. */
mat4 worldTransform;
} entityposition_t;
/**
* Initializes the entity position component, setting identity transforms and
* zeroing all parent/child state.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityPositionInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Positions and orients the entity at eye, facing target. Stores this as
* the entity's normal world-space placement (consistent with every other
* setter), not as a view matrix -- invert entityPositionGetTransform()'s
* result to get a view matrix for rendering.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param eye The eye/camera position.
* @param target The target point to look at.
* @param up The up vector.
*/
void entityPositionLookAt(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 eye,
vec3 target,
vec3 up
);
/**
* Gets the world-space transform matrix, recomputing it lazily if dirty.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination matrix.
*/
void entityPositionGetTransform(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 dest
);
/**
* Gets the local transform matrix (does not include parent transforms).
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination matrix.
*/
void entityPositionGetLocalTransform(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
mat4 dest
);
/**
* Gets the cached local position (XYZ). Decomposes localTransform into PRS
* first if ENTITY_POSITION_FLAG_PRS_DIRTY is set; never triggers a matrix
* rebuild or world-transform update.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetLocalPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Gets the world-space position. For parentless entities this is the same as
* the local position.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetWorldPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Sets the world-space position. For parentless entities this is equivalent
* to entityPositionSetLocalPosition. For parented entities the position is
* converted to local space via the inverted parent world transform.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param position The desired world-space position.
*/
void entityPositionSetWorldPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 position
);
/**
* Sets the local position, marks localTransform and worldTransform (self +
* descendants) dirty.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param position The new local position.
*/
void entityPositionSetLocalPosition(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 position
);
/**
* Gets the cached local euler rotation (XYZ, radians). Decomposes
* localTransform first if ENTITY_POSITION_FLAG_PRS_DIRTY is set.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetLocalRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Gets the world-space euler rotation (XYZ, radians) by decomposing the
* world transform. For parentless entities this is the same as local
* rotation.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetWorldRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Sets the local euler rotation (XYZ, radians) and marks transforms dirty.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param rotation The new local rotation.
*/
void entityPositionSetLocalRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 rotation
);
/**
* Sets the world-space euler rotation (XYZ, radians). For parentless
* entities this is equivalent to entityPositionSetLocalRotation. For
* parented entities the rotation is converted to local space by removing
* the parent world rotation.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param rotation The desired world-space euler rotation.
*/
void entityPositionSetWorldRotation(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 rotation
);
/**
* Gets the cached local scale. Decomposes localTransform first if
* ENTITY_POSITION_FLAG_PRS_DIRTY is set.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetLocalScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Gets the world-space scale by extracting column lengths from the world
* transform. For parentless entities this is the same as local scale.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest Destination vector.
*/
void entityPositionGetWorldScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Sets the local scale and marks transforms dirty.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param scale The new local scale.
*/
void entityPositionSetLocalScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 scale
);
/**
* Sets the world-space scale. For parentless entities this is equivalent to
* entityPositionSetLocalScale. For parented entities the scale is converted
* to local space by dividing by the parent world scale (assumes no shear).
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param scale The desired world-space scale.
*/
void entityPositionSetWorldScale(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 scale
);
/**
* Sets the parent of this entity's position component.
* Pass ENTITY_ID_INVALID / COMPONENT_ID_INVALID to detach from any parent.
*
* @param mgr The entity manager that owns both entities.
* @param entityId The child entity ID.
* @param componentId The child component ID.
* @param parentEntityId The parent entity ID.
* @param parentComponentId The parent component ID.
*/
void entityPositionSetParent(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityid_t parentEntityId,
const componentid_t parentComponentId
);
/**
* Gets the entity ID of this position component's parent.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The parent entity ID, or ENTITY_ID_INVALID if unparented.
*/
entityid_t entityPositionGetParent(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Returns a direct pointer to the entity position component data.
* After modifying localTransform directly, call entityPositionMarkDirty() to
* set ENTITY_POSITION_FLAG_WORLD_DIRTY on self and descendants. After
* modifying PRS directly, call entityPositionRebuild() instead.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return Pointer to the component data.
*/
entityposition_t *entityPositionGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Signals that the PRS cache was modified externally. Sets both
* ENTITY_POSITION_FLAG_ROTATION_DIRTY and ENTITY_POSITION_FLAG_POSITION_DIRTY
* so all of localTransform is rebuilt lazily on the next read, clears
* ENTITY_POSITION_FLAG_PRS_DIRTY, propagates ENTITY_POSITION_FLAG_WORLD_DIRTY
* to self and all descendants.
*
* @param mgr The entity manager that owns pos and its descendants.
* @param pos The position component whose PRS was modified.
*/
void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos);
/**
* Sets ENTITY_POSITION_FLAG_WORLD_DIRTY on this node and all descendants,
* indicating that worldTransform must be recomputed before it is read.
* Call this after modifying localTransform directly.
*
* @param mgr The entity manager that owns pos and its descendants.
* @param pos The position component to mark dirty.
*/
void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos);
/**
* Disposes this entity and all of its position-component descendants
* recursively. Detaches from any parent before destroying.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The root entity ID.
* @param componentId The root position component ID.
*/
void entityPositionDisposeDeep(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Decomposes the local transform matrix back into the position, rotation
* (XYZ euler, radians), and scale cache fields.
*
* @param pos The position component to decompose.
*/
void entityPositionDecompose(entityposition_t *pos);
/**
* Internal. Decomposes localTransform into the PRS cache if
* ENTITY_POSITION_FLAG_PRS_DIRTY is set.
*
* @param pos The position component to update.
*/
void entityPositionEnsurePRS(entityposition_t *pos);
/**
* Internal. Rebuilds localTransform from the PRS cache, touching only the
* columns flagged as stale (ROTATION_DIRTY and/or POSITION_DIRTY).
*
* @param pos The position component to update.
*/
void entityPositionEnsureLocal(entityposition_t *pos);
/**
* Internal. Recomputes worldTransform from the parent chain if
* ENTITY_POSITION_FLAG_WORLD_DIRTY is set.
*
* @param mgr The entity manager that owns pos and its ancestors.
* @param pos The position component to update.
*/
void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos);
@@ -0,0 +1,192 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entityrenderable.h"
#include "entity/entitymanager.h"
#include "display/shader/shadermaterial.h"
#include "display/shader/shaderunlit.h"
#include "display/display.h"
#include "display/mesh/cube.h"
#include "util/memory.h"
#include "assert/assert.h"
void entityRenderableInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
memoryZero(r, sizeof(entityrenderable_t));
r->type = ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL;
r->data.material.shaderType = SHADER_LIST_SHADER_UNLIT;
r->data.material.material.unlit.color = COLOR_WHITE;
r->data.material.meshes[0] = &CUBE_MESH_SIMPLE;
r->data.material.meshOffsets[0] = 0;
r->data.material.meshCounts[0] = -1;
r->data.material.meshCount = 1;
r->data.material.state.flags = DISPLAY_STATE_FLAG_DEPTH_TEST;
}
void entityRenderableDispose(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
}
void entityRenderableSetType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityrenderabletype_t type
) {
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
r->type = type;
}
void entityRenderableSetPriority(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const int8_t priority
) {
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
r->priority = priority;
}
void entityRenderableSetColor(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const color_t color
) {
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
assertTrue(
r->type == ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
"Renderable must be ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL to set color"
);
r->data.material.material.unlit.color = color;
}
void entityRenderableSetMesh(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint8_t slot,
mesh_t *mesh
) {
assertNotNull(mesh, "Mesh cannot be null");
assertTrue(slot < ENTITY_RENDERABLE_MESHES_MAX, "Mesh slot out of bounds");
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
assertTrue(
r->type == ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
"Renderable must be ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL to set a mesh"
);
r->data.material.meshes[slot] = mesh;
r->data.material.meshOffsets[slot] = 0;
r->data.material.meshCounts[slot] = -1;
if(slot >= r->data.material.meshCount) {
r->data.material.meshCount = slot + 1;
}
}
void entityRenderableSetDraw(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
errorret_t (*draw)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
),
void *user
) {
assertNotNull(draw, "Draw callback cannot be null");
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
r->type = ENTITY_RENDERABLE_TYPE_CUSTOM;
r->data.custom.draw = draw;
r->data.custom.drawUser = user;
}
errorret_t entityRenderableDraw(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityrenderable_t *r = componentGetData(
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
);
switch(r->type) {
case ENTITY_RENDERABLE_TYPE_SPRITEBATCH:
return entityRenderableDrawSpritebatch(&r->data.spritebatch);
case ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL:
return entityRenderableDrawMaterial(&r->data.material);
case ENTITY_RENDERABLE_TYPE_CUSTOM:
return entityRenderableDrawCustom(
mgr, entityId, componentId, &r->data.custom
);
default:
assertUnreachable("Invalid renderable type");
}
}
errorret_t entityRenderableDrawSpritebatch(
const entityrenderablespritebatch_t *sb
) {
if(sb->spriteCount == 0) errorOk();
errorChain(displaySetState((displaystate_t){
.flags = DISPLAY_STATE_FLAG_BLEND
}));
spriteBatchClear();
shadermaterial_t mat;
memoryZero(&mat, sizeof(shadermaterial_t));
mat.unlit.texture = sb->texture;
mat.unlit.color = COLOR_WHITE;
errorChain(spriteBatchBuffer(
sb->sprites, sb->spriteCount,
SHADER_LIST_DEFS[SHADER_LIST_SHADER_UNLIT].shader, mat
));
return spriteBatchFlush();
}
errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m) {
errorChain(displaySetState(m->state));
shader_t *shader = SHADER_LIST_DEFS[m->shaderType].shader;
assertNotNull(shader, "Shader cannot be null for material type");
errorChain(shaderBind(shader));
errorChain(shaderSetMaterial(shader, &m->material));
for(uint8_t i = 0; i < m->meshCount; i++) {
errorChain(meshDraw(m->meshes[i], m->meshOffsets[i], m->meshCounts[i]));
}
errorOk();
}
errorret_t entityRenderableDrawCustom(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityrenderablecustom_t *custom
) {
return custom->draw(mgr, entityId, componentId, custom->drawUser);
}
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "display/mesh/mesh.h"
#include "display/shader/shadermaterial.h"
#include "display/spritebatch/spritebatch.h"
#include "display/displaystate.h"
#include "error/error.h"
#define ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX 64
#define ENTITY_RENDERABLE_MESHES_MAX 8
typedef enum {
ENTITY_RENDERABLE_TYPE_CUSTOM = 0,
ENTITY_RENDERABLE_TYPE_SPRITEBATCH,
ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
} entityrenderabletype_t;
typedef struct {
spritebatchsprite_t sprites[ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX];
uint32_t spriteCount;
texture_t *texture;
} entityrenderablespritebatch_t;
typedef struct {
mesh_t *meshes[ENTITY_RENDERABLE_MESHES_MAX];
int32_t meshOffsets[ENTITY_RENDERABLE_MESHES_MAX];
int32_t meshCounts[ENTITY_RENDERABLE_MESHES_MAX];
uint8_t meshCount;
shaderlistshader_t shaderType;
shadermaterial_t material;
displaystate_t state;
} entityrenderablematerial_t;
typedef struct {
errorret_t (*draw)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
);
void *drawUser;
} entityrenderablecustom_t;
typedef union entityrenderabledata_u {
entityrenderablespritebatch_t spritebatch;
entityrenderablematerial_t material;
entityrenderablecustom_t custom;
} entityrenderabledata_t;
typedef struct {
entityrenderabletype_t type;
entityrenderabledata_t data;
/**
* Render priority. 0 = auto (derived from type/flags). Higher values
* render later (on top of lower values). Range: [-128..127] with 0 auto.
*/
int8_t priority;
} entityrenderable_t;
/**
* Initializes the entity renderable component. Defaults to
* ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL with the unlit shader, a white
* cube, and depth-test enabled.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to initialize the component for.
* @param componentId The renderable component of the entity.
*/
void entityRenderableInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Disposes the entity renderable component.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to dispose the component for.
* @param componentId The renderable component of the entity.
*/
void entityRenderableDispose(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the rendering type for the renderable component. Resets
* type-specific data to zero.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to configure.
* @param componentId The renderable component.
* @param type The rendering type to use.
*/
void entityRenderableSetType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityrenderabletype_t type
);
/**
* Sets the render priority. 0 = auto (derived from type/flags). Higher
* values render later (on top). Use non-zero to force ordering.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to configure.
* @param componentId The renderable component.
* @param priority The priority value, or 0 for auto.
*/
void entityRenderableSetPriority(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const int8_t priority
);
/**
* Sets the unlit material color. Only meaningful when the renderable is
* (or defaults to) ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL -- asserts
* otherwise.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to configure.
* @param componentId The renderable component.
* @param color The color to tint the material with.
*/
void entityRenderableSetColor(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const color_t color
);
/**
* Sets one of the material renderable's meshes, drawn in full (no offset/
* count override). Only meaningful when the renderable is (or defaults
* to) ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL -- asserts otherwise.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to configure.
* @param componentId The renderable component.
* @param slot Index into the material's meshes array (0 to
* ENTITY_RENDERABLE_MESHES_MAX - 1).
* @param mesh The mesh to draw in that slot.
*/
void entityRenderableSetMesh(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint8_t slot,
mesh_t *mesh
);
/**
* Sets the draw callback, switching the type to
* ENTITY_RENDERABLE_TYPE_CUSTOM.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to configure.
* @param componentId The renderable component of the entity.
* @param draw The draw callback to assign.
* @param user Userdata passed to the callback.
*/
void entityRenderableSetDraw(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
errorret_t (*draw)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
),
void *user
);
/**
* Draws the entity using its renderable component data.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to draw.
* @param componentId The renderable component of the entity.
* @return Any error state that happened.
*/
errorret_t entityRenderableDraw(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Internal. Draws a spritebatch-type renderable.
*
* @param sb The spritebatch data to draw.
* @return Error state.
*/
errorret_t entityRenderableDrawSpritebatch(
const entityrenderablespritebatch_t *sb
);
/**
* Internal. Draws a shader-material-type renderable.
*
* @param m The material data to draw.
* @return Error state.
*/
errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m);
/**
* Internal. Invokes a custom-type renderable's draw callback.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity being drawn.
* @param componentId The renderable component of the entity.
* @param custom The custom draw data.
* @return Error state.
*/
errorret_t entityRenderableDrawCustom(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityrenderablecustom_t *custom
);
@@ -6,5 +6,5 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
entityitem.c
entityphysics.c
)
@@ -0,0 +1,135 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entityphysics.h"
#include "entity/entitymanager.h"
#include "util/memory.h"
void entityPhysicsInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
memoryZero(phys, sizeof(entityphysics_t));
// Default to cube
phys->type = PHYSICS_BODY_DYNAMIC;
phys->shape.type = PHYSICS_SHAPE_CUBE;
phys->shape.data.cube.halfExtents[0] = 0.5f;
phys->shape.data.cube.halfExtents[1] = 0.5f;
phys->shape.data.cube.halfExtents[2] = 0.5f;
phys->gravityScale = 1.0f;
phys->onGround = false;
phys->collideMask = 0x1;
}
entityphysics_t *entityPhysicsGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
return componentGetData(mgr, entityId, componentId, COMPONENT_TYPE_PHYSICS);
}
void entityPhysicsSetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsshape_t shape
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
phys->shape = shape;
}
physicsshape_t entityPhysicsGetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
return phys->shape;
}
void entityPhysicsGetVelocity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
glm_vec3_copy(phys->velocity, dest);
}
void entityPhysicsSetVelocity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 velocity
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
glm_vec3_copy(velocity, phys->velocity);
}
void entityPhysicsApplyImpulse(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 impulse
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
if(phys->type == PHYSICS_BODY_STATIC) return;
glm_vec3_add(phys->velocity, impulse, phys->velocity);
}
bool_t entityPhysicsIsOnGround(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
return phys->onGround;
}
void entityPhysicsSetBodyType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsbodytype_t type
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
phys->type = type;
}
physicsbodytype_t entityPhysicsGetBodyType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
return phys->type;
}
void entityPhysicsSetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint32_t mask
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
phys->collideMask = mask;
}
uint32_t entityPhysicsGetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entityphysics_t *phys = entityPhysicsGet(mgr, entityId, componentId);
return phys->collideMask;
}
@@ -0,0 +1,211 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "physics/physicsshape.h"
#include "physics/physicsbodytype.h"
#include "error/error.h"
typedef struct {
physicsbodytype_t type;
physicsshape_t shape;
vec3 velocity;
float_t gravityScale;
bool_t onGround;
/**
* Bitmask of collision layers/categories this body belongs to. Two
* bodies only collide with each other if (a.collideMask & b.collideMask)
* is non-zero -- i.e. they share at least one set bit. Defaults to 0x1
* (only bit 0 set).
*/
uint32_t collideMask;
} entityphysics_t;
/**
* Initializes the physics component: defaults to a dynamic 1x1x1 cube
* body, zero velocity, unit gravity scale, and onGround false.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityPhysicsInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the underlying physics structure (temporarily) for the given entity.
* This is really just intended for doing operations faster than using the
* getters and setters, but it is preferred that you use those.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The physics component data for the given entity and component ID.
*/
entityphysics_t *entityPhysicsGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the shape of the entity's physics body. This will not reset the body
* state, so if you change from a cube to a sphere, it will keep the same
* velocity and onGround state.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param shape The new shape to set on the physics body.
*/
void entityPhysicsSetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsshape_t shape
);
/**
* Gets the shape of the entity's physics body.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The shape of the physics body.
*/
physicsshape_t entityPhysicsGetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the velocity of the entity's physics body.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param dest The destination vec3 to write the velocity to.
*/
void entityPhysicsGetVelocity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 dest
);
/**
* Sets the velocity of the entity's physics body. This is not an impulse, so
* it will be affected by mass and drag.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param velocity The new velocity to set on the physics body.
*/
void entityPhysicsSetVelocity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 velocity
);
/**
* Applies an impulse to the entity's physics body. This is an immediate
* velocity change that is not affected by mass or drag. No-op on STATIC
* bodies.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param impulse The impulse to apply to the physics body.
*/
void entityPhysicsApplyImpulse(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
vec3 impulse
);
/**
* Returns true if the entity's physics body rested on a surface during the
* last physicsWorldStep.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return True if the body is on the ground, false otherwise.
*/
bool_t entityPhysicsIsOnGround(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the body type of the entity's physics body.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param type The body type to set.
*/
void entityPhysicsSetBodyType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsbodytype_t type
);
/**
* Gets the body type of the entity's physics body.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The body type of the physics body.
*/
physicsbodytype_t entityPhysicsGetBodyType(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the collision mask of the entity's physics body. Two bodies only
* collide if their masks share at least one set bit.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param mask The new collision mask to set on the physics body.
*/
void entityPhysicsSetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint32_t mask
);
/**
* Gets the collision mask of the entity's physics body.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The collision mask of the physics body.
*/
uint32_t entityPhysicsGetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
@@ -6,8 +6,6 @@
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
npc.c
npcturn.c
npcwalk.c
npcpath.c
entitytrigger.c
entitytriggerevents.c
)
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitytrigger.h"
#include "entity/entitymanager.h"
#include "util/memory.h"
void entityTriggerInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
memoryZero(trig, sizeof(entitytrigger_t));
trig->shape.type = PHYSICS_SHAPE_CUBE;
trig->shape.data.cube.halfExtents[0] = 0.5f;
trig->shape.data.cube.halfExtents[1] = 0.5f;
trig->shape.data.cube.halfExtents[2] = 0.5f;
trig->collideMask = 0x1;
}
entitytrigger_t *entityTriggerGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
return componentGetData(mgr, entityId, componentId, COMPONENT_TYPE_TRIGGER);
}
void entityTriggerSetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsshape_t shape
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
trig->shape = shape;
}
physicsshape_t entityTriggerGetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
return trig->shape;
}
void entityTriggerSetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint32_t mask
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
trig->collideMask = mask;
}
uint32_t entityTriggerGetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
return trig->collideMask;
}
uint8_t entityTriggerGetOccupantCount(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
return trig->occupantCount;
}
entityid_t entityTriggerGetOccupantEntityId(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint8_t index
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
if(index >= trig->occupantCount) return ENTITY_ID_INVALID;
return trig->occupants[index].entityId;
}
bool_t entityTriggerIsOccupyingEntity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityid_t otherEntityId
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
for(uint8_t i = 0; i < trig->occupantCount; i++) {
if(trig->occupants[i].entityId == otherEntityId) return true;
}
return false;
}
@@ -0,0 +1,226 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity/entitybase.h"
#include "physics/physicsshape.h"
#include "error/error.h"
/** Maximum number of entities a single trigger can track at once. */
#define ENTITY_TRIGGER_OCCUPANTS_MAX 8
/** Maximum number of subscribers per trigger event (onEnter/onActive/...). */
#define ENTITY_TRIGGER_CALLBACK_COUNT_MAX 4
/**
* Callback invoked for a trigger's onEnter/onActive/onMove/onLeave events.
* See entityTriggerOnEnterAdd() and its onActive/onMove/onLeave siblings.
*
* @param mgr The entity manager that owns both entities.
* @param triggerEntityId The entity ID of the trigger volume.
* @param triggerComponentId The TRIGGER component ID.
* @param otherEntityId The entity ID that entered/is inside/left the
* trigger volume.
* @param otherComponentId The other entity's POSITION component ID.
* @param user The user pointer passed to the matching *Add() call.
*/
typedef void (*triggercallback_t)(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId,
void *user
);
/** Tracks one entity currently overlapping a trigger volume. */
typedef struct {
/** The occupying entity's ID. */
entityid_t entityId;
/** The occupying entity's POSITION component ID. */
componentid_t componentId;
/** The occupying entity's local position as of the last step, used to
* detect movement (see entityTriggerOnMoveAdd()). */
vec3 lastPosition;
} entitytriggeroccupant_t;
typedef struct {
/** The trigger volume's shape, in the owning entity's local space. */
physicsshape_t shape;
/**
* Bitmask of collision layers/categories this trigger detects. An
* entity's PHYSICS body is only tested against this trigger if
* (trigger.collideMask & body.collideMask) is non-zero -- i.e. they
* share at least one set bit. Defaults to 0x1 (only bit 0 set). See
* entityPhysicsSetCollideMask().
*/
uint32_t collideMask;
/** Number of entities currently tracked in occupants. */
uint8_t occupantCount;
/** Entities currently overlapping this trigger. */
entitytriggeroccupant_t occupants[ENTITY_TRIGGER_OCCUPANTS_MAX];
/** Fired the step an entity begins overlapping this trigger. */
uint8_t onEnterCount;
triggercallback_t onEnter[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
void *onEnterUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
/** Fired every step an entity continues overlapping this trigger,
* starting the step after onEnter fired. */
uint8_t onActiveCount;
triggercallback_t onActive[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
void *onActiveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
/** Fired alongside onActive on any step where the entity's position
* changed since the previous step. */
uint8_t onMoveCount;
triggercallback_t onMove[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
void *onMoveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
/** Fired the step an entity stops overlapping this trigger. */
uint8_t onLeaveCount;
triggercallback_t onLeave[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
void *onLeaveUser[ENTITY_TRIGGER_CALLBACK_COUNT_MAX];
} entitytrigger_t;
/**
* Initializes the trigger component: defaults to a 1x1x1 cube volume, no
* tracked occupants, and no event subscribers.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
*/
void entityTriggerInit(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the underlying trigger structure (temporarily) for the given
* entity. Prefer the dedicated getters/setters where possible.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The trigger component data for the given entity and component ID.
*/
entitytrigger_t *entityTriggerGet(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the shape of the trigger volume.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param shape The new shape to set on the trigger volume.
*/
void entityTriggerSetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const physicsshape_t shape
);
/**
* Gets the shape of the trigger volume.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The shape of the trigger volume.
*/
physicsshape_t entityTriggerGetShape(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Sets the collision mask of the trigger volume. An entity's PHYSICS body
* is only detected if their masks share at least one set bit.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param mask The new collision mask to set on the trigger volume.
*/
void entityTriggerSetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint32_t mask
);
/**
* Gets the collision mask of the trigger volume.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The collision mask of the trigger volume.
*/
uint32_t entityTriggerGetCollideMask(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the number of entities currently overlapping this trigger.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @return The number of currently tracked occupants.
*/
uint8_t entityTriggerGetOccupantCount(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId
);
/**
* Gets the entity ID of the occupant at the given index, in [0,
* entityTriggerGetOccupantCount()). Order is not stable across steps.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param index The occupant index.
* @return The occupying entity's ID, or ENTITY_ID_INVALID if index is out
* of range.
*/
entityid_t entityTriggerGetOccupantEntityId(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const uint8_t index
);
/**
* Checks whether a specific entity is currently overlapping this trigger.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param otherEntityId The entity ID to check for.
* @return True if otherEntityId is a currently tracked occupant.
*/
bool_t entityTriggerIsOccupyingEntity(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const entityid_t otherEntityId
);
@@ -0,0 +1,221 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitytriggerevents.h"
#include "assert/assert.h"
void entityTriggerOnEnterAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
assertTrue(
trig->onEnterCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX,
"Trigger onEnter callback slots full"
);
trig->onEnter[trig->onEnterCount] = callback;
trig->onEnterUser[trig->onEnterCount] = user;
trig->onEnterCount++;
}
void entityTriggerOnEnterRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
for(uint8_t i = 0; i < trig->onEnterCount; i++) {
if(trig->onEnter[i] != callback) continue;
trig->onEnterCount--;
for(uint8_t j = i; j < trig->onEnterCount; j++) {
trig->onEnter[j] = trig->onEnter[j + 1];
trig->onEnterUser[j] = trig->onEnterUser[j + 1];
}
return;
}
}
void entityTriggerOnActiveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
assertTrue(
trig->onActiveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX,
"Trigger onActive callback slots full"
);
trig->onActive[trig->onActiveCount] = callback;
trig->onActiveUser[trig->onActiveCount] = user;
trig->onActiveCount++;
}
void entityTriggerOnActiveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
for(uint8_t i = 0; i < trig->onActiveCount; i++) {
if(trig->onActive[i] != callback) continue;
trig->onActiveCount--;
for(uint8_t j = i; j < trig->onActiveCount; j++) {
trig->onActive[j] = trig->onActive[j + 1];
trig->onActiveUser[j] = trig->onActiveUser[j + 1];
}
return;
}
}
void entityTriggerOnMoveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
assertTrue(
trig->onMoveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX,
"Trigger onMove callback slots full"
);
trig->onMove[trig->onMoveCount] = callback;
trig->onMoveUser[trig->onMoveCount] = user;
trig->onMoveCount++;
}
void entityTriggerOnMoveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
for(uint8_t i = 0; i < trig->onMoveCount; i++) {
if(trig->onMove[i] != callback) continue;
trig->onMoveCount--;
for(uint8_t j = i; j < trig->onMoveCount; j++) {
trig->onMove[j] = trig->onMove[j + 1];
trig->onMoveUser[j] = trig->onMoveUser[j + 1];
}
return;
}
}
void entityTriggerOnLeaveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
assertTrue(
trig->onLeaveCount < ENTITY_TRIGGER_CALLBACK_COUNT_MAX,
"Trigger onLeave callback slots full"
);
trig->onLeave[trig->onLeaveCount] = callback;
trig->onLeaveUser[trig->onLeaveCount] = user;
trig->onLeaveCount++;
}
void entityTriggerOnLeaveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
) {
entitytrigger_t *trig = entityTriggerGet(mgr, entityId, componentId);
for(uint8_t i = 0; i < trig->onLeaveCount; i++) {
if(trig->onLeave[i] != callback) continue;
trig->onLeaveCount--;
for(uint8_t j = i; j < trig->onLeaveCount; j++) {
trig->onLeave[j] = trig->onLeave[j + 1];
trig->onLeaveUser[j] = trig->onLeaveUser[j + 1];
}
return;
}
}
void entityTriggerFireEnter(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
) {
entitytrigger_t *trig = entityTriggerGet(
mgr, triggerEntityId, triggerComponentId
);
for(uint8_t i = 0; i < trig->onEnterCount; i++) {
trig->onEnter[i](
mgr, triggerEntityId, triggerComponentId,
otherEntityId, otherComponentId, trig->onEnterUser[i]
);
}
}
void entityTriggerFireActive(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
) {
entitytrigger_t *trig = entityTriggerGet(
mgr, triggerEntityId, triggerComponentId
);
for(uint8_t i = 0; i < trig->onActiveCount; i++) {
trig->onActive[i](
mgr, triggerEntityId, triggerComponentId,
otherEntityId, otherComponentId, trig->onActiveUser[i]
);
}
}
void entityTriggerFireMove(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
) {
entitytrigger_t *trig = entityTriggerGet(
mgr, triggerEntityId, triggerComponentId
);
for(uint8_t i = 0; i < trig->onMoveCount; i++) {
trig->onMove[i](
mgr, triggerEntityId, triggerComponentId,
otherEntityId, otherComponentId, trig->onMoveUser[i]
);
}
}
void entityTriggerFireLeave(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
) {
entitytrigger_t *trig = entityTriggerGet(
mgr, triggerEntityId, triggerComponentId
);
for(uint8_t i = 0; i < trig->onLeaveCount; i++) {
trig->onLeave[i](
mgr, triggerEntityId, triggerComponentId,
otherEntityId, otherComponentId, trig->onLeaveUser[i]
);
}
}
@@ -0,0 +1,220 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entitytrigger.h"
/**
* Subscribes a callback to a trigger's onEnter event, fired the step an
* entity begins overlapping the trigger volume.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback to invoke.
* @param user Arbitrary pointer forwarded to the callback unchanged.
*/
void entityTriggerOnEnterAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
);
/**
* Unsubscribes a previously added onEnter callback. No-op if not
* currently subscribed.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback that was passed to entityTriggerOnEnterAdd().
*/
void entityTriggerOnEnterRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
);
/**
* Subscribes a callback to a trigger's onActive event, fired every step an
* entity continues overlapping the trigger volume (starting the step
* after onEnter fired).
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback to invoke.
* @param user Arbitrary pointer forwarded to the callback unchanged.
*/
void entityTriggerOnActiveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
);
/**
* Unsubscribes a previously added onActive callback. No-op if not
* currently subscribed.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback that was passed to entityTriggerOnActiveAdd().
*/
void entityTriggerOnActiveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
);
/**
* Subscribes a callback to a trigger's onMove event, fired alongside
* onActive on any step where the overlapping entity's position changed
* since the previous step.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback to invoke.
* @param user Arbitrary pointer forwarded to the callback unchanged.
*/
void entityTriggerOnMoveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
);
/**
* Unsubscribes a previously added onMove callback. No-op if not currently
* subscribed.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback that was passed to entityTriggerOnMoveAdd().
*/
void entityTriggerOnMoveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
);
/**
* Subscribes a callback to a trigger's onLeave event, fired the step an
* entity stops overlapping the trigger volume.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback to invoke.
* @param user Arbitrary pointer forwarded to the callback unchanged.
*/
void entityTriggerOnLeaveAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback,
void *user
);
/**
* Unsubscribes a previously added onLeave callback. No-op if not
* currently subscribed.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity ID.
* @param componentId The component ID.
* @param callback The callback that was passed to entityTriggerOnLeaveAdd().
*/
void entityTriggerOnLeaveRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
const triggercallback_t callback
);
/**
* Internal. Invokes every subscribed onEnter callback for a trigger.
* Called by triggerSystemStep() when an entity begins overlapping.
*
* @param mgr The entity manager that owns both entities.
* @param triggerEntityId The entity ID of the trigger volume.
* @param triggerComponentId The TRIGGER component ID.
* @param otherEntityId The entity ID that entered the trigger volume.
* @param otherComponentId The other entity's POSITION component ID.
*/
void entityTriggerFireEnter(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
);
/**
* Internal. Invokes every subscribed onActive callback for a trigger.
* Called by triggerSystemStep() for every entity still overlapping.
*
* @param mgr The entity manager that owns both entities.
* @param triggerEntityId The entity ID of the trigger volume.
* @param triggerComponentId The TRIGGER component ID.
* @param otherEntityId The entity ID still inside the trigger volume.
* @param otherComponentId The other entity's POSITION component ID.
*/
void entityTriggerFireActive(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
);
/**
* Internal. Invokes every subscribed onMove callback for a trigger.
* Called by triggerSystemStep() when an overlapping entity's position
* changed since the previous step.
*
* @param mgr The entity manager that owns both entities.
* @param triggerEntityId The entity ID of the trigger volume.
* @param triggerComponentId The TRIGGER component ID.
* @param otherEntityId The entity ID that moved within the trigger volume.
* @param otherComponentId The other entity's POSITION component ID.
*/
void entityTriggerFireMove(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
);
/**
* Internal. Invokes every subscribed onLeave callback for a trigger.
* Called by triggerSystemStep() when an entity stops overlapping.
*
* @param mgr The entity manager that owns both entities.
* @param triggerEntityId The entity ID of the trigger volume.
* @param triggerComponentId The TRIGGER component ID.
* @param otherEntityId The entity ID that left the trigger volume.
* @param otherComponentId The other entity's POSITION component ID.
*/
void entityTriggerFireLeave(
entitymanager_t *mgr,
const entityid_t triggerEntityId,
const componentid_t triggerComponentId,
const entityid_t otherEntityId,
const componentid_t otherComponentId
);
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entity/component/display/entityposition.h"
#include "entity/component/display/entitycamera.h"
#include "entity/component/display/entityrenderable.h"
#include "entity/component/physics/entityphysics.h"
#include "entity/component/trigger/entitytrigger.h"
#include "entity/component/animation/entityanimation.h"
// Name (Uppercase)
// Structure
// Field name (lowercase)
// Init function (optional)
// Dispose function (optional)
// Render function (optional)
X(POSITION, entityposition_t, position, entityPositionInit, NULL, NULL)
X(CAMERA, entitycamera_t, camera, entityCameraInit, NULL, NULL)
X(RENDERABLE, entityrenderable_t, renderable,
entityRenderableInit, entityRenderableDispose, NULL)
X(PHYSICS, entityphysics_t, physics, entityPhysicsInit, NULL, NULL)
X(TRIGGER, entitytrigger_t, trigger, entityTriggerInit, NULL, NULL)
X(ANIMATION, entityanimation_t, animation, entityAnimationInit, NULL, NULL)
// Game-specific components
#include "entity/gamecomponentlist.h"
+215
View File
@@ -0,0 +1,215 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitymanager.h"
#include "component/display/entityposition.h"
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
void entityInit(entitymanager_t *mgr, const entityid_t entityId) {
entity_t *ent = &mgr->entities[entityId];
memoryZero(ent, sizeof(entity_t));
// Mark all component types not using this entity.
for(
componenttype_t compType = 0;
compType < COMPONENT_TYPE_COUNT;
compType++
) {
mgr->entitiesWithComponent[
compType * ENTITY_COUNT_MAX + entityId
] = COMPONENT_ID_INVALID;
}
ent->state |= ENTITY_STATE_ACTIVE;
}
componentid_t entityAddComponent(
entitymanager_t *mgr,
const entityid_t entityId,
const componenttype_t type
) {
componentindex_t compInd;
for(componentid_t i = 0; i < ENTITY_COMPONENT_COUNT_MAX; i++) {
compInd = componentGetIndex(entityId, i);
if(mgr->components[compInd].type != COMPONENT_TYPE_NULL) {
assertTrue(
mgr->components[compInd].type != type,
"Entity already has component of this type"
);
continue;
}
componentInit(mgr, entityId, i, type);
mgr->entitiesWithComponent[
type * ENTITY_COUNT_MAX + entityId
] = i;
return i;
}
assertUnreachable("Entity has no more component slots available");
return COMPONENT_ID_INVALID;
}
componentid_t entityGetComponent(
entitymanager_t *mgr,
const entityid_t entityId,
const componenttype_t type
) {
componentid_t compId = mgr->entitiesWithComponent[
type * ENTITY_COUNT_MAX + entityId
];
if(compId == COMPONENT_ID_INVALID) return compId;
assertTrue(
mgr->components[componentGetIndex(entityId, compId)].type == type,
"Component type mismatch"
);
return compId;
}
void entitySetName(
entitymanager_t *mgr,
const entityid_t entityId,
const char_t *name
) {
stringCopy(mgr->entities[entityId].name, name, ENTITY_NAME_MAX);
}
const char_t *entityGetName(
entitymanager_t *mgr,
const entityid_t entityId
) {
return mgr->entities[entityId].name;
}
entityid_t entityFindByName(
entitymanager_t *mgr,
const char_t *name
) {
for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) {
if(!(mgr->entities[i].state & ENTITY_STATE_ACTIVE)) continue;
if(stringEquals(mgr->entities[i].name, name)) return i;
}
return ENTITY_ID_INVALID;
}
void entityDisposeDeep(entitymanager_t *mgr, const entityid_t entityId) {
componentid_t posComp = entityGetComponent(
mgr, entityId, COMPONENT_TYPE_POSITION
);
if(posComp != COMPONENT_ID_INVALID) {
entityPositionDisposeDeep(mgr, entityId, posComp);
} else {
entityDispose(mgr, entityId);
}
}
void entityUpdate(entitymanager_t *mgr, const entityid_t entityId) {
entity_t *ent = &mgr->entities[entityId];
for(uint8_t i = 0; i < ent->updateCount; i++) {
ent->onUpdate[i](
mgr, entityId, ent->updateComponentId[i], ent->updateUser[i]
);
}
}
void entityDispose(entitymanager_t *mgr, const entityid_t entityId) {
componentindex_t compInd;
entity_t *ent = &mgr->entities[entityId];
for(uint8_t i = 0; i < ent->disposeCount; i++) {
ent->onDispose[i](
mgr, entityId, ent->disposeComponentId[i], ent->disposeUser[i]
);
}
for(componentid_t i = 0; i < ENTITY_COMPONENT_COUNT_MAX; i++) {
compInd = componentGetIndex(entityId, i);
componenttype_t type = mgr->components[compInd].type;
if(type == COMPONENT_TYPE_NULL) continue;
mgr->entitiesWithComponent[
type * ENTITY_COUNT_MAX + entityId
] = COMPONENT_ID_INVALID;
componentDispose(mgr, entityId, i);
}
ent->state = 0;
}
void entityUpdateAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback,
const componentid_t componentId,
void *user
) {
entity_t *ent = &mgr->entities[entityId];
assertTrue(
ent->updateCount < ENTITY_UPDATE_CALLBACK_COUNT_MAX,
"Entity update callback slots full"
);
ent->onUpdate[ent->updateCount] = callback;
ent->updateComponentId[ent->updateCount] = componentId;
ent->updateUser[ent->updateCount] = user;
ent->updateCount++;
}
void entityUpdateRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback
) {
entity_t *ent = &mgr->entities[entityId];
for(uint8_t i = 0; i < ent->updateCount; i++) {
if(ent->onUpdate[i] != callback) continue;
ent->updateCount--;
for(uint8_t j = i; j < ent->updateCount; j++) {
ent->onUpdate[j] = ent->onUpdate[j + 1];
ent->updateComponentId[j] = ent->updateComponentId[j + 1];
ent->updateUser[j] = ent->updateUser[j + 1];
}
return;
}
}
void entityDisposeAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback,
const componentid_t componentId,
void *user
) {
entity_t *ent = &mgr->entities[entityId];
assertTrue(
ent->disposeCount < ENTITY_DISPOSE_CALLBACK_COUNT_MAX,
"Entity dispose callback slots full"
);
ent->onDispose[ent->disposeCount] = callback;
ent->disposeComponentId[ent->disposeCount] = componentId;
ent->disposeUser[ent->disposeCount] = user;
ent->disposeCount++;
}
void entityDisposeRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback
) {
entity_t *ent = &mgr->entities[entityId];
for(uint8_t i = 0; i < ent->disposeCount; i++) {
if(ent->onDispose[i] != callback) continue;
ent->disposeCount--;
for(uint8_t j = i; j < ent->disposeCount; j++) {
ent->onDispose[j] = ent->onDispose[j + 1];
ent->disposeComponentId[j] = ent->disposeComponentId[j + 1];
ent->disposeUser[j] = ent->disposeUser[j + 1];
}
return;
}
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "component.h"
#include "error/error.h"
#define ENTITY_STATE_ACTIVE (1 << 0)
#define ENTITY_UPDATE_CALLBACK_COUNT_MAX 5
#define ENTITY_DISPOSE_CALLBACK_COUNT_MAX 5
#define ENTITY_NAME_MAX 32
typedef void (*entitycallback_t)(
entitymanager_t *mgr,
const entityid_t entityId,
const componentid_t componentId,
void *user
);
typedef struct {
char_t name[ENTITY_NAME_MAX];
uint8_t state;
uint8_t updateCount;
uint8_t disposeCount;
entitycallback_t onUpdate[ENTITY_UPDATE_CALLBACK_COUNT_MAX];
componentid_t updateComponentId[ENTITY_UPDATE_CALLBACK_COUNT_MAX];
void *updateUser[ENTITY_UPDATE_CALLBACK_COUNT_MAX];
entitycallback_t onDispose[ENTITY_DISPOSE_CALLBACK_COUNT_MAX];
componentid_t disposeComponentId[ENTITY_DISPOSE_CALLBACK_COUNT_MAX];
void *disposeUser[ENTITY_DISPOSE_CALLBACK_COUNT_MAX];
} entity_t;
/**
* Initializes an entity with the given ID.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to initialize.
*/
void entityInit(entitymanager_t *mgr, const entityid_t entityId);
/**
* Adds a component of the given type to the entity with the given ID.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to add the component to.
* @param type The type of the component to add.
* @return The ID of the entity with component.
*/
componentid_t entityAddComponent(
entitymanager_t *mgr,
const entityid_t entityId,
const componenttype_t type
);
/**
* Gets the ID of the component of the given type on the entity with the
* given ID, or COMPONENT_ID_INVALID if the entity lacks the component.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to get the component from.
* @param type The type of the component to get.
* @return The ID of the component.
*/
componentid_t entityGetComponent(
entitymanager_t *mgr,
const entityid_t entityId,
const componenttype_t type
);
/**
* Sets an entity's name. Names are optional -- an entity's name is empty
* by default (entityInit zeroes it) -- but are required to make the
* entity referenceable by name, e.g. as a parent via
* entityPositionSetParent().
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to name.
* @param name The name to give the entity. Must be shorter than
* ENTITY_NAME_MAX.
*/
void entitySetName(
entitymanager_t *mgr,
const entityid_t entityId,
const char_t *name
);
/**
* Gets an entity's name, or an empty string if it has none.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to get the name of.
* @return The entity's name.
*/
const char_t *entityGetName(
entitymanager_t *mgr,
const entityid_t entityId
);
/**
* Finds the first active entity with the given name.
*
* @param mgr The entity manager to search.
* @param name The name to search for.
* @return The entity ID, or ENTITY_ID_INVALID if no active entity matches.
*/
entityid_t entityFindByName(
entitymanager_t *mgr,
const char_t *name
);
/**
* Runs all registered update callbacks for the entity.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to update.
*/
void entityUpdate(entitymanager_t *mgr, const entityid_t entityId);
/**
* Disposes of an entity with the given ID. Fires all dispose callbacks
* before cleaning up components and state.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to dispose of.
*/
void entityDispose(entitymanager_t *mgr, const entityid_t entityId);
/**
* Disposes of an entity and all of its position-component descendants
* recursively. If the entity has no position component, behaves like
* entityDispose.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The root entity ID.
*/
void entityDisposeDeep(entitymanager_t *mgr, const entityid_t entityId);
/**
* Registers an update callback, invoked each time entityUpdate is called.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to register on.
* @param callback The function to call.
* @param componentId The component ID passed back to the callback.
* @param user Userdata passed back to the callback.
*/
void entityUpdateAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback,
const componentid_t componentId,
void *user
);
/**
* Removes a previously registered update callback.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to remove from.
* @param callback The function to remove.
*/
void entityUpdateRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback
);
/**
* Registers a dispose callback, invoked at the start of entityDispose
* before any component or state cleanup.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to register on.
* @param callback The function to call.
* @param componentId The component ID passed back to the callback.
* @param user Userdata passed back to the callback.
*/
void entityDisposeAdd(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback,
const componentid_t componentId,
void *user
);
/**
* Removes a previously registered dispose callback.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The entity to remove from.
* @param callback The function to remove.
*/
void entityDisposeRemove(
entitymanager_t *mgr,
const entityid_t entityId,
const entitycallback_t callback
);
+24
View File
@@ -0,0 +1,24 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
#define ENTITY_COUNT_MAX 64
#define ENTITY_COMPONENT_COUNT_MAX 16
#define ENTITY_ID_INVALID 0xFF
#define COMPONENT_ID_INVALID 0xFF
typedef uint8_t entityid_t;
typedef uint8_t componentid_t;
typedef uint16_t componentindex_t;
// Forward declared here (rather than in component.h) so every entity/
// component header can reference entitymanager_t* regardless of include
// order, without needing a full entitymanager.h include.
typedef struct entitymanager_t entitymanager_t;
+56
View File
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entitymanager.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "console/console.h"
void entityManagerInit(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
memoryZero(mgr, sizeof(entitymanager_t));
memorySet(
mgr->entitiesWithComponent, COMPONENT_ID_INVALID,
sizeof(componentid_t) * COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX
);
consolePrint(
"Entity manager size: %zu bytes (%.2f KB)",
sizeof(entitymanager_t),
sizeof(entitymanager_t) / 1024.0f
);
}
entityid_t entityManagerAdd(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) {
if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0) continue;
entityInit(mgr, i);
return i;
}
assertUnreachable("No more entity IDs available");
return ENTITY_ID_INVALID;
}
void entityManagerUpdate(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
entityid_t i = 0;
while(i < ENTITY_COUNT_MAX) {
if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) != 0) {
entityUpdate(mgr, i);
}
i++;
}
}
void entityManagerDispose(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
for(entityid_t i = 0; i < ENTITY_COUNT_MAX; i++) {
if((mgr->entities[i].state & ENTITY_STATE_ACTIVE) == 0) continue;
entityDispose(mgr, i);
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entity.h"
typedef struct entitymanager_t {
entity_t entities[ENTITY_COUNT_MAX];
component_t components[ENTITY_COUNT_MAX * ENTITY_COMPONENT_COUNT_MAX];
componentid_t entitiesWithComponent[COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX];
} entitymanager_t;
/**
* Initializes an entity manager, marking all entities and components as
* unused.
*
* @param mgr The entity manager to initialize.
*/
void entityManagerInit(entitymanager_t *mgr);
/**
* Adds / Reserves a new entity ID in the given manager.
*
* @param mgr The entity manager to allocate the entity in.
* @return The new entity ID.
*/
entityid_t entityManagerAdd(entitymanager_t *mgr);
/**
* Updates all active entities in the given manager.
*
* @param mgr The entity manager to update.
*/
void entityManagerUpdate(entitymanager_t *mgr);
/**
* Disposes of the entity manager, in turn freeing all entities and
* components.
*
* @param mgr The entity manager to dispose.
*/
void entityManagerDispose(entitymanager_t *mgr);
+56
View File
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "entityprefab.h"
#include "util/string.h"
#include "assert/assert.h"
// Pulls in every prefab's real header (entityprefablist.h's one-time
// #include lines) exactly once -- each header's #pragma once then makes
// the array-building pass below a no-op for those lines, leaving only
// bare X(...) invocations to expand.
#define X(enumName, extends, apply) // do nothing
#include "entityprefablist.h"
#undef X
entityprefab_t ENTITY_PREFABS[] = {
#define X(enm, extendsName, applyFn) \
{ .name = #enm, .extends = extendsName, .apply = applyFn },
#include "entityprefablist.h"
#undef X
// Sentinel: name[0] == '\0' marks the end for callers iterating without
// a separate count.
{ 0 }
};
errorret_t entityPrefabInit(
entitymanager_t *mgr,
const entityid_t entityId,
const entityprefab_t prefab
) {
assertNotNull(prefab.apply, "Prefab apply function cannot be null");
if(prefab.extends[0] != '\0') {
errorChain(entityPrefabResolveAndApply(mgr, entityId, prefab.extends));
}
errorChain(prefab.apply(mgr, entityId));
errorOk();
}
errorret_t entityPrefabResolveAndApply(
entitymanager_t *mgr,
const entityid_t entityId,
const char_t *name
) {
for(size_t i = 0; ENTITY_PREFABS[i].name[0] != '\0'; i++) {
if(!stringEquals(ENTITY_PREFABS[i].name, name)) continue;
errorChain(entityPrefabInit(mgr, entityId, ENTITY_PREFABS[i]));
errorOk();
}
errorThrow("Unknown entity prefab '%s'", name);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entitybase.h"
#include "error/error.h"
#define ENTITY_PREFAB_NAME_MAX 32
/**
* Applies a prefab to an entity, e.g. adding and configuring whatever
* components make up the prefab.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to apply the prefab to.
* @return Error state.
*/
typedef errorret_t (*entityprefabapply_t)(
entitymanager_t *mgr,
const entityid_t entityId
);
typedef struct {
char_t name[ENTITY_PREFAB_NAME_MAX];
char_t extends[ENTITY_PREFAB_NAME_MAX];
entityprefabapply_t apply;
} entityprefab_t;
/**
* The C-coded prefab registry, built from entityprefablist.h. Terminated
* by a sentinel entry whose name[0] is '\0' -- iterate with a
* `for(...; ENTITY_PREFABS[i].name[0] != '\0'; ...)` style loop rather
* than a fixed count.
*/
extern entityprefab_t ENTITY_PREFABS[];
/**
* Initializes an entity from a prefab, running its apply function. If the
* prefab declares a parent (prefab.extends is non-empty), that parent is
* resolved and applied first (see entityPrefabResolveAndApply()), so this
* prefab's own apply function runs on top of/after it.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to apply the prefab to.
* @param prefab The prefab to apply.
* @return Error state.
*/
errorret_t entityPrefabInit(
entitymanager_t *mgr,
const entityid_t entityId,
const entityprefab_t prefab
);
/**
* Resolves a prefab by name and applies it to the given entity, by
* searching the C-coded ENTITY_PREFABS[] registry (see
* entityprefablist.h) for a matching name. Throws if no prefab with that
* name is registered.
*
* @param mgr The entity manager that owns the entity.
* @param entityId The ID of the entity to apply the prefab to.
* @param name The prefab name to resolve.
* @return Error state.
*/
errorret_t entityPrefabResolveAndApply(
entitymanager_t *mgr,
const entityid_t entityId,
const char_t *name
);
+13
View File
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
// Name (Uppercase)
// Extends (name of another prefab to apply first, or "" for none)
// Apply function
// Game-specific prefabs
#include "entity/gameprefablist.h"
@@ -3,7 +3,4 @@
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
sceneoverworld.c
)
# Header-only: game.h's implementation lives in src/duskrpg/game/game.c
+32
View File
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
/**
* Initializes the game. Called once, after the engine and all of its
* subsystems have finished initializing.
*
* @return An error code indicating success or failure.
*/
errorret_t gameInit(void);
/**
* Updates the game. Called once per frame.
*
* @return An error code indicating success or failure.
*/
errorret_t gameUpdate(void);
/**
* Disposes of the game. Called once, before the engine disposes of its
* own subsystems.
*
* @return An error code indicating success or failure.
*/
errorret_t gameDispose(void);
+1 -10
View File
@@ -9,13 +9,4 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
input.c
inputbutton.c
inputaction.c
)
# Input Action Definitions
dusk_run_python(
dusk_input_csv_defs
tools.input
--csv ${CMAKE_CURRENT_SOURCE_DIR}/input.csv
--output ${DUSK_GENERATED_HEADERS_DIR}/input/inputactiondefs.h
)
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} dusk_input_csv_defs)
)
+35 -35
View File
@@ -18,8 +18,8 @@ errorret_t inputInit(void) {
memoryZero(&INPUT, sizeof(input_t));
INPUT.deadzone = INPUT_DEADZONE_DEFAULT;
for(uint8_t i = 0; i < INPUT_ACTION_COUNT; i++) {
INPUT.actions[i].action = (inputaction_t)i;
for(uint8_t i = 0; i < INPUT_BIND_COUNT; i++) {
INPUT.actions[i].action = (inputbind_t)i;
INPUT.actions[i].lastValue = 0.0f;
INPUT.actions[i].currentValue = 0.0f;
}
@@ -52,7 +52,7 @@ void inputUpdate(void) {
#endif
action++;
} while(action < &INPUT.actions[INPUT_ACTION_COUNT]);
} while(action < &INPUT.actions[INPUT_BIND_COUNT]);
// For each button...
inputbuttondata_t *cur = &INPUT_BUTTON_DATA[0];
@@ -85,62 +85,62 @@ void inputUpdate(void) {
}
}
float_t inputGetCurrentValue(const inputaction_t action) {
float_t inputGetCurrentValue(const inputbind_t action) {
#ifdef DUSK_TIME_DYNAMIC
if(TIME.dynamicUpdate) return inputGetCurrentValueDynamic(action);
#endif
assertTrue(action < INPUT_ACTION_COUNT, "Input action out of bounds");
assertTrue(action < INPUT_BIND_COUNT, "Input action out of bounds");
return INPUT.actions[action].currentValue;
}
float_t inputGetLastValue(const inputaction_t action) {
float_t inputGetLastValue(const inputbind_t action) {
#ifdef DUSK_TIME_DYNAMIC
if(TIME.dynamicUpdate) return inputGetLastValueDynamic(action);
#endif
assertTrue(action < INPUT_ACTION_COUNT, "Input action out of bounds");
assertTrue(action < INPUT_BIND_COUNT, "Input action out of bounds");
return INPUT.actions[action].lastValue;
}
#ifdef DUSK_TIME_DYNAMIC
float_t inputGetCurrentValueDynamic(const inputaction_t action) {
assertTrue(action < INPUT_ACTION_COUNT, "Input action out of bounds");
float_t inputGetCurrentValueDynamic(const inputbind_t action) {
assertTrue(action < INPUT_BIND_COUNT, "Input action out of bounds");
return INPUT.actions[action].currentDynamicValue;
}
float_t inputGetLastValueDynamic(const inputaction_t action) {
assertTrue(action < INPUT_ACTION_COUNT, "Input action out of bounds");
float_t inputGetLastValueDynamic(const inputbind_t action) {
assertTrue(action < INPUT_BIND_COUNT, "Input action out of bounds");
return INPUT.actions[action].lastDynamicValue;
}
bool_t inputIsDownDynamic(const inputaction_t action) {
bool_t inputIsDownDynamic(const inputbind_t action) {
return inputGetCurrentValueDynamic(action) > 0.0f;
}
bool_t inputWasDownDynamic(const inputaction_t action) {
bool_t inputWasDownDynamic(const inputbind_t action) {
return inputGetLastValueDynamic(action) > 0.0f;
}
bool_t inputPressedDynamic(const inputaction_t action) {
bool_t inputPressedDynamic(const inputbind_t action) {
return inputIsDownDynamic(action) && !inputWasDownDynamic(action);
}
bool_t inputReleasedDynamic(const inputaction_t action) {
bool_t inputReleasedDynamic(const inputbind_t action) {
return !inputIsDownDynamic(action) && inputWasDownDynamic(action);
}
float_t inputAxisDynamic(
const inputaction_t neg,
const inputaction_t pos
const inputbind_t neg,
const inputbind_t pos
) {
return inputGetCurrentValueDynamic(pos) -
inputGetCurrentValueDynamic(neg);
}
void inputAxis2DDynamic(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
) {
assertNotNull(result, "Result vector cannot be null");
@@ -149,8 +149,8 @@ float_t inputGetLastValue(const inputaction_t action) {
}
void inputAngle2DDynamic(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
) {
assertNotNull(result, "Result vector cannot be null");
@@ -171,32 +171,32 @@ float_t inputGetLastValue(const inputaction_t action) {
}
#endif
bool_t inputIsDown(const inputaction_t action) {
bool_t inputIsDown(const inputbind_t action) {
return inputGetCurrentValue(action) > 0.0f;
}
bool_t inputWasDown(const inputaction_t action) {
bool_t inputWasDown(const inputbind_t action) {
return inputGetLastValue(action) > 0.0f;
}
bool_t inputPressed(const inputaction_t action) {
bool_t inputPressed(const inputbind_t action) {
return inputIsDown(action) && !inputWasDown(action);
}
bool_t inputReleased(const inputaction_t action) {
bool_t inputReleased(const inputbind_t action) {
return !inputIsDown(action) && inputWasDown(action);
}
float_t inputAxis(const inputaction_t neg, const inputaction_t pos) {
assertTrue(neg < INPUT_ACTION_COUNT, "Negative input action out of bounds");
assertTrue(pos < INPUT_ACTION_COUNT, "Positive input action out of bounds");
float_t inputAxis(const inputbind_t neg, const inputbind_t pos) {
assertTrue(neg < INPUT_BIND_COUNT, "Negative input action out of bounds");
assertTrue(pos < INPUT_BIND_COUNT, "Positive input action out of bounds");
return inputGetCurrentValue(pos) - inputGetCurrentValue(neg);
}
void inputAxis2D(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
) {
assertNotNull(result, "Result vector cannot be null");
@@ -205,8 +205,8 @@ void inputAxis2D(
}
void inputAngle2D(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
) {
assertNotNull(result, "Result vector cannot be null");
@@ -226,12 +226,12 @@ void inputAngle2D(
result[1] = y;
}
void inputBind(const inputbutton_t button, const inputaction_t act) {
void inputBind(const inputbutton_t button, const inputbind_t act) {
assertTrue(
act < INPUT_ACTION_COUNT,
act < INPUT_BIND_COUNT,
"Invalid input action"
);
assertTrue(act != INPUT_ACTION_NULL, "Cannot bind to NULL action");
assertTrue(act != INPUT_BIND_NULL, "Cannot bind to NULL action");
// Get the button data for this button.
inputbuttondata_t *data = inputButtonGetData(button);
-12
View File
@@ -1,12 +0,0 @@
id,
UP,
DOWN,
LEFT,
RIGHT,
ACCEPT,
CANCEL,
PAUSE,
RAGEQUIT,
CONSOLE,
POINTERX,
POINTERY,
1 id
2 UP
3 DOWN
4 LEFT
5 RIGHT
6 ACCEPT
7 CANCEL
8 PAUSE
9 RAGEQUIT
10 CONSOLE
11 POINTERX
12 POINTERY
+25 -25
View File
@@ -15,7 +15,7 @@
#define INPUT_DEADZONE_DEFAULT 0.1f
typedef struct {
inputactiondata_t actions[INPUT_ACTION_COUNT];
inputactiondata_t actions[INPUT_BIND_COUNT];
inputplatform_t platform;
@@ -43,7 +43,7 @@ void inputUpdate(void);
* @param action The input action to get the value for.
* @return The current value of the action (0.0f to 1.0f).
*/
float_t inputGetCurrentValue(const inputaction_t action);
float_t inputGetCurrentValue(const inputbind_t action);
/**
* Gets the last value of a specific input action.
@@ -51,7 +51,7 @@ float_t inputGetCurrentValue(const inputaction_t action);
* @param action The input action to get the value for.
* @return The last value of the action (0.0f to 1.0f).
*/
float_t inputGetLastValue(const inputaction_t action);
float_t inputGetLastValue(const inputbind_t action);
#ifdef DUSK_TIME_DYNAMIC
/**
@@ -60,7 +60,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to get the value for.
* @return The current value of the action (0.0f to 1.0f).
*/
float_t inputGetCurrentValueDynamic(const inputaction_t action);
float_t inputGetCurrentValueDynamic(const inputbind_t action);
/**
* Gets the last value of a specific input action (dynamic timestep).
@@ -68,7 +68,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to get the value for.
* @return The last value of the action (0.0f to 1.0f).
*/
float_t inputGetLastValueDynamic(const inputaction_t action);
float_t inputGetLastValueDynamic(const inputbind_t action);
/**
* Checks if an action is currently down on the dynamic timestep.
@@ -76,7 +76,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to check.
* @return true if the action is currently down.
*/
bool_t inputIsDownDynamic(const inputaction_t action);
bool_t inputIsDownDynamic(const inputbind_t action);
/**
* Checks if an action was down on the previous dynamic frame.
@@ -84,7 +84,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to check.
* @return true if the action was down last dynamic frame.
*/
bool_t inputWasDownDynamic(const inputaction_t action);
bool_t inputWasDownDynamic(const inputbind_t action);
/**
* Checks if an action was pressed this dynamic frame (down now, not before).
@@ -92,7 +92,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to check.
* @return true if the action was just pressed this dynamic frame.
*/
bool_t inputPressedDynamic(const inputaction_t action);
bool_t inputPressedDynamic(const inputbind_t action);
/**
* Checks if an action was released this dynamic frame (up now, down before).
@@ -100,7 +100,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to check.
* @return true if the action was just released this dynamic frame.
*/
bool_t inputReleasedDynamic(const inputaction_t action);
bool_t inputReleasedDynamic(const inputbind_t action);
/**
* Gets the value of an input axis on the dynamic timestep.
@@ -110,8 +110,8 @@ float_t inputGetLastValue(const inputaction_t action);
* @return The current axis value (-1.0f to 1.0f).
*/
float_t inputAxisDynamic(
const inputaction_t neg,
const inputaction_t pos
const inputbind_t neg,
const inputbind_t pos
);
/**
@@ -124,8 +124,8 @@ float_t inputGetLastValue(const inputaction_t action);
* @param result vec2 to store the result (-1.0f to 1.0f per axis).
*/
void inputAxis2DDynamic(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
);
@@ -139,8 +139,8 @@ float_t inputGetLastValue(const inputaction_t action);
* @param result vec2 to store the result.
*/
void inputAngle2DDynamic(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
);
#endif
@@ -151,7 +151,7 @@ float_t inputGetLastValue(const inputaction_t action);
* @param action The input action to check.
* @return true if the action is currently pressed, false otherwise.
*/
bool_t inputIsDown(const inputaction_t action);
bool_t inputIsDown(const inputbind_t action);
/**
* Checks if a specific input action was pressed in the last update.
@@ -159,7 +159,7 @@ bool_t inputIsDown(const inputaction_t action);
* @param action The input action to check.
* @return true if the action was pressed in the last update, false otherwise.
*/
bool_t inputWasDown(const inputaction_t action);
bool_t inputWasDown(const inputbind_t action);
/**
* Checks if a specific input action was down this frame but not in the the
@@ -168,7 +168,7 @@ bool_t inputWasDown(const inputaction_t action);
* @param action The input action to check.
* @return true if the action is currently pressed, false otherwise.
*/
bool_t inputPressed(const inputaction_t action);
bool_t inputPressed(const inputbind_t action);
/**
* Checks if a specific input action was released this frame.
@@ -176,7 +176,7 @@ bool_t inputPressed(const inputaction_t action);
* @param action The input action to check.
* @return true if the action was released this frame, false otherwise.
*/
bool_t inputReleased(const inputaction_t action);
bool_t inputReleased(const inputbind_t action);
/**
* Gets the value of an input axis, defined by two actions (negative and
@@ -186,7 +186,7 @@ bool_t inputReleased(const inputaction_t action);
* @param pos The action representing the positive direction of the axis.
* @return The current value of the axis (-1.0f to 1.0f).
*/
float_t inputAxis(const inputaction_t neg, const inputaction_t pos);
float_t inputAxis(const inputbind_t neg, const inputbind_t pos);
/**
* Gets the values of a 2D input axis, defined by two pairs of actions (negative
@@ -199,8 +199,8 @@ float_t inputAxis(const inputaction_t neg, const inputaction_t pos);
* @param result A vec2 to store the resulting axis values (-1.0f to 1.0f).
*/
void inputAxis2D(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
);
@@ -215,8 +215,8 @@ void inputAxis2D(
* @param result A vec2 to store the resulting axis values (-1.0f to 1.0f).
*/
void inputAngle2D(
const inputaction_t negX, const inputaction_t posX,
const inputaction_t negY, const inputaction_t posY,
const inputbind_t negX, const inputbind_t posX,
const inputbind_t negY, const inputbind_t posY,
vec2 result
);
@@ -226,7 +226,7 @@ void inputAngle2D(
* @param button The input button to bind.
* @param action The input action to bind the button to.
*/
void inputBind(const inputbutton_t button, const inputaction_t act);
void inputBind(const inputbutton_t button, const inputbind_t act);
/**
* Applies deadzone formula to a raw input value. This will;
+7 -7
View File
@@ -9,14 +9,14 @@
#include "assert/assert.h"
#include "util/string.h"
inputaction_t inputActionGetByName(const char_t *name) {
inputbind_t inputActionGetByName(const char_t *name) {
assertNotNull(name, "name must not be NULL");
for(inputaction_t i = 0; i < INPUT_ACTION_COUNT; i++) {
if(INPUT_ACTION_IDS[i] == NULL) continue;
if(stringCompareInsensitive(INPUT_ACTION_IDS[i], name) != 0) continue;
for(inputbind_t i = 0; i < INPUT_BIND_COUNT; i++) {
if(INPUT_BIND_IDS[i] == NULL) continue;
if(stringCompareInsensitive(INPUT_BIND_IDS[i], name) != 0) continue;
return i;
}
return INPUT_ACTION_COUNT;
return INPUT_BIND_COUNT;
}
+4 -4
View File
@@ -7,10 +7,10 @@
#pragma once
#include "time/time.h"
#include "input/inputactiondefs.h"
#include "input/inputbind.h"
typedef struct {
inputaction_t action;
inputbind_t action;
float_t lastValue;
float_t currentValue;
@@ -24,6 +24,6 @@ typedef struct {
* Gets an input action by its name.
*
* @param name The name of the input action.
* @return The input action, or INPUT_ACTION_COUNT if not found.
* @return The input action, or INPUT_BIND_COUNT if not found.
*/
inputaction_t inputActionGetByName(const char_t *name);
inputbind_t inputActionGetByName(const char_t *name);
+2 -2
View File
@@ -6,7 +6,7 @@
*/
#pragma once
#include "inputaction.h"
#include "input/inputaction.h"
#include "input/inputplatform.h"
#ifndef inputButtonGetValuePlatform
@@ -77,7 +77,7 @@ typedef struct {
inputbutton_t button;
float_t curVal;
float_t lastVal;
inputaction_t action;
inputbind_t action;
} inputbuttondata_t;
extern inputbuttondata_t INPUT_BUTTON_DATA[];
@@ -3,8 +3,11 @@
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
physicsbody.c
physicsworld.c
physicstest.c
physicsshapemesh.c
triggersystem.c
)
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
typedef enum {
/**
* Never moves. Acts as an immovable collision surface.
*/
PHYSICS_BODY_STATIC,
/**
* Simulated by the world step: gravity, forces, and collision response.
*/
PHYSICS_BODY_DYNAMIC,
/**
* Moved programmatically via the owning entity's position component;
* collides but is not driven by the simulation. Typical use: player
* character controller.
*/
PHYSICS_BODY_KINEMATIC
} physicsbodytype_t;
+110
View File
@@ -0,0 +1,110 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
#include "error/error.h"
typedef enum {
PHYSICS_SHAPE_CUBE,
PHYSICS_SHAPE_SPHERE,
PHYSICS_SHAPE_CAPSULE,
PHYSICS_SHAPE_PLANE,
/**
* Arbitrary shape of any complexity (heightfield, mesh, etc), tested via
* a caller-supplied callback rather than a built-in primitive routine.
* See physicsshapecustom_t.
*/
PHYSICS_SHAPE_CUSTOM
} physicshapetype_t;
typedef struct {
vec3 halfExtents;
} physicsshapecube_t;
typedef struct {
float_t radius;
} physicsshapesphere_t;
typedef struct {
float_t radius;
float_t halfHeight;
} physicsshapecapsule_t;
typedef struct {
vec3 normal;
float_t distance;
} physicsshapeplane_t;
typedef struct physicsshape_t physicsshape_t;
/**
* Tests a custom shape (positioned at selfPos, describing itself via
* selfData) against another shape (positioned at otherPos). Return true
* and fill outNormal/outDepth if the shapes overlap.
*
* outNormal points from self toward other -- the direction to push other
* away from self, e.g. straight up for a body resting on flat ground.
* This is the opposite convention from physicsTestShapeVsShape's
* A-vs-B/outNormal (which points from B toward A); physicsWorldStep
* always queries a custom shape as the static/kinematic side, so this
* convention lets an implementation delegate directly to a primitive test
* like physicsTestSphereVsPlane (self=ground, other=body) and return its
* result unmodified.
*
* @param selfPos The custom shape's position.
* @param selfData The custom shape's own describing data (physicsshapecustom_t.userData).
* @param otherPos The other shape's position.
* @param otherShape The other shape's full descriptor. May itself be any
* type except PHYSICS_SHAPE_CUSTOM -- custom-vs-custom is not supported.
* @param outNormal Push-out normal, pointing from self toward other.
* @param outDepth Penetration depth (positive when overlapping).
* @return true if the shapes overlap, false otherwise.
*/
typedef bool_t (*physicsshapecustomtest_t)(
const vec3 selfPos,
const void *selfData,
const vec3 otherPos,
const physicsshape_t *otherShape,
vec3 outNormal,
float_t *outDepth
);
/**
* A shape of arbitrary complexity, e.g. landscape/terrain geometry, that
* the built-in primitive tests (cube/sphere/capsule/plane) cannot describe.
* Rather than embedding the shape's data (which may be large -- a
* heightfield, a triangle mesh, a BVH) inline, this just holds an opaque
* pointer plus a callback that the physics system invokes to test overlap
* against it.
*
* Ownership: userData is not owned or freed by the physics system. The
* struct/asset that actually describes the shape (e.g. a chunk's
* heightfield) must outlive this physicsshape_t -- typically it's owned by
* whatever entity/asset also owns the entityphysics_t component that holds
* this shape.
*/
typedef struct {
/** Opaque pointer to the owner's shape-describing data. */
void *userData;
/** Callback invoked to test this shape against another. */
physicsshapecustomtest_t test;
} physicsshapecustom_t;
typedef union {
physicsshapecube_t cube;
physicsshapesphere_t sphere;
physicsshapecapsule_t capsule;
physicsshapeplane_t plane;
physicsshapecustom_t custom;
} physicsshapedata_t;
typedef struct physicsshape_t {
physicshapetype_t type;
physicsshapedata_t data;
} physicsshape_t;
+207
View File
@@ -0,0 +1,207 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "physicsshapemesh.h"
#include "physicstest.h"
#include "assert/assert.h"
void physicsShapeMeshGetVertex(
const physicsshapemesh_t *mesh, const uint32_t index, vec3 out
) {
assertNotNull(mesh, "Mesh cannot be null");
assertTrue(index < mesh->triangleCount * 3, "Vertex index OOB");
const float_t *p = (const float_t *)(mesh->vertices + (size_t)index * mesh->stride);
out[0] = p[0];
out[1] = p[1];
out[2] = p[2];
}
void physicsShapeMeshComputeBounds(physicsshapemesh_t *mesh) {
assertNotNull(mesh, "Mesh cannot be null");
const uint32_t vertexCount = mesh->triangleCount * 3;
if(vertexCount == 0) {
glm_vec3_zero(mesh->boundsCenter);
mesh->boundsRadius = 0.0f;
return;
}
vec3 minPoint, maxPoint;
physicsShapeMeshGetVertex(mesh, 0, minPoint);
glm_vec3_copy(minPoint, maxPoint);
for(uint32_t i = 1; i < vertexCount; i++) {
vec3 v;
physicsShapeMeshGetVertex(mesh, i, v);
if(v[0] < minPoint[0]) minPoint[0] = v[0];
if(v[1] < minPoint[1]) minPoint[1] = v[1];
if(v[2] < minPoint[2]) minPoint[2] = v[2];
if(v[0] > maxPoint[0]) maxPoint[0] = v[0];
if(v[1] > maxPoint[1]) maxPoint[1] = v[1];
if(v[2] > maxPoint[2]) maxPoint[2] = v[2];
}
glm_vec3_add(minPoint, maxPoint, mesh->boundsCenter);
glm_vec3_scale(mesh->boundsCenter, 0.5f, mesh->boundsCenter);
vec3 extent;
glm_vec3_sub(maxPoint, mesh->boundsCenter, extent);
mesh->boundsRadius = glm_vec3_norm(extent);
}
bool_t physicsShapeMeshTestSphere(
const physicsshapemesh_t *mesh, const vec3 meshPos,
const vec3 sphereCenter, const float_t sphereRadius,
vec3 outNormal, float_t *outDepth
) {
assertNotNull(mesh, "Mesh cannot be null");
// Broad-phase: reject in O(1) against the cached bounding sphere before
// scanning every triangle.
vec3 worldBoundsCenter;
glm_vec3_add((float_t *)mesh->boundsCenter, (float_t *)meshPos, worldBoundsCenter);
float_t rejectRadius = mesh->boundsRadius + sphereRadius;
vec3 toCenter;
glm_vec3_sub((float_t *)sphereCenter, worldBoundsCenter, toCenter);
if(glm_vec3_norm2(toCenter) >= rejectRadius * rejectRadius) return false;
bool_t found = false;
float_t bestDist2 = 0.0f;
vec3 bestPoint;
for(uint32_t i = 0; i < mesh->triangleCount; i++) {
vec3 a, b, c;
physicsShapeMeshGetVertex(mesh, i * 3 + 0, a);
physicsShapeMeshGetVertex(mesh, i * 3 + 1, b);
physicsShapeMeshGetVertex(mesh, i * 3 + 2, c);
glm_vec3_add(a, (float_t *)meshPos, a);
glm_vec3_add(b, (float_t *)meshPos, b);
glm_vec3_add(c, (float_t *)meshPos, c);
vec3 closest;
physicsTestClosestPointOnTriangle(sphereCenter, a, b, c, closest);
vec3 diff;
glm_vec3_sub((float_t *)sphereCenter, closest, diff);
float_t dist2 = glm_vec3_norm2(diff);
if(found && dist2 >= bestDist2) continue;
found = true;
bestDist2 = dist2;
glm_vec3_copy(closest, bestPoint);
}
if(!found || bestDist2 >= sphereRadius * sphereRadius) return false;
float_t dist = sqrtf(bestDist2);
*outDepth = sphereRadius - dist;
vec3 diff;
glm_vec3_sub((float_t *)sphereCenter, bestPoint, diff);
if(dist > 1e-6f) {
glm_vec3_scale(diff, 1.0f / dist, outNormal);
} else {
outNormal[0] = 0.0f;
outNormal[1] = 1.0f;
outNormal[2] = 0.0f;
}
return true;
}
bool_t physicsShapeMeshTestCapsule(
const physicsshapemesh_t *mesh, const vec3 meshPos,
const vec3 capsuleCenter, const float_t capsuleRadius,
const float_t capsuleHalfHeight,
vec3 outNormal, float_t *outDepth
) {
assertNotNull(mesh, "Mesh cannot be null");
// Broad-phase: reject the whole capsule (skipping every sample sphere
// below) against the cached bounding sphere before doing any real work.
// The capsule's own bounding sphere (radius + half-height) is a looser
// bound than its true shape, but cheap and always conservative.
vec3 worldBoundsCenter;
glm_vec3_add((float_t *)mesh->boundsCenter, (float_t *)meshPos, worldBoundsCenter);
float_t rejectRadius = mesh->boundsRadius + capsuleRadius + capsuleHalfHeight;
vec3 toCenter;
glm_vec3_sub((float_t *)capsuleCenter, worldBoundsCenter, toCenter);
if(glm_vec3_norm2(toCenter) >= rejectRadius * rejectRadius) return false;
vec3 capA = {
capsuleCenter[0], capsuleCenter[1] - capsuleHalfHeight, capsuleCenter[2]
};
vec3 capB = {
capsuleCenter[0], capsuleCenter[1] + capsuleHalfHeight, capsuleCenter[2]
};
int32_t sampleCount = capsuleRadius > 1e-6f
? (int32_t)ceilf((2.0f * capsuleHalfHeight) / capsuleRadius) + 1
: 2;
if(sampleCount < 2) sampleCount = 2;
if(sampleCount > PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX) {
sampleCount = PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX;
}
bool_t found = false;
vec3 bestNormal = { 0.0f, 1.0f, 0.0f };
float_t bestDepth = 0.0f;
for(int32_t i = 0; i < sampleCount; i++) {
float_t t = (float_t)i / (float_t)(sampleCount - 1);
vec3 samplePoint;
glm_vec3_lerp(capA, capB, t, samplePoint);
vec3 normal; float_t depth;
if(!physicsShapeMeshTestSphere(
mesh, meshPos, samplePoint, capsuleRadius, normal, &depth
)) continue;
if(found && depth <= bestDepth) continue;
found = true;
bestDepth = depth;
glm_vec3_copy(normal, bestNormal);
}
if(!found) return false;
*outDepth = bestDepth;
glm_vec3_copy(bestNormal, outNormal);
return true;
}
bool_t physicsShapeMeshTest(
const vec3 selfPos, const void *selfData,
const vec3 otherPos, const physicsshape_t *otherShape,
vec3 outNormal, float_t *outDepth
) {
const physicsshapemesh_t *mesh = (const physicsshapemesh_t *)selfData;
switch(otherShape->type) {
case PHYSICS_SHAPE_SPHERE:
return physicsShapeMeshTestSphere(
mesh, selfPos, otherPos, otherShape->data.sphere.radius,
outNormal, outDepth
);
case PHYSICS_SHAPE_CAPSULE:
return physicsShapeMeshTestCapsule(
mesh, selfPos, otherPos,
otherShape->data.capsule.radius, otherShape->data.capsule.halfHeight,
outNormal, outDepth
);
default:
return false;
}
}
physicsshape_t physicsShapeMeshCreate(physicsshapemesh_t *mesh) {
assertNotNull(mesh, "Mesh cannot be null");
physicsShapeMeshComputeBounds(mesh);
return (physicsshape_t){
.type = PHYSICS_SHAPE_CUSTOM,
.data.custom = { .userData = (void *)mesh, .test = physicsShapeMeshTest }
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "physicsshape.h"
/**
* Capsule-vs-mesh is approximated by sampling spheres along the capsule's
* axis (see physicsShapeMeshTestCapsule). This bounds how many samples a
* single query can take, regardless of how tall/thin the capsule is.
*/
#define PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX 8
/**
* Describes an arbitrary triangle mesh (e.g. landscape/terrain geometry)
* for use as a PHYSICS_SHAPE_CUSTOM shape via physicsShapeMeshCreate().
*
* Vertex positions are read directly out of `vertices` at `stride`-byte
* intervals, so this can point straight at an existing render vertex
* buffer (e.g. `&myMeshVertices[0].pos` with `stride = sizeof(meshvertex_t)`)
* with no need to duplicate the geometry for physics -- as long as each
* vertex's position is 3 consecutive float_t.
*
* Not copied or owned: this struct (and the buffer it points into) must
* outlive any physicsshape_t built from it, so it's typically a field
* alongside whatever owns the mesh (e.g. a terrain chunk).
*
* A query first rejects against the cached local-space bounding sphere
* (boundsCenter/boundsRadius, see physicsShapeMeshComputeBounds) in O(1);
* only once that overlaps does it fall through to scanning all
* triangleCount triangles. There's still no finer broad-phase/BVH within
* a mesh, so for good performance keep each mesh shape scoped to one
* reasonably-sized chunk rather than an entire world, and only add nearby
* chunks' physics bodies to the scene.
*/
typedef struct {
/** Pointer to the first triangle vertex's position (3 consecutive float_t). */
const uint8_t *vertices;
/** Byte stride between consecutive vertex positions. */
size_t stride;
/** Number of triangles. `vertices` holds triangleCount * 3 positions. */
uint32_t triangleCount;
/**
* Cached local-space bounding sphere, set by
* physicsShapeMeshComputeBounds() (called once by physicsShapeMeshCreate).
* Used to reject a query in O(1) before scanning every triangle.
*/
vec3 boundsCenter;
/** Cached local-space bounding sphere radius. See boundsCenter. */
float_t boundsRadius;
} physicsshapemesh_t;
/**
* Reads the world-space position of a single mesh vertex (vertex index,
* not triangle index -- pass triangleIndex * 3 + 0/1/2 for a triangle's
* corners).
*
* @param mesh The mesh to read from.
* @param index Vertex index, in [0, triangleCount * 3).
* @param out Receives the vertex position.
*/
void physicsShapeMeshGetVertex(
const physicsshapemesh_t *mesh, const uint32_t index, vec3 out
);
/**
* Computes and caches mesh's local-space bounding sphere (boundsCenter/
* boundsRadius) from its current vertices, by scanning every vertex once.
* Called automatically by physicsShapeMeshCreate(); call again yourself
* only if you mutate the underlying vertex buffer afterward (e.g. terrain
* deformation) and need the cached bounds to stay accurate.
*
* @param mesh The mesh to compute bounds for; boundsCenter/boundsRadius
* are written in place.
*/
void physicsShapeMeshComputeBounds(physicsshapemesh_t *mesh);
/**
* Tests a sphere against every triangle in the mesh, keeping only the
* single closest triangle. outNormal points from the mesh surface toward
* the sphere (self toward other, per physicsshapecustomtest_t).
*
* @param mesh The mesh to test against.
* @param meshPos World-space position of the mesh shape's owning entity;
* added to every vertex read from mesh.
* @param sphereCenter World-space center of the sphere.
* @param sphereRadius Radius of the sphere.
* @param outNormal Push-out normal (mesh surface toward sphere).
* @param outDepth Penetration depth.
* @return true if the sphere overlaps the mesh.
*/
bool_t physicsShapeMeshTestSphere(
const physicsshapemesh_t *mesh, const vec3 meshPos,
const vec3 sphereCenter, const float_t sphereRadius,
vec3 outNormal, float_t *outDepth
);
/**
* Tests a Y-axis-aligned capsule against the mesh by approximating the
* capsule as a series of spheres sampled along its axis (see
* PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX) and keeping the deepest overlap
* found. An approximation, not an exact capsule-vs-triangle test.
*
* @param mesh The mesh to test against.
* @param meshPos World-space position of the mesh shape's owning entity.
* @param capsuleCenter World-space center of the capsule.
* @param capsuleRadius Radius of the capsule.
* @param capsuleHalfHeight Half-height of the capsule's cylindrical segment.
* @param outNormal Push-out normal (mesh surface toward capsule).
* @param outDepth Penetration depth.
* @return true if the capsule overlaps the mesh.
*/
bool_t physicsShapeMeshTestCapsule(
const physicsshapemesh_t *mesh, const vec3 meshPos,
const vec3 capsuleCenter, const float_t capsuleRadius,
const float_t capsuleHalfHeight,
vec3 outNormal, float_t *outDepth
);
/**
* physicsshapecustomtest_t implementation backing physicsShapeMeshCreate().
* Routes to physicsShapeMeshTestSphere/TestCapsule depending on
* otherShape's type. Cube (AABB) bodies are not supported yet and never
* collide against a mesh shape.
*
* @param selfPos The mesh shape's position.
* @param selfData The physicsshapemesh_t this shape was created from.
* @param otherPos The other shape's position.
* @param otherShape The other shape's descriptor.
* @param outNormal Push-out normal (mesh surface toward other).
* @param outDepth Penetration depth.
* @return true if the shapes overlap.
*/
bool_t physicsShapeMeshTest(
const vec3 selfPos, const void *selfData,
const vec3 otherPos, const physicsshape_t *otherShape,
vec3 outNormal, float_t *outDepth
);
/**
* Builds a PHYSICS_SHAPE_CUSTOM shape backed by the given triangle mesh.
* Calls physicsShapeMeshComputeBounds(mesh) once to populate its cached
* bounding sphere.
*
* @param mesh Mesh descriptor; boundsCenter/boundsRadius are computed and
* written in place. Must outlive the returned shape (see
* physicsshapemesh_t).
* @return A physicsshape_t wired to test against this mesh.
*/
physicsshape_t physicsShapeMeshCreate(physicsshapemesh_t *mesh);
+500
View File
@@ -0,0 +1,500 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "physicstest.h"
#include "assert/assert.h"
bool_t physicsTestAabbVsAabb(
const vec3 ac, const vec3 ah,
const vec3 bc, const vec3 bh,
vec3 outNormal, float_t *outDepth
) {
float_t dx = ac[0] - bc[0];
float_t dy = ac[1] - bc[1];
float_t dz = ac[2] - bc[2];
float_t px = (ah[0] + bh[0]) - fabsf(dx);
float_t py = (ah[1] + bh[1]) - fabsf(dy);
float_t pz = (ah[2] + bh[2]) - fabsf(dz);
if(px <= 0.0f || py <= 0.0f || pz <= 0.0f) return false;
outNormal[0] = outNormal[1] = outNormal[2] = 0.0f;
if(px < py && px < pz) {
*outDepth = px;
outNormal[0] = dx >= 0.0f ? 1.0f : -1.0f;
} else if(py < pz) {
*outDepth = py;
outNormal[1] = dy >= 0.0f ? 1.0f : -1.0f;
} else {
*outDepth = pz;
outNormal[2] = dz >= 0.0f ? 1.0f : -1.0f;
}
return true;
}
bool_t physicsTestSphereVsSphere(
const vec3 ac, const float_t ar,
const vec3 bc, const float_t br,
vec3 outNormal, float_t *outDepth
) {
vec3 diff;
glm_vec3_sub((float_t *)ac, (float_t *)bc, diff);
float_t dist2 = glm_vec3_norm2(diff);
float_t sumR = ar + br;
if(dist2 >= sumR * sumR) return false;
float_t dist = sqrtf(dist2);
*outDepth = sumR - dist;
if(dist > 1e-6f) {
glm_vec3_scale(diff, 1.0f / dist, outNormal);
} else {
outNormal[0] = 0.0f;
outNormal[1] = 1.0f;
outNormal[2] = 0.0f;
}
return true;
}
bool_t physicsTestSphereVsAabb(
const vec3 sc, const float_t sr,
const vec3 ac, const vec3 ah,
vec3 outNormal, float_t *outDepth
) {
vec3 closest = {
glm_clamp(sc[0], ac[0] - ah[0], ac[0] + ah[0]),
glm_clamp(sc[1], ac[1] - ah[1], ac[1] + ah[1]),
glm_clamp(sc[2], ac[2] - ah[2], ac[2] + ah[2])
};
vec3 diff;
glm_vec3_sub((float_t *)sc, closest, diff);
float_t dist2 = glm_vec3_norm2(diff);
bool_t inside = (dist2 < 1e-10f);
if(!inside && dist2 >= sr * sr) return false;
if(!inside) {
float_t dist = sqrtf(dist2);
*outDepth = sr - dist;
glm_vec3_scale(diff, 1.0f / dist, outNormal);
} else {
float_t faces[6] = {
(ac[0] + ah[0]) - sc[0],
sc[0] - (ac[0] - ah[0]),
(ac[1] + ah[1]) - sc[1],
sc[1] - (ac[1] - ah[1]),
(ac[2] + ah[2]) - sc[2],
sc[2] - (ac[2] - ah[2])
};
const float_t normals[6][3] = {
{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}
};
int32_t mi = 0;
for(int32_t k = 1; k < 6; k++) {
if(faces[k] < faces[mi]) mi = k;
}
*outDepth = sr + faces[mi];
outNormal[0] = normals[mi][0];
outNormal[1] = normals[mi][1];
outNormal[2] = normals[mi][2];
}
return true;
}
bool_t physicsTestSphereVsPlane(
const vec3 sc, const float_t sr,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
) {
float_t signedDist = glm_vec3_dot((float_t *)pn, (float_t *)sc) - pd;
*outDepth = sr - signedDist;
if(*outDepth <= 0.0f) return false;
glm_vec3_copy((float_t *)pn, outNormal);
return true;
}
bool_t physicsTestAabbVsPlane(
const vec3 ac, const vec3 ah,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
) {
float_t proj = fabsf(pn[0] * ah[0])
+ fabsf(pn[1] * ah[1])
+ fabsf(pn[2] * ah[2]);
float_t signedDist = glm_vec3_dot((float_t *)pn, (float_t *)ac) - pd;
*outDepth = proj - signedDist;
if(*outDepth <= 0.0f) return false;
glm_vec3_copy((float_t *)pn, outNormal);
return true;
}
void physicsTestClosestPointOnSegment(
const vec3 a, const vec3 b, const vec3 p, vec3 out
) {
vec3 ab, ap;
glm_vec3_sub((float_t *)b, (float_t *)a, ab);
glm_vec3_sub((float_t *)p, (float_t *)a, ap);
float_t denom = glm_vec3_dot(ab, ab);
float_t t = (denom > 1e-10f)
? glm_clamp(glm_vec3_dot(ap, ab) / denom, 0.0f, 1.0f)
: 0.0f;
glm_vec3_lerp((float_t *)a, (float_t *)b, t, out);
}
void physicsTestClosestPointOnTriangle(
const vec3 p, const vec3 a, const vec3 b, const vec3 c, vec3 out
) {
vec3 ab, ac, ap;
glm_vec3_sub((float_t *)b, (float_t *)a, ab);
glm_vec3_sub((float_t *)c, (float_t *)a, ac);
glm_vec3_sub((float_t *)p, (float_t *)a, ap);
float_t d1 = glm_vec3_dot(ab, ap);
float_t d2 = glm_vec3_dot(ac, ap);
if(d1 <= 0.0f && d2 <= 0.0f) {
glm_vec3_copy((float_t *)a, out);
return;
}
vec3 bp;
glm_vec3_sub((float_t *)p, (float_t *)b, bp);
float_t d3 = glm_vec3_dot(ab, bp);
float_t d4 = glm_vec3_dot(ac, bp);
if(d3 >= 0.0f && d4 <= d3) {
glm_vec3_copy((float_t *)b, out);
return;
}
float_t vc = d1 * d4 - d3 * d2;
if(vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) {
float_t v = d1 / (d1 - d3);
vec3 scaled;
glm_vec3_scale(ab, v, scaled);
glm_vec3_add((float_t *)a, scaled, out);
return;
}
vec3 cp;
glm_vec3_sub((float_t *)p, (float_t *)c, cp);
float_t d5 = glm_vec3_dot(ab, cp);
float_t d6 = glm_vec3_dot(ac, cp);
if(d6 >= 0.0f && d5 <= d6) {
glm_vec3_copy((float_t *)c, out);
return;
}
float_t vb = d5 * d2 - d1 * d6;
if(vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) {
float_t w = d2 / (d2 - d6);
vec3 scaled;
glm_vec3_scale(ac, w, scaled);
glm_vec3_add((float_t *)a, scaled, out);
return;
}
float_t va = d3 * d6 - d5 * d4;
if(va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) {
float_t w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
vec3 bc, scaled;
glm_vec3_sub((float_t *)c, (float_t *)b, bc);
glm_vec3_scale(bc, w, scaled);
glm_vec3_add((float_t *)b, scaled, out);
return;
}
// Interior: barycentric combination of ab and ac from a.
float_t denom = 1.0f / (va + vb + vc);
float_t v = vb * denom;
float_t w = vc * denom;
vec3 abScaled, acScaled, sum;
glm_vec3_scale(ab, v, abScaled);
glm_vec3_scale(ac, w, acScaled);
glm_vec3_add(abScaled, acScaled, sum);
glm_vec3_add((float_t *)a, sum, out);
}
void physicsTestClosestPointsBetweenSegments(
const vec3 a1, const vec3 b1,
const vec3 a2, const vec3 b2,
vec3 outP1, vec3 outP2
) {
vec3 d1, d2, r;
glm_vec3_sub((float_t *)b1, (float_t *)a1, d1);
glm_vec3_sub((float_t *)b2, (float_t *)a2, d2);
glm_vec3_sub((float_t *)a1, (float_t *)a2, r);
float_t a = glm_vec3_dot(d1, d1);
float_t e = glm_vec3_dot(d2, d2);
float_t f = glm_vec3_dot(d2, r);
float_t s, t;
if(a <= 1e-10f && e <= 1e-10f) {
glm_vec3_copy((float_t *)a1, outP1);
glm_vec3_copy((float_t *)a2, outP2);
return;
}
if(a <= 1e-10f) {
t = 0.0f;
s = glm_clamp(f / e, 0.0f, 1.0f);
} else {
float_t c = glm_vec3_dot(d1, r);
if(e <= 1e-10f) {
s = 0.0f;
t = glm_clamp(-c / a, 0.0f, 1.0f);
} else {
float_t b = glm_vec3_dot(d1, d2);
float_t denom = a * e - b * b;
t = (fabsf(denom) > 1e-10f)
? glm_clamp((b * f - c * e) / denom, 0.0f, 1.0f)
: 0.0f;
s = glm_clamp((b * t + f) / e, 0.0f, 1.0f);
t = glm_clamp((b * s - c) / a, 0.0f, 1.0f);
}
}
glm_vec3_lerp((float_t *)a1, (float_t *)b1, t, outP1);
glm_vec3_lerp((float_t *)a2, (float_t *)b2, s, outP2);
}
bool_t physicsTestCapsuleVsSphere(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 sc, const float_t sr,
vec3 outNormal, float_t *outDepth
) {
vec3 capA = { cc[0], cc[1] - chh, cc[2] };
vec3 capB = { cc[0], cc[1] + chh, cc[2] };
vec3 closest;
physicsTestClosestPointOnSegment(capA, capB, sc, closest);
return physicsTestSphereVsSphere(
closest, cr, sc, sr, outNormal, outDepth
);
}
bool_t physicsTestCapsuleVsAabb(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 ac, const vec3 ah,
vec3 outNormal, float_t *outDepth
) {
vec3 capA = { cc[0], cc[1] - chh, cc[2] };
vec3 capB = { cc[0], cc[1] + chh, cc[2] };
vec3 closest;
physicsTestClosestPointOnSegment(capA, capB, ac, closest);
return physicsTestSphereVsAabb(
closest, cr, ac, ah, outNormal, outDepth
);
}
bool_t physicsTestCapsuleVsPlane(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
) {
vec3 capA = { cc[0], cc[1] - chh, cc[2] };
vec3 capB = { cc[0], cc[1] + chh, cc[2] };
float_t da = glm_vec3_dot((float_t *)pn, capA) - pd;
float_t db = glm_vec3_dot((float_t *)pn, capB) - pd;
float_t minDist = (da < db) ? da : db;
*outDepth = cr - minDist;
if(*outDepth <= 0.0f) return false;
glm_vec3_copy((float_t *)pn, outNormal);
return true;
}
bool_t physicsTestCapsuleVsCapsule(
const vec3 c1, const float_t r1, const float_t hh1,
const vec3 c2, const float_t r2, const float_t hh2,
vec3 outNormal, float_t *outDepth
) {
vec3 a1 = { c1[0], c1[1] - hh1, c1[2] };
vec3 b1 = { c1[0], c1[1] + hh1, c1[2] };
vec3 a2 = { c2[0], c2[1] - hh2, c2[2] };
vec3 b2 = { c2[0], c2[1] + hh2, c2[2] };
vec3 p1, p2;
physicsTestClosestPointsBetweenSegments(a1, b1, a2, b2, p1, p2);
return physicsTestSphereVsSphere(p1, r1, p2, r2, outNormal, outDepth);
}
bool_t physicsTestDispatch(
const vec3 aPos, const physicsshape_t *aShape,
const vec3 bPos, const physicsshape_t *bShape,
vec3 outNormal, float_t *outDepth
) {
physicshapetype_t ta = aShape->type;
physicshapetype_t tb = bShape->type;
assertFalse(
ta == PHYSICS_SHAPE_CUSTOM && tb == PHYSICS_SHAPE_CUSTOM,
"Custom-vs-custom shape collision is not supported"
);
if(ta == PHYSICS_SHAPE_CUSTOM) {
// Callback returns self(A)->other(B); this function's contract needs
// B->A, so negate.
vec3 tmp; float_t d;
if(!aShape->data.custom.test(
aPos, aShape->data.custom.userData, bPos, bShape, tmp, &d
)) return false;
glm_vec3_scale(tmp, -1.0f, outNormal);
*outDepth = d;
return true;
}
if(tb == PHYSICS_SHAPE_CUSTOM) {
// Callback returns self(B)->other(A), which already matches this
// function's B->A contract -- no negation needed.
return bShape->data.custom.test(
bPos, bShape->data.custom.userData, aPos, aShape, outNormal, outDepth
);
}
if(tb == PHYSICS_SHAPE_PLANE) {
const float_t *pn = bShape->data.plane.normal;
const float_t pd = bShape->data.plane.distance;
switch(ta) {
case PHYSICS_SHAPE_CUBE:
return physicsTestAabbVsPlane(
aPos, aShape->data.cube.halfExtents,
pn, pd, outNormal, outDepth
);
case PHYSICS_SHAPE_SPHERE:
return physicsTestSphereVsPlane(
aPos, aShape->data.sphere.radius,
pn, pd, outNormal, outDepth
);
case PHYSICS_SHAPE_CAPSULE:
return physicsTestCapsuleVsPlane(
aPos,
aShape->data.capsule.radius,
aShape->data.capsule.halfHeight,
pn, pd, outNormal, outDepth
);
default:
return false;
}
}
if(ta == PHYSICS_SHAPE_PLANE) {
vec3 tmp; float_t d;
if(!physicsTestDispatch(
bPos, bShape, aPos, aShape, tmp, &d
)) return false;
glm_vec3_scale(tmp, -1.0f, outNormal);
*outDepth = d;
return true;
}
switch(ta) {
case PHYSICS_SHAPE_CUBE: {
const float_t *ac = aPos;
const float_t *ah = aShape->data.cube.halfExtents;
switch(tb) {
case PHYSICS_SHAPE_CUBE:
return physicsTestAabbVsAabb(
ac, ah,
bPos, bShape->data.cube.halfExtents,
outNormal, outDepth
);
case PHYSICS_SHAPE_SPHERE: {
vec3 tmp; float_t d;
if(!physicsTestSphereVsAabb(
bPos, bShape->data.sphere.radius,
ac, ah, tmp, &d
)) return false;
glm_vec3_scale(tmp, -1.0f, outNormal);
*outDepth = d;
return true;
}
case PHYSICS_SHAPE_CAPSULE: {
vec3 tmp; float_t d;
if(!physicsTestCapsuleVsAabb(
bPos,
bShape->data.capsule.radius,
bShape->data.capsule.halfHeight,
ac, ah, tmp, &d
)) return false;
glm_vec3_scale(tmp, -1.0f, outNormal);
*outDepth = d;
return true;
}
default: return false;
}
}
case PHYSICS_SHAPE_SPHERE: {
const float_t sr = aShape->data.sphere.radius;
switch(tb) {
case PHYSICS_SHAPE_CUBE:
return physicsTestSphereVsAabb(
aPos, sr,
bPos, bShape->data.cube.halfExtents,
outNormal, outDepth
);
case PHYSICS_SHAPE_SPHERE:
return physicsTestSphereVsSphere(
aPos, sr,
bPos, bShape->data.sphere.radius,
outNormal, outDepth
);
case PHYSICS_SHAPE_CAPSULE: {
vec3 tmp; float_t d;
if(!physicsTestCapsuleVsSphere(
bPos,
bShape->data.capsule.radius,
bShape->data.capsule.halfHeight,
aPos, sr, tmp, &d
)) return false;
glm_vec3_scale(tmp, -1.0f, outNormal);
*outDepth = d;
return true;
}
default: return false;
}
}
case PHYSICS_SHAPE_CAPSULE: {
const float_t cr = aShape->data.capsule.radius;
const float_t chh = aShape->data.capsule.halfHeight;
switch(tb) {
case PHYSICS_SHAPE_CUBE:
return physicsTestCapsuleVsAabb(
aPos, cr, chh,
bPos, bShape->data.cube.halfExtents,
outNormal, outDepth
);
case PHYSICS_SHAPE_SPHERE:
return physicsTestCapsuleVsSphere(
aPos, cr, chh,
bPos, bShape->data.sphere.radius,
outNormal, outDepth
);
case PHYSICS_SHAPE_CAPSULE:
return physicsTestCapsuleVsCapsule(
aPos, cr, chh,
bPos,
bShape->data.capsule.radius,
bShape->data.capsule.halfHeight,
outNormal, outDepth
);
default: return false;
}
}
default: return false;
}
}
bool_t physicsTestShapeVsShape(
const vec3 aPos, const physicsshape_t *aShape,
const vec3 bPos, const physicsshape_t *bShape,
vec3 outNormal, float_t *outDepth
) {
return physicsTestDispatch(
aPos, aShape, bPos, bShape, outNormal, outDepth
);
}
+265
View File
@@ -0,0 +1,265 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "physicsshape.h"
/**
* Tests overlap between two axis-aligned bounding boxes.
* outNormal points from B toward A.
*
* @param ac Center of AABB A.
* @param ah Half-extents of AABB A.
* @param bc Center of AABB B.
* @param bh Half-extents of AABB B.
* @param outNormal Push-out normal (B toward A).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestAabbVsAabb(
const vec3 ac, const vec3 ah,
const vec3 bc, const vec3 bh,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between two spheres.
* outNormal points from B toward A.
*
* @param ac Center of sphere A.
* @param ar Radius of sphere A.
* @param bc Center of sphere B.
* @param br Radius of sphere B.
* @param outNormal Push-out normal (B toward A).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestSphereVsSphere(
const vec3 ac, const float_t ar,
const vec3 bc, const float_t br,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between a sphere and an axis-aligned bounding box.
* outNormal points from the AABB toward the sphere.
*
* @param sc Center of the sphere.
* @param sr Radius of the sphere.
* @param ac Center of the AABB.
* @param ah Half-extents of the AABB.
* @param outNormal Push-out normal (AABB toward sphere).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestSphereVsAabb(
const vec3 sc, const float_t sr,
const vec3 ac, const vec3 ah,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between a sphere and an infinite plane.
* outNormal equals the plane normal (pointing away from the surface).
*
* @param sc Center of the sphere.
* @param sr Radius of the sphere.
* @param pn Plane normal (unit vector, world-space).
* @param pd Plane offset: dot(pn, surfacePoint) == pd.
* @param outNormal Push-out normal (equals pn).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestSphereVsPlane(
const vec3 sc, const float_t sr,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between an AABB and an infinite plane.
* outNormal equals the plane normal.
*
* @param ac Center of the AABB.
* @param ah Half-extents of the AABB.
* @param pn Plane normal (unit vector, world-space).
* @param pd Plane offset (see physicsTestSphereVsPlane).
* @param outNormal Push-out normal (equals pn).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestAabbVsPlane(
const vec3 ac, const vec3 ah,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
);
/**
* Finds the closest point on segment [a, b] to query point p.
*
* @param a Start of the segment.
* @param b End of the segment.
* @param p Query point.
* @param out Receives the closest point on [a, b] to p.
*/
void physicsTestClosestPointOnSegment(
const vec3 a, const vec3 b, const vec3 p, vec3 out
);
/**
* Finds the closest point on triangle (a, b, c) to query point p. Handles
* all six Voronoi regions (the three vertices, the three edges, and the
* face interior).
*
* @param p Query point.
* @param a First triangle vertex.
* @param b Second triangle vertex.
* @param c Third triangle vertex.
* @param out Receives the closest point on the triangle to p.
*/
void physicsTestClosestPointOnTriangle(
const vec3 p, const vec3 a, const vec3 b, const vec3 c, vec3 out
);
/**
* Finds the closest points between two line segments.
*
* @param a1 Start of segment 1.
* @param b1 End of segment 1.
* @param a2 Start of segment 2.
* @param b2 End of segment 2.
* @param outP1 Receives the closest point on segment 1.
* @param outP2 Receives the closest point on segment 2.
*/
void physicsTestClosestPointsBetweenSegments(
const vec3 a1, const vec3 b1,
const vec3 a2, const vec3 b2,
vec3 outP1, vec3 outP2
);
/**
* Tests overlap between a Y-axis-aligned capsule and a sphere.
* outNormal points from the sphere toward the capsule.
*
* @param cc Center of the capsule.
* @param cr Radius of the capsule.
* @param chh Half-height of the capsule's cylindrical segment.
* @param sc Center of the sphere.
* @param sr Radius of the sphere.
* @param outNormal Push-out normal (sphere toward capsule).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestCapsuleVsSphere(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 sc, const float_t sr,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between a Y-axis-aligned capsule and an AABB.
* outNormal points from the AABB toward the capsule.
*
* @param cc Center of the capsule.
* @param cr Radius of the capsule.
* @param chh Half-height of the capsule's cylindrical segment.
* @param ac Center of the AABB.
* @param ah Half-extents of the AABB.
* @param outNormal Push-out normal (AABB toward capsule).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestCapsuleVsAabb(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 ac, const vec3 ah,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between a Y-axis-aligned capsule and an infinite plane.
* outNormal equals the plane normal.
*
* @param cc Center of the capsule.
* @param cr Radius of the capsule.
* @param chh Half-height of the capsule's cylindrical segment.
* @param pn Plane normal (unit vector, world-space).
* @param pd Plane offset (see physicsTestSphereVsPlane).
* @param outNormal Push-out normal (equals pn).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestCapsuleVsPlane(
const vec3 cc, const float_t cr, const float_t chh,
const vec3 pn, const float_t pd,
vec3 outNormal, float_t *outDepth
);
/**
* Tests overlap between two Y-axis-aligned capsules.
* outNormal points from capsule B toward capsule A.
*
* @param c1 Center of capsule A.
* @param r1 Radius of capsule A.
* @param hh1 Half-height of capsule A's cylindrical segment.
* @param c2 Center of capsule B.
* @param r2 Radius of capsule B.
* @param hh2 Half-height of capsule B's cylindrical segment.
* @param outNormal Push-out normal (B toward A).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestCapsuleVsCapsule(
const vec3 c1, const float_t r1, const float_t hh1,
const vec3 c2, const float_t r2, const float_t hh2,
vec3 outNormal, float_t *outDepth
);
/**
* Routes a shape-pair collision test to the correct primitive.
* When A is a plane, delegates with swapped arguments and negates
* the resulting normal. outNormal points from B toward A.
* If either shape is PHYSICS_SHAPE_CUSTOM, delegates to its
* physicsshapecustom_t.test callback instead (swapping/negating the same
* way if it's B that's custom). Asserts if both shapes are custom.
*
* @param aPos Position of shape A.
* @param aShape Shape descriptor of A.
* @param bPos Position of shape B.
* @param bShape Shape descriptor of B.
* @param outNormal Push-out normal (B toward A).
* @param outDepth Penetration depth.
* @return true if overlapping.
*/
bool_t physicsTestDispatch(
const vec3 aPos, const physicsshape_t *aShape,
const vec3 bPos, const physicsshape_t *bShape,
vec3 outNormal, float_t *outDepth
);
/**
* Tests for collision between two shapes. Returns true if they
* overlap, and if so, outputs the push-out normal and depth.
*
* outNormal always points from shape B toward shape A, so adding
* (outNormal * outDepth) to A's position separates the two shapes.
*
* @param aPos Position of shape A.
* @param aShape Shape descriptor of A.
* @param bPos Position of shape B.
* @param bShape Shape descriptor of B.
* @param outNormal Push-out normal, pointing from B toward A.
* @param outDepth Penetration depth (positive when overlapping).
* @return true if the shapes overlap, false otherwise.
*/
bool_t physicsTestShapeVsShape(
const vec3 aPos,
const physicsshape_t *aShape,
const vec3 bPos,
const physicsshape_t *bShape,
vec3 outNormal,
float_t *outDepth
);
+177
View File
@@ -0,0 +1,177 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "physicsworld.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "entity/entitymanager.h"
#include "entity/component/display/entityposition.h"
#include "entity/component/physics/entityphysics.h"
#include "physicstest.h"
void physicsWorldInit(physicsworld_t *world) {
assertNotNull(world, "World cannot be null");
memoryZero(world, sizeof(physicsworld_t));
world->gravity[0] = 0.0f;
world->gravity[1] = -9.81f;
world->gravity[2] = 0.0f;
}
void physicsWorldStep(
const physicsworld_t *world,
entitymanager_t *mgr,
const float_t dt
) {
assertNotNull(world, "World cannot be null");
assertNotNull(mgr, "Entity manager cannot be null");
assertTrue(dt > 0.0f, "Delta time must be positive");
entityid_t physEnts[ENTITY_COUNT_MAX];
componentid_t physComps[ENTITY_COUNT_MAX];
entityid_t physCount = componentGetEntitiesWithComponent(
mgr, COMPONENT_TYPE_PHYSICS, physEnts, physComps
);
if(physCount == 0) return;
// Pre-fetch all position and physics pointers once. Ensure each dynamic
// body's PRS cache is up to date before this step reads/writes
// ->position directly, in case it was last touched via a raw transform
// write (e.g. entityPositionLookAt) rather than a PRS setter.
entityposition_t *positions[ENTITY_COUNT_MAX];
entityphysics_t *physBodies[ENTITY_COUNT_MAX];
for(entityid_t i = 0; i < physCount; i++) {
componentid_t posComp = entityGetComponent(
mgr, physEnts[i], COMPONENT_TYPE_POSITION
);
positions[i] = (posComp != COMPONENT_ID_INVALID)
? entityPositionGet(mgr, physEnts[i], posComp)
: NULL;
if(positions[i]) entityPositionEnsurePRS(positions[i]);
physBodies[i] = entityPhysicsGet(mgr, physEnts[i], physComps[i]);
}
// Partition once into dynamic vs non-dynamic (static/kinematic) indices,
// so the phases below only ever iterate the subset they actually care
// about instead of re-scanning (and skipping past) every physics entity
// each time.
entityid_t dynamicIndices[ENTITY_COUNT_MAX];
entityid_t otherIndices[ENTITY_COUNT_MAX];
entityid_t dynamicCount = 0;
entityid_t otherCount = 0;
for(entityid_t i = 0; i < physCount; i++) {
if(physBodies[i]->type == PHYSICS_BODY_DYNAMIC) {
dynamicIndices[dynamicCount++] = i;
} else {
otherIndices[otherCount++] = i;
}
}
// Phase 1: integrate dynamic bodies (gravity + velocity -> position).
// Writes directly to pos->position, matrix rebuilt at the end.
for(entityid_t di = 0; di < dynamicCount; di++) {
entityid_t i = dynamicIndices[di];
if(!positions[i]) continue;
entityphysics_t *phys = physBodies[i];
phys->onGround = false;
phys->velocity[0] += world->gravity[0] * phys->gravityScale * dt;
phys->velocity[1] += world->gravity[1] * phys->gravityScale * dt;
phys->velocity[2] += world->gravity[2] * phys->gravityScale * dt;
float_t *pos = positions[i]->position;
pos[0] += phys->velocity[0] * dt;
pos[1] += phys->velocity[1] * dt;
pos[2] += phys->velocity[2] * dt;
}
// Phase 2: dynamic vs static/kinematic.
for(entityid_t di = 0; di < dynamicCount; di++) {
entityid_t i = dynamicIndices[di];
if(!positions[i]) continue;
entityphysics_t *phys = physBodies[i];
float_t *pos = positions[i]->position;
for(entityid_t oj = 0; oj < otherCount; oj++) {
entityid_t j = otherIndices[oj];
if(!positions[j]) continue;
entityphysics_t *otherPhys = physBodies[j];
if(!(phys->collideMask & otherPhys->collideMask)) continue;
vec3 normal; float_t depth;
if(!physicsTestShapeVsShape(
pos, &phys->shape,
positions[j]->position, &otherPhys->shape,
normal, &depth
)) continue;
pos[0] += normal[0] * depth;
pos[1] += normal[1] * depth;
pos[2] += normal[2] * depth;
float_t vn = glm_vec3_dot(phys->velocity, normal);
if(vn < 0.0f) {
phys->velocity[0] -= vn * normal[0];
phys->velocity[1] -= vn * normal[1];
phys->velocity[2] -= vn * normal[2];
}
if(normal[1] > PHYSICS_GROUND_THRESHOLD) phys->onGround = true;
}
}
// Phase 3: dynamic vs dynamic.
for(entityid_t di = 0; di < dynamicCount; di++) {
entityid_t i = dynamicIndices[di];
if(!positions[i]) continue;
entityphysics_t *physA = physBodies[i];
float_t *posA = positions[i]->position;
for(entityid_t dj = di + 1; dj < dynamicCount; dj++) {
entityid_t j = dynamicIndices[dj];
if(!positions[j]) continue;
entityphysics_t *physB = physBodies[j];
float_t *posB = positions[j]->position;
if(!(physA->collideMask & physB->collideMask)) continue;
vec3 normal; float_t depth;
if(!physicsTestShapeVsShape(
posA, &physA->shape, posB, &physB->shape, normal, &depth
)) continue;
posA[0] += normal[0] * depth * 0.5f;
posA[1] += normal[1] * depth * 0.5f;
posA[2] += normal[2] * depth * 0.5f;
posB[0] -= normal[0] * depth * 0.5f;
posB[1] -= normal[1] * depth * 0.5f;
posB[2] -= normal[2] * depth * 0.5f;
float_t vRel = glm_vec3_dot(physA->velocity, normal)
- glm_vec3_dot(physB->velocity, normal);
if(vRel < 0.0f) {
physA->velocity[0] -= vRel * normal[0];
physA->velocity[1] -= vRel * normal[1];
physA->velocity[2] -= vRel * normal[2];
physB->velocity[0] += vRel * normal[0];
physB->velocity[1] += vRel * normal[1];
physB->velocity[2] += vRel * normal[2];
}
if( normal[1] > PHYSICS_GROUND_THRESHOLD) physA->onGround = true;
if(-normal[1] > PHYSICS_GROUND_THRESHOLD) physB->onGround = true;
}
}
// Rebuild transforms for all dynamic bodies once, after all phases.
for(entityid_t di = 0; di < dynamicCount; di++) {
entityid_t i = dynamicIndices[di];
if(!positions[i]) continue;
entityPositionRebuild(mgr, positions[i]);
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "physics/physicsshape.h"
#include "physics/physicsbodytype.h"
#include "entity/entitybase.h"
/**
* Surface normals with a Y component above this are considered "ground"
* (angle from vertical <= ~45 degrees) and set a resolved body's onGround
* flag.
*/
#define PHYSICS_GROUND_THRESHOLD 0.707f
typedef struct {
/** Downward acceleration applied to every dynamic body each step. */
vec3 gravity;
} physicsworld_t;
/**
* Initializes a physics world with Earth-like default gravity
* (0, -9.81, 0).
*
* @param world The physics world to initialize.
*/
void physicsWorldInit(physicsworld_t *world);
/**
* Steps every PHYSICS-component entity in mgr forward by dt: integrates
* gravity into dynamic bodies, resolves dynamic-vs-static/kinematic and
* dynamic-vs-dynamic overlap, then rebuilds each moved entity's transform.
* A pair is skipped entirely (no overlap test, no resolution) unless
* their collideMask bitmasks share at least one set bit (see
* entityPhysicsSetCollideMask()). A no-op if mgr has no entities with a
* PHYSICS component.
*
* @param world The physics world configuration (gravity) to step with.
* @param mgr The entity manager whose PHYSICS-component entities to step.
* @param dt Timestep, in seconds (use DUSK_TIME_STEP for the fixed step).
*/
void physicsWorldStep(
const physicsworld_t *world,
entitymanager_t *mgr,
const float_t dt
);
+153
View File
@@ -0,0 +1,153 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "triggersystem.h"
#include "assert/assert.h"
#include "entity/entitymanager.h"
#include "entity/component/display/entityposition.h"
#include "entity/component/physics/entityphysics.h"
#include "entity/component/trigger/entitytrigger.h"
#include "entity/component/trigger/entitytriggerevents.h"
#include "physicstest.h"
/**
* Position deltas smaller than this (squared distance) are not considered
* movement, avoiding onMove firing from floating point noise.
*/
#define TRIGGER_SYSTEM_MOVE_EPSILON_SQ 0.000001f
void triggerSystemStep(entitymanager_t *mgr) {
assertNotNull(mgr, "Entity manager cannot be null");
entityid_t trigEnts[ENTITY_COUNT_MAX];
componentid_t trigComps[ENTITY_COUNT_MAX];
entityid_t trigCount = componentGetEntitiesWithComponent(
mgr, COMPONENT_TYPE_TRIGGER, trigEnts, trigComps
);
if(trigCount == 0) return;
entityid_t physEnts[ENTITY_COUNT_MAX];
componentid_t physComps[ENTITY_COUNT_MAX];
entityid_t physCount = componentGetEntitiesWithComponent(
mgr, COMPONENT_TYPE_PHYSICS, physEnts, physComps
);
for(entityid_t ti = 0; ti < trigCount; ti++) {
entityid_t trigEntityId = trigEnts[ti];
componentid_t trigComponentId = trigComps[ti];
componentid_t trigPosComp = entityGetComponent(
mgr, trigEntityId, COMPONENT_TYPE_POSITION
);
if(trigPosComp == COMPONENT_ID_INVALID) continue;
entitytrigger_t *trig = entityTriggerGet(
mgr, trigEntityId, trigComponentId
);
entityposition_t *trigPos = entityPositionGet(
mgr, trigEntityId, trigPosComp
);
entityPositionEnsurePRS(trigPos);
entityid_t currentEntityIds[ENTITY_TRIGGER_OCCUPANTS_MAX];
componentid_t currentComponentIds[ENTITY_TRIGGER_OCCUPANTS_MAX];
vec3 currentPositions[ENTITY_TRIGGER_OCCUPANTS_MAX];
uint8_t currentCount = 0;
for(entityid_t oi = 0; oi < physCount; oi++) {
entityid_t otherEntityId = physEnts[oi];
if(otherEntityId == trigEntityId) continue;
entityphysics_t *otherPhys = entityPhysicsGet(
mgr, otherEntityId, physComps[oi]
);
if(!(trig->collideMask & otherPhys->collideMask)) continue;
componentid_t otherPosComp = entityGetComponent(
mgr, otherEntityId, COMPONENT_TYPE_POSITION
);
if(otherPosComp == COMPONENT_ID_INVALID) continue;
entityposition_t *otherPos = entityPositionGet(
mgr, otherEntityId, otherPosComp
);
entityPositionEnsurePRS(otherPos);
vec3 normal; float_t depth;
if(!physicsTestShapeVsShape(
trigPos->position, &trig->shape,
otherPos->position, &otherPhys->shape,
normal, &depth
)) continue;
assertTrue(
currentCount < ENTITY_TRIGGER_OCCUPANTS_MAX,
"Trigger occupant capacity exceeded"
);
currentEntityIds[currentCount] = otherEntityId;
currentComponentIds[currentCount] = otherPosComp;
glm_vec3_copy(otherPos->position, currentPositions[currentCount]);
currentCount++;
}
for(uint8_t ci = 0; ci < currentCount; ci++) {
bool_t found = false;
uint8_t foundIndex = 0;
for(uint8_t pi = 0; pi < trig->occupantCount; pi++) {
if(trig->occupants[pi].entityId != currentEntityIds[ci]) continue;
found = true;
foundIndex = pi;
break;
}
if(!found) {
entityTriggerFireEnter(
mgr, trigEntityId, trigComponentId,
currentEntityIds[ci], currentComponentIds[ci]
);
continue;
}
entityTriggerFireActive(
mgr, trigEntityId, trigComponentId,
currentEntityIds[ci], currentComponentIds[ci]
);
if(
glm_vec3_distance2(
trig->occupants[foundIndex].lastPosition, currentPositions[ci]
) > TRIGGER_SYSTEM_MOVE_EPSILON_SQ
) {
entityTriggerFireMove(
mgr, trigEntityId, trigComponentId,
currentEntityIds[ci], currentComponentIds[ci]
);
}
}
for(uint8_t pi = 0; pi < trig->occupantCount; pi++) {
bool_t stillIn = false;
for(uint8_t ci = 0; ci < currentCount; ci++) {
if(currentEntityIds[ci] != trig->occupants[pi].entityId) continue;
stillIn = true;
break;
}
if(stillIn) continue;
entityTriggerFireLeave(
mgr, trigEntityId, trigComponentId,
trig->occupants[pi].entityId, trig->occupants[pi].componentId
);
}
trig->occupantCount = currentCount;
for(uint8_t ci = 0; ci < currentCount; ci++) {
trig->occupants[ci].entityId = currentEntityIds[ci];
trig->occupants[ci].componentId = currentComponentIds[ci];
glm_vec3_copy(currentPositions[ci], trig->occupants[ci].lastPosition);
}
}
}

Some files were not shown because too many files have changed in this diff Show More