77 Commits

Author SHA1 Message Date
YourWishes 06bc4fcd55 .MD remove 2026-08-03 14:35:30 -05:00
YourWishes 42cb84b610 Extract player entity setup into a Player class
Moves the capsule/physics/renderable/PLAYER setup out of
overworldscene.js's inline init() into assets/scripts/Player.js so the
scene module just instantiates it.
2026-08-02 21:06:31 -05:00
YourWishes f8607d114c Add UDP socket client/server multiplayer protocol
Implements the multiplayer transport layer from ROADMAP.md: UUID-based
client identity, a reliable-packet channel over UDP, handshake/ack/
ping/disconnect/player-joined/player-left/player-state packet types,
and split online (networked) vs offline (in-process singleplayer)
server modes wired into the engine's update/dispose loop.
2026-08-02 21:06:23 -05:00
YourWishes 56230dd340 Fix ref-count underflow, PSP timezone units, thread restart race, color codegen
- util/ref.c: refUnlock's assert(count >= 0) on an unsigned count was
  tautological, so a double-unlock silently underflowed to UINT32_MAX
  instead of asserting. Now asserts count > 0 before decrementing.
- duskpsp/time/timepsp.c: timeGetRealTimeZonePSP returned hours while
  every other platform (and timeepoch.c's math) expects seconds.
- thread.c: threadHandler reset threadId outside the mutex, after
  signaling STOPPED, letting a caller's immediate threadStart() race
  threadStartRequest()'s "thread id not 0" assert. threadId is now
  reset inside the same locked section.
- tools/color.py: whole-number CSV channels (0, 1) produced invalid C
  float literals (0f, 1f) for the generated COLOR_*_3F/_4F macros.
  Route through float() so they always stringify with a decimal point.

Also adds .claude/code-check.md: a full review pass over every
subsystem in src/dusk/, with the above (plus several other findings
not yet acted on) written up in detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:02:17 -05:00
YourWishes df9fdf26c8 Refresh STATUS.md/ROADMAP.md for the Scene.set()/UI caching/CI work
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:53:06 -05:00
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
YourWishes 8fb1c9fb42 physics and rendering fixes 2026-07-17 21:06:21 -05:00
YourWishes 5f08337726 physics stuff 2026-07-17 19:07:32 -05:00
YourWishes 0bc80d5df3 Roadmap and physics 2026-07-17 13:09:58 -05:00
YourWishes 2432443ea6 Cleaned settings a bit 2026-07-17 09:11:19 -05:00
YourWishes fa138971e7 Console optimized slightly for rendering 2026-07-16 23:52:47 -05:00
YourWishes d7982599d1 Simplified PSP network 2026-07-16 22:55:47 -05:00
YourWishes f16c80aa0f Fixed PSP rendering 2026-07-16 21:20:49 -05:00
YourWishes ef5adf8274 PSP Network connect 2026-07-16 20:09:39 -05:00
YourWishes 2ffc65b1b1 Network conn and disconn 2026-07-16 19:32:44 -05:00
YourWishes d4a17bd98d Net 2026-07-16 19:08:32 -05:00
YourWishes dcf5f434c5 Remove vita for now 2026-07-16 15:50:19 -05:00
YourWishes 78cd973a33 Remove story and battle stuff 2026-07-16 15:37:55 -05:00
YourWishes ca02ee0352 Add camera shake 2026-07-11 11:02:09 -05:00
YourWishes fbaa54145e Fixed dolphin rendering. 2026-07-10 20:15:26 -05:00
YourWishes 28754ffbf2 Removed old scripted types 2026-07-10 13:24:21 -05:00
YourWishes 470c0eba7a Whatever, some minor map chunking improvements 2026-07-10 12:57:31 -05:00
YourWishes 7098dcec43 Cleaned some log 2026-07-09 23:25:43 -05:00
YourWishes 07137f57af Assets are slightly optimized 2026-07-09 23:25:26 -05:00
YourWishes 8b7491a3d3 Emoji support to characters 2026-07-09 13:18:48 -05:00
YourWishes 8cfa8ddfeb Weaather baseline 2026-07-08 12:57:13 -05:00
YourWishes ef284a15a1 pre-cache shader matrices 2026-07-08 12:36:40 -05:00
YourWishes 3723921573 Render culling on sceneoverworld.h 2026-07-08 12:02:41 -05:00
YourWishes 195399635e Updating mini textbox 2026-07-08 11:45:10 -05:00
YourWishes 46e2a924d3 Mini textboxes 2026-07-08 10:53:53 -05:00
YourWishes b693ea4102 Starting item and battle stuff 2026-07-08 10:05:21 -05:00
YourWishes a73f55beb0 Battle stuff
Build Dusk / build-linux (push) Successful in 7m5s
Build Dusk / build-psp (push) Successful in 1m33s
Build Dusk / build-knulli (push) Successful in 4m0s
Build Dusk / build-gamecube (push) Successful in 3m31s
Build Dusk / build-gamecube-iso (push) Successful in 3m39s
Build Dusk / build-wii (push) Successful in 3m5s
Build Dusk / build-wii-iso (push) Successful in 3m25s
2026-07-08 07:55:15 -05:00
YourWishes 0bd2491ab7 Dropdown test. 2026-07-07 15:18:33 -05:00
YourWishes 860c797c9c Last round of cleanup for settings for now. 2026-07-07 14:56:08 -05:00
YourWishes 38c5080f9f Collapsing more ui settings code 2026-07-07 14:36:19 -05:00
YourWishes fae191d8fe Settings improvements 2026-07-07 14:00:37 -05:00
YourWishes d83a953e2d First pass of language 2026-07-07 11:14:02 -05:00
YourWishes 2dcf0d0f0d UI Confirm 2026-07-07 10:12:22 -05:00
YourWishes 6dee37d8c1 Slider widget and some cleanup 2026-07-07 09:58:10 -05:00
YourWishes 3bad03afb3 Item entity 2026-07-07 07:52:22 -05:00
YourWishes 6a6d8448f7 Area cutscene controls 2026-07-07 07:28:12 -05:00
YourWishes 189babd2cf Trigger types on areas 2026-07-07 07:07:32 -05:00
YourWishes 988a0f2294 Area basics 2026-07-06 23:19:05 -05:00
YourWishes 85bf455731 entity pos sets chunk now 2026-07-04 18:43:09 -05:00
YourWishes 589e4224f3 Example global ent 2026-07-04 13:03:24 -05:00
YourWishes bd7fa154b4 Add user data to callbacks 2026-07-04 09:13:37 -05:00
YourWishes a292f1992b Change to use the Z tile system 2026-07-04 08:40:53 -05:00
YourWishes 88ddf429b7 add some entity management in prep for global entities. 2026-07-03 12:25:24 -05:00
YourWishes 7a1f6662df More cutscene tools 2026-07-03 09:17:49 -05:00
YourWishes afc68bccc6 Update test runner
Build Dusk / build-linux (push) Successful in 5m36s
Build Dusk / build-psp (push) Successful in 1m15s
Build Dusk / build-knulli (push) Successful in 4m53s
Build Dusk / build-gamecube (push) Successful in 3m51s
Build Dusk / build-gamecube-iso (push) Successful in 3m52s
Build Dusk / build-wii (push) Successful in 3m17s
Build Dusk / build-wii-iso (push) Successful in 4m54s
2026-07-02 19:20:17 -05:00
621 changed files with 29045 additions and 8762 deletions
-30
View File
@@ -4,36 +4,6 @@ on:
tags: tags:
- '*' - '*'
jobs: jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+106
View File
@@ -0,0 +1,106 @@
name: Test Dusk
on:
pull_request:
branches:
- main
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
python3 \
python3-pip \
python3-polib \
python3-pil \
libsdl2-dev \
libgl1-mesa-dev \
libzip-dev \
python3-dotenv \
python3-pyqt5 \
python3-opengl \
xz-utils \
liblzma-dev \
libbz2-dev \
zlib1g-dev \
git \
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
-455
View File
@@ -1,455 +0,0 @@
# Dusk — Claude Code rules
## File headers
Every C, H, and JS file starts with:
```c
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
```
JS files use `//` comment style instead.
---
## C conventions
### Types
Always use the project-defined aliases instead of bare C primitives:
| Use | Not |
|-----------|--------------|
| `bool_t` | `bool` |
| `int_t` | `int` |
| `float_t` | `float` |
| `char_t` | `char` |
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
### Naming
- **Functions** — snake_case, prefixed with their module:
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
- **Macros / constants** — UPPER_SNAKE_CASE:
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
- **Files** — snake_case matching the primary type: `entityposition.c`,
`moduleassetbatch.c`
### Header files (`.h`)
- Use `#pragma once` — no include guards.
- Declare every public function, `#define`, and `extern` global.
- Write a JSDoc block (`/** … */`) above every declaration explaining
purpose, `@param`s, and `@returns`.
- Only include headers that the `.h` file itself strictly requires for
the types it exposes. Move everything else to the `.c` file.
Do not use forward declarations as a workaround — use the real
include in the `.c` file instead.
### Implementation files (`.c`)
- Contain function bodies only; no declarations.
- Pull in whatever additional includes the implementation needs.
- Do not use `static` or `inline` on **functions**. Every function,
including internal helpers, must be declared in the matching `.h` and
defined in the `.c` file. Internal helpers belong near the bottom of
the `.c` file, not at the top with a `static` qualifier.
`static` and `inline` on functions are only appropriate when the
function body is written directly inside a `.h` file.
`static` on **variables** (file-scope state) is fine and expected.
### Formatting
- Hard-wrap all lines at **80 characters**.
### Error handling
Return `errorret_t` from fallible functions. Use these macros:
```c
errorOk(); // return success
errorThrow("msg %d", val); // return failure with message
errorChain(someCall()); // propagate failure, continue on success
errorIsOk(ret) / errorIsNotOk(ret) // test a result
errorCatch(ret); // handle + free an error
```
Never return raw error codes or use `errno` for in-engine errors.
### Memory
Use the project allocator — never raw `malloc`/`free`:
```c
memoryAllocate(size) // allocate
memoryFree(ptr) // free
memoryZero(dest, size) // zero a block
memoryCopy(dest, src, size) // copy
```
### Asserts
Prefer specific assert macros over bare `assert()`:
```c
assertNotNull(ptr, "msg");
assertTrue(cond, "msg");
assertFalse(cond, "msg");
assertUnreachable("msg");
assertIsMainThread("msg");
```
---
## Build system
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
```cmake
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
myfile.c
)
```
Never add source files to the root `CMakeLists.txt` directly.
---
## Platform support
### Targets
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|----------------------|-------------------|------------------|
| `linux` | `DUSK_LINUX` | Linux desktop |
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
| `psp` | `DUSK_PSP` | Sony PSP |
| `vita` | `DUSK_VITA` | PlayStation Vita |
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
| `wii` | `DUSK_WII` | Nintendo Wii |
### Layer structure
```
src/dusk/ core, platform-agnostic game logic
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
src/dusklinux/ Linux + Knulli platform impl
src/duskpsp/ PSP platform impl
src/duskvita/ Vita platform impl
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
```
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
it uses native GameCube/Wii rendering and input APIs.
### Platform guards
Use the compile-time macros for platform-specific code:
```c
#ifdef DUSK_PSP
// PSP-only path
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
// GameCube / Wii path
#else
// Generic / Linux fallback
#endif
```
Additional capability macros set per-target:
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
### Abstraction pattern
Platform-specific implementations are wired in via `#define` macros in
each platform's `displayplatform.h` / `inputplatform.h` etc., which
the core calls through. Functions that a platform does not support are
simply left undefined — the core guards calls with `#ifdef`.
### Adding platform-specific code
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
or capability macro.
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
the platform header macros instead.
---
## Adding a new asset loader type
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
`src/dusk/asset/loader/assetloader.h`.
2. Add fields to the input/loading/output unions in `assetloader.h`.
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
`src/dusk/asset/loader/assetloader.c`.
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
---
## 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`:
```c
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
```
This auto-generates the enum, union field, and definition entry.
4. If JS-facing, create the script module and `.d.ts` (see below).
---
## Adding a new script (JS) module
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.
- Register props/funcs in `moduleMyModInit()` with
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
`scriptProtoDefineStaticFunc`.
2. `#include` the header in
`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.
4. Create `types/<category>/mymod.d.ts` and add a
`/// <reference path="..." />` line to `types/index.d.ts`.
---
## Script module type declarations
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
check whether the corresponding `types/**/*.d.ts` needs updating and
apply any changes before finishing the task.
---
## JavaScript (asset scripts)
- Use `var` for module-level state; `const` for values that never
change.
- Always use semicolons.
- Scene objects are plain objects (`var scene = {}`) with assigned
methods.
- Export via `module.exports = scene`.
- Async scene init should use `async function` and `await`.
---
## Coding style
### ASCII only
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000U+007F).
Non-ASCII characters are banned even in comments and string literals.
Use ASCII-only substitutes instead:
- `--` or `-` instead of `` (em dash)
- `->` instead of `` (arrow)
- `x` or `*` instead of `×` (multiplication)
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
### Indentation
2 spaces. No tabs.
### Keyword and operator spacing
No space between a keyword or function name and its opening parenthesis:
```c
if(!ptr) return;
for(uint8_t i = 0; i < count; i++) {
while(entry->state != DONE) {
switch(type) {
sizeof(assetbatch_t)
memoryZero(ptr, size)
```
Spaces around all binary operators and after every comma:
```c
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
(size_t)end - (size_t)start
foo(a, b, c)
```
### Braces
Opening brace on the **same line** as the statement (K&R style) for all
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
```c
void assetEntryLock(assetentry_t *entry) {
...
}
if(dirty) {
...
} else {
...
}
```
### Guard returns
Short guards go on one line with no braces:
```c
if(!ptr) return;
if(!b || !b->batch) return jerry_undefined();
if(!(flags & DIRTY)) return;
```
### Blank lines
- One blank line between functions; no blank line at the start or end of
a function body.
- One blank line between logical blocks inside a function body.
- No trailing blank lines at the end of a file.
### Pointer placement
`*` is attached to the variable name, not the type:
```c
assetentry_t *entry
const char_t *name
void *ptr
uint8_t *d = (uint8_t *)dest;
```
### Casts
Space between cast and operand:
```c
(assetbatch_t *)user
(uint8_t *)dest
(textureformat_t)v
```
### Return
No parentheses around the return value:
```c
return ptr;
return MEMORY_POINTERS_IN_USE;
```
### switch / case
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
```c
switch(type) {
case ASSET_LOADER_TYPE_TEXTURE:
descs[i].input.texture = (textureformat_t)v;
break;
default:
break;
}
```
### Multi-line function signatures
When parameters don't fit on one line, put each on its own line indented
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
its own line at column 0:
```c
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
errorret_t memoryCompare(
const void *a,
const void *b,
const size_t size
);
```
### Structs and enums
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
brace and name on the same line:
```c
typedef struct {
errorcode_t code;
char_t *message;
} errorstate_t;
typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
```
### Designated initialisers
Spaces inside braces; `.field = value`:
```c
jsassetentry_t e = { .entry = entry };
assetbatchloadedpend_t init = { .batch = batch };
```
### Ternary operator
Spaces around `?` and `:`:
```c
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
```
### const placement
`const` before the type, `*` attached to the variable:
```c
const char_t *name
const void *src
const size_t size
```
### Comments in `.c` files
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
functions follow one another with a single blank line between them.
- Multi-line explanatory comments inside function bodies use `//` lines:
```c
// Script modules are freed; orphaned JS wrapper objects now get GC'd
// so their finalizers fire before assetDispose() checks ref counts.
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
```
- Do not use `/* */` for inline or inline-block comments inside `.c`
function bodies.
### Comments in `.h` files
Every public declaration gets a Javadoc block (`/** … */`) with
`@param` and `@returns` where relevant. Keep it on the lines immediately
above the declaration with no blank line in between.
---
## Color system
Colors are defined in `src/dusk/display/color.csv` and code-generated
into a `color.h` header by `tools/color/csv/__main__.py`.
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
The script emits four `#define` variants per color plus a bare alias:
```
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
COLOR_<NAME>_3B color3b(r8, g8, b8)
COLOR_<NAME>_3F color3f(rf, gf, bf)
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
COLOR_<NAME> COLOR_<NAME>_4B
```
`color_t` is `color4b_t` (four `uint8_t` channels).
To add a new color, append a row to `color.csv` and rebuild — do not
hand-edit the generated header.
---
## Tests
- Tests live in `test/` mirroring `src/dusk/` structure.
- Use cmocka; include `dusktest.h`.
- Test functions: `static void test_something(void **state)`.
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
leaks.
- Build with `-DDUSK_BUILD_TESTS=ON`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49 -43
View File
@@ -10,51 +10,57 @@ msgid "ui.title"
msgstr "" msgstr ""
"Welcome" "Welcome"
#: ui/user.c:22 #: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.greeting" msgid "ui.settings.tabs.general"
msgstr "Hello, %s!" msgstr "General"
#: ui/files.c:40 #: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.file_status" msgid "ui.settings.tabs.input"
msgstr "%s has %d files." msgstr "Input"
#: ui/cart.c:55 #: src/dusk/ui/frame/settings/uisettings.c
msgid "cart.item_count" msgid "ui.settings.tabs.display"
msgid_plural "cart.item_count" msgstr "Display"
msgstr[0] "%d item"
msgstr[1] "%d items (dual)"
msgstr[2] "%d items (few)"
msgstr[3] "%d items (many)"
#: ui/notifications.c:71 #: src/dusk/ui/frame/settings/uisettings.c
msgid "" msgid "ui.settings.tabs.audio"
"ui.multiline_help" msgstr "Audio"
msgstr ""
"Line one of the help text.\n"
"Line two continues here.\n"
"Line three ends here."
#: ui/errors.c:90 msgid "ui.settings.input.deadzone"
msgid "" msgstr "Deadzone"
"error.upload_failed.long"
msgstr ""
"Upload failed for file \"%s\".\n"
"Please try again later or contact support."
#: ui/messages.c:110 #: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "" msgid "ui.settings.general.language"
"user.invite_status" msgstr "Language"
msgid_plural ""
"user.invite_status" msgid "ui.settings.general.language_detail"
msgstr[0] "" msgstr "Takes effect after restarting the application."
"%s invited %d user.\n"
"Please review the request." #: src/dusk/ui/frame/settings/uisettings.c
msgstr[1] "" msgid "ui.settings.apply"
"%s invited %d users (dual).\n" msgstr "Apply"
"Please review the requests."
msgstr[2] "" #: src/dusk/ui/frame/uiconfirm.c
"%s invited %d users (few).\n" msgid "ui.confirm.discard_changes"
"Please review the requests." msgstr "Discard unsaved changes?"
msgstr[3] ""
"%s invited %d users (many).\n" #: src/dusk/ui/frame/game/uigamemenu.c
"Please review the requests." msgid "ui.game_menu.characters"
msgstr "Characters"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Items"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Settings"
msgid "item.potion.name"
msgstr "Potion"
msgid "item.potato.name"
msgstr "Potato"
msgid "item.apple.name"
msgstr "Apple"
+70
View File
@@ -0,0 +1,70 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n==1 ? 0 : 1);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"Bienvenido"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "General"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "Entrada"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "Pantalla"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "Audio"
msgid "ui.settings.input.deadzone"
msgstr "Deadzone"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "Idioma"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "Se aplica después de reiniciar la aplicación."
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "Aplicar"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "¿Descartar los cambios no guardados?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "Personajes"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "Objetos"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "Configuración"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "Poción"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "Papa"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "Manzana"
+70
View File
@@ -0,0 +1,70 @@
msgid ""
msgstr ""
"Project-Id-Version: ExampleApp 1.0\n"
"Language: ja\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=(0);\n"
#: ui/menu.c:10
msgid "ui.title"
msgstr ""
"歓迎"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.general"
msgstr "一般"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.input"
msgstr "入力"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.display"
msgstr "表示"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.tabs.audio"
msgstr "オーディオ"
msgid "ui.settings.input.deadzone"
msgstr "デッドゾーン"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language"
msgstr "言語"
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
msgid "ui.settings.general.language_detail"
msgstr "アプリケーションを再起動すると適用されます。"
#: src/dusk/ui/frame/settings/uisettings.c
msgid "ui.settings.apply"
msgstr "適用"
#: src/dusk/ui/frame/uiconfirm.c
msgid "ui.confirm.discard_changes"
msgstr "未保存の変更を破棄しますか?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.characters"
msgstr "キャラクター"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.items"
msgstr "アイテム"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.settings"
msgstr "設定"
#: src/dusk/rpg/item/item.json
msgid "item.potion.name"
msgstr "ポーション"
#: src/dusk/rpg/item/item.json
msgid "item.potato.name"
msgstr "ジャガイモ"
#: src/dusk/rpg/item/item.json
msgid "item.apple.name"
msgstr "リンゴ"
Binary file not shown.
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) 2026 Dominic Masters
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
// Player: dynamic capsule body, moved relative to the camera by PLAYER's
// own update callback (see entityplayer.c).
class Player {
constructor() {
this.entity = new Entity();
this.position = this.entity.add(POSITION);
this.position.setLocalPosition(0.0, 2.0, 0.0);
this.physics = this.entity.add(PHYSICS);
this.physics.setShape(PHYSICS_SHAPE_CAPSULE, 0.5, 0.5);
this.renderable = this.entity.add(RENDERABLE);
this.renderable.setMesh(0, MESH_CAPSULE);
this.renderable.setColor(0, 0, 255, 255);
this.entity.add(PLAYER);
}
}
module.exports = Player;
+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);
+74
View File
@@ -0,0 +1,74 @@
// 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 Player = require('./Player.js');
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);
new 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})
+3
View File
@@ -16,6 +16,9 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DOL=1 DOL=1
ISO=2 ISO=2
DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE} DUSK_DOLPHIN_BUILD_TYPE=${DUSK_DOLPHIN_BUILD_TYPE}
# GameCube/Wii PowerPC is always big-endian; declare it at compile time
# like every other target instead of relying on endian.h's runtime probe.
DUSK_PLATFORM_ENDIAN_BIG
) )
# Custom compiler flags # Custom compiler flags
+1 -1
View File
@@ -6,7 +6,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
# Link libraries # Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
# bba bba
) )
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images # ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
+1 -1
View File
@@ -36,7 +36,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
# DUSK_OPENGL_LEGACY # DUSK_OPENGL_LEGACY
DUSK_LINUX DUSK_LINUX
DUSK_DISPLAY_SIZE_DYNAMIC DUSK_DISPLAY_SIZE_DYNAMIC
DUSK_DISPLAY_WIDTH_DEFAULT=640 DUSK_DISPLAY_WIDTH_DEFAULT=854
DUSK_DISPLAY_HEIGHT_DEFAULT=480 DUSK_DISPLAY_HEIGHT_DEFAULT=480
DUSK_DISPLAY_SCREEN_HEIGHT=240 DUSK_DISPLAY_SCREEN_HEIGHT=240
DUSK_INPUT_KEYBOARD DUSK_INPUT_KEYBOARD
+5 -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_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" 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) set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
@@ -55,15 +59,9 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
DUSK_DISPLAY_WIDTH=480 DUSK_DISPLAY_WIDTH=480
DUSK_DISPLAY_HEIGHT=272 DUSK_DISPLAY_HEIGHT=272
DUSK_THREAD_PTHREAD 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. # Postbuild, create .pbp file for PSP.
create_pbp_file( create_pbp_file(
TARGET "${DUSK_BINARY_TARGET_NAME}" TARGET "${DUSK_BINARY_TARGET_NAME}"
-88
View File
@@ -1,88 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
if(NOT DEFINED ENV{VITASDK})
message(FATAL_ERROR "VITASDK environment variable is not set.")
endif()
include("$ENV{VITASDK}/share/vita.cmake" REQUIRED)
set(VITA_APP_NAME "Dusk")
set(VITA_TITLEID "DUSK00001")
set(VITA_VERSION "01.00")
find_package(SDL2 REQUIRED)
# Custom flags for cglm
set(CGLM_SHARED OFF CACHE BOOL "Build cglm shared" FORCE)
set(CGLM_STATIC ON CACHE BOOL "Build cglm static" FORCE)
find_package(cglm REQUIRED)
# Link libraries
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
${SDL2_LIBRARIES}
cglm
SDL2
SDL2main
zip
bz2
z
zstd
crypto
lzma
m
pthread
stdc++
vitaGL
mathneon
vitashark
kubridge_stub
SceAppMgr_stub
SceAudio_stub
SceCtrl_stub
SceCommonDialog_stub
SceDisplay_stub
SceKernelDmacMgr_stub
SceGxm_stub
SceShaccCg_stub
SceSysmodule_stub
ScePower_stub
SceTouch_stub
SceVshBridge_stub
SceIofilemgr_stub
SceShaccCgExt
libtaihen_stub.a
# SceKernel_stub
SceAppUtil_stub
SceHid_stub
SceRtc_stub
)
target_include_directories(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
${SDL2_INCLUDE_DIRS}
)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SDL2
DUSK_OPENGL
DUSK_VITA
DUSK_INPUT_GAMEPAD
DUSK_PLATFORM_ENDIAN_LITTLE
DUSK_OPENGL_LEGACY
DUSK_DISPLAY_WIDTH=960
DUSK_DISPLAY_HEIGHT=544
)
# Post-build: create SELF from the ELF binary (UNSAFE = homebrew, no signing)
vita_create_self(${DUSK_BINARY_TARGET_NAME}.self ${DUSK_BINARY_TARGET_NAME} UNSAFE)
# Post-build: package SELF + assets into a .vpk installable on the Vita
vita_create_vpk(${DUSK_BINARY_TARGET_NAME}.vpk ${VITA_TITLEID} ${DUSK_BINARY_TARGET_NAME}.self
VERSION ${VITA_VERSION}
NAME ${VITA_APP_NAME}
FILE ${DUSK_ASSETS_ZIP} dusk.dsk
)
+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 -5
View File
@@ -4,6 +4,7 @@
# https://opensource.org/licenses/MIT # https://opensource.org/licenses/MIT
add_subdirectory(dusk) add_subdirectory(dusk)
add_subdirectory(duskrpg)
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli") if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
add_subdirectory(dusklinux) add_subdirectory(dusklinux)
@@ -15,11 +16,6 @@ elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
add_subdirectory(dusksdl2) add_subdirectory(dusksdl2)
add_subdirectory(duskgl) add_subdirectory(duskgl)
elseif(DUSK_TARGET_SYSTEM STREQUAL "vita")
add_subdirectory(duskvita)
add_subdirectory(dusksdl2)
add_subdirectory(duskgl)
elseif(DUSK_TARGET_SYSTEM STREQUAL "wii" OR DUSK_TARGET_SYSTEM STREQUAL "gamecube") elseif(DUSK_TARGET_SYSTEM STREQUAL "wii" OR DUSK_TARGET_SYSTEM STREQUAL "gamecube")
add_subdirectory(duskdolphin) add_subdirectory(duskdolphin)
+13 -1
View File
@@ -32,6 +32,15 @@ if(NOT yyjson_FOUND)
endif() endif()
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) if(DUSK_BACKTRACE)
target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic) target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic)
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
@@ -58,17 +67,20 @@ add_subdirectory(assert)
add_subdirectory(asset) add_subdirectory(asset)
add_subdirectory(console) add_subdirectory(console)
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(entity)
add_subdirectory(log) add_subdirectory(log)
add_subdirectory(engine) add_subdirectory(engine)
add_subdirectory(error) add_subdirectory(error)
add_subdirectory(game)
add_subdirectory(input) add_subdirectory(input)
add_subdirectory(locale) add_subdirectory(locale)
add_subdirectory(rpg)
add_subdirectory(scene) add_subdirectory(scene)
add_subdirectory(system) add_subdirectory(system)
add_subdirectory(time) add_subdirectory(time)
add_subdirectory(ui) add_subdirectory(ui)
add_subdirectory(network) add_subdirectory(network)
add_subdirectory(physics)
add_subdirectory(save) add_subdirectory(save)
add_subdirectory(script)
add_subdirectory(util) add_subdirectory(util)
add_subdirectory(thread) add_subdirectory(thread)
+2
View File
@@ -6,5 +6,7 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
easing.c easing.c
keyframe.c
keyframeset.c
animation.c animation.c
) )
+52 -30
View File
@@ -5,48 +5,70 @@
#include "animation.h" #include "animation.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h"
#include "util/math.h" #include "util/math.h"
#include "time/time.h"
void animationInit( void animationInit(
animation_t *anim, animation_t *anim,
keyframe_t *keyframes, keyframe_t **tracks,
uint16_t keyframeCount uint16_t *trackCounts,
uint16_t trackCount
) { ) {
assertNotNull(anim, "Animation pointer cannot be null."); 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; keyframeSetInit(
anim->keyframeCount = keyframeCount; &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, "Animation pointer cannot be null.");
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null."); if(!anim->playing) return;
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
assertTrue(time >= 0, "Time must be non-negative.");
keyframe_t *start; float_t duration = keyframeSetGetDuration(&anim->keyframes);
keyframe_t *end; float_t prevTime = anim->time;
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1; anim->time += TIME.delta * anim->speed;
keyframe_t *current = anim->keyframes;
start = current;
do { // Loops that overshoot duration wrap back into [0, duration) -- an event
if(current->time > time) { // near the end must still be checked across that wrap, as two segments:
end = current; // (prevTime, duration] then [0, newTime].
break; bool_t wrapped = anim->loop && duration > 0.0f && anim->time >= duration;
}
start = current;
current++;
if(current > last) { if(anim->loop) {
end = start; if(duration > 0.0f) anim->time = mathModFloat(anim->time, duration);
break; } else if(anim->time >= duration) {
} anim->time = duration;
} while(true); anim->playing = false;
}
float_t t = (time - start->time) / (end->time - start->time); if(!anim->eventCallback) return;
return mathLerp(start->value, end->value, easingApply(start->easing, t));
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);
} }
+81 -13
View File
@@ -4,31 +4,99 @@
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
#pragma once #pragma once
#include "keyframe.h" #include "keyframeset.h"
typedef struct { typedef struct animation_t animation_t;
keyframe_t *keyframes;
uint16_t keyframeCount; /**
* 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; } 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 anim The animation to initialize.
* @param keyframes The keyframes to use for the animation. * @param tracks Array of trackCount keyframe_t arrays.
* @param keyframeCount The number of keyframes in the animation. * @param trackCounts Array of trackCount keyframe counts, matching tracks.
* @param trackCount The number of tracks/channels in this animation.
*/ */
void animationInit( void animationInit(
animation_t *anim, animation_t *anim,
keyframe_t *keyframes, keyframe_t **tracks,
uint16_t keyframeCount uint16_t *trackCounts,
uint16_t trackCount
); );
/** /**
* Gets the value of the animation at a 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.
*/
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 anim The animation to get the value from.
* @param time The time at which to get the value, in seconds. * @param trackIndex The track to evaluate, in [0, trackCount).
* @return The value of the animation at the given time. * @return The interpolated value of that track at the current time.
*/ */
float_t animationGetValue(animation_t *anim, const float_t 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));
}
+14
View File
@@ -11,3 +11,17 @@ typedef struct {
float_t value; float_t value;
easingtype_t easing; 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 #pragma once
#include "dusk.h" #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 #ifdef DUSK_TEST_ASSERT
#include <cmocka.h> #include <cmocka.h>
+48 -57
View File
@@ -59,20 +59,28 @@ assetentry_t * assetGetEntry(
entry++; entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX); } while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
// We did not find one existing, Find first available slot. // We did not find one existing. Find first available slot, reaping
entry = ASSET.entries; // zero-ref entries to make room if none are immediately available.
do { bool_t reaped = false;
if(entry->type != ASSET_LOADER_TYPE_NULL) { for(;;) {
entry++; entry = ASSET.entries;
continue; do {
} if(entry->type != ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
if(entry->state == ASSET_ENTRY_STATE_NOT_STARTED) { if(entry->state == ASSET_ENTRY_STATE_NOT_STARTED) {
assetEntryInit(entry, name, type, input); assetEntryInit(entry, name, type, input);
return entry; return entry;
} }
entry++; entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX); } while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
if(reaped) break;
reaped = true;
errorCatch(assetReapUnused());
}
assertUnreachable("No available asset entry slots."); assertUnreachable("No available asset entry slots.");
return NULL; return NULL;
@@ -191,6 +199,32 @@ void assetUnlockEntry(assetentry_t *entry) {
assetEntryUnlock(entry); assetEntryUnlock(entry);
} }
errorret_t assetReapUnused(void) {
assertIsMainThread("assetReapUnused must be called from the main thread.");
// Repeatedly find and dispose zero-ref LOADED entries until none remain.
// This handles dependency chains where an entry (e.g. a model) holds refs
// on child entries (mesh, texture): dispose parents first so child ref
// counts drop to zero, then pick up the children on the next pass. Without
// this, a forward-only scan fails when a shared child entry appears before
// a parent that still holds a ref to it.
bool_t any;
do {
any = false;
assetentry_t *entry = ASSET.entries;
do {
if(entry->type == ASSET_LOADER_TYPE_NULL) { entry++; continue; }
if(entry->state != ASSET_ENTRY_STATE_LOADED) { entry++; continue; }
if(entry->refs.count > 0) { entry++; continue; }
errorChain(assetEntryDispose(entry));
any = true;
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
errorOk();
}
errorret_t assetUpdate(void) { errorret_t assetUpdate(void) {
assertIsMainThread("assetUpdate must be called from the main thread."); assertIsMainThread("assetUpdate must be called from the main thread.");
@@ -324,30 +358,6 @@ errorret_t assetUpdate(void) {
} }
} while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX); } while(loading < ASSET.loading + ASSET_LOADING_COUNT_MAX);
// Reap unused entries.
entry = ASSET.entries;
do {
if(entry->state != ASSET_ENTRY_STATE_LOADED) {
entry++;
continue;
}
if(entry->type == ASSET_LOADER_TYPE_NULL) {
entry++;
continue;
}
if(entry->refs.count > 0) {
entry++;
continue;
}
// consolePrint("Reaping asset %s", entry->name);
errorChain(assetEntryDispose(entry));
entry++;
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
errorOk(); errorOk();
} }
@@ -413,26 +423,7 @@ errorret_t assetDispose(void) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
threadStop(&ASSET.loadThread); threadStop(&ASSET.loadThread);
// Drain-dispose: repeatedly find and dispose zero-ref LOADED entries errorChain(assetReapUnused());
// until none remain. This handles dependency chains where an entry
// (e.g. a model) holds refs on child entries (mesh, texture): dispose
// parents first so child ref counts drop to zero, then pick up the
// children on the next pass. Without this, a forward-only scan fails
// when a shared child entry appears before a parent that still holds a
// ref to it.
bool_t any;
do {
any = false;
assetentry_t *e = ASSET.entries;
do {
if(e->type == ASSET_LOADER_TYPE_NULL) { e++; continue; }
if(e->state != ASSET_ENTRY_STATE_LOADED) { e++; continue; }
if(e->refs.count > 0) { e++; continue; }
errorChain(assetEntryDispose(e));
any = true;
e++;
} while(e < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
} while(any);
// Cleanup zip file. // Cleanup zip file.
if(ASSET.zip != NULL) { if(ASSET.zip != NULL) {
+12 -2
View File
@@ -23,8 +23,8 @@
#define ASSET_FILE_NAME "dusk.dsk" #define ASSET_FILE_NAME "dusk.dsk"
#define ASSET_HEADER_SIZE 3 #define ASSET_HEADER_SIZE 3
#define ASSET_LOADING_COUNT_MAX 20 #define ASSET_LOADING_COUNT_MAX 10
#define ASSET_ENTRY_COUNT_MAX 128 #define ASSET_ENTRY_COUNT_MAX 64
typedef struct asset_s { typedef struct asset_s {
zip_t *zip; zip_t *zip;
@@ -112,6 +112,16 @@ void assetUnlock(const char_t *name);
*/ */
void assetUnlockEntry(assetentry_t *entry); void assetUnlockEntry(assetentry_t *entry);
/**
* Frees every currently unreferenced (zero-ref) loaded asset entry. Repeats
* until a full pass frees nothing further, since disposing a parent entry
* (e.g. a model) may drop a child entry's (e.g. a mesh) ref count to zero,
* making it eligible for reaping too.
*
* @return An error code if any entry could not be disposed properly.
*/
errorret_t assetReapUnused(void);
/** /**
* Requires an asset entry to be loaded. This will block until the asset entry * Requires an asset entry to be loaded. This will block until the asset entry
* is fully loaded. * is fully loaded.
+1 -1
View File
@@ -15,5 +15,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(locale) add_subdirectory(locale)
add_subdirectory(json) add_subdirectory(json)
add_subdirectory(chunk)
add_subdirectory(dmf) add_subdirectory(dmf)
add_subdirectory(animation)
@@ -0,0 +1,9 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
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 .dispose = assetJsonDispose
}, },
[ASSET_LOADER_TYPE_CHUNK] = { [ASSET_LOADER_TYPE_ANIMATION] = {
.loadSync = assetChunkLoaderSync, .loadSync = assetAnimationLoaderSync,
.loadAsync = assetChunkLoaderAsync, .loadAsync = assetAnimationLoaderAsync,
.dispose = assetChunkDispose .dispose = assetAnimationDispose
}, },
}; };
+4 -5
View File
@@ -12,7 +12,7 @@
#include "asset/loader/display/assettilesetloader.h" #include "asset/loader/display/assettilesetloader.h"
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h" #include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h" #include "asset/loader/animation/assetanimationloader.h"
typedef enum { typedef enum {
ASSET_LOADER_TYPE_NULL, ASSET_LOADER_TYPE_NULL,
@@ -23,7 +23,7 @@ typedef enum {
ASSET_LOADER_TYPE_TILESET, ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE, ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON, ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK, ASSET_LOADER_TYPE_ANIMATION,
ASSET_LOADER_TYPE_COUNT ASSET_LOADER_TYPE_COUNT
} assetloadertype_t; } assetloadertype_t;
@@ -35,7 +35,7 @@ typedef union {
assettilesetloaderloading_t tileset; assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale; assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json; assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk; assetanimationloaderloading_t animation;
} assetloaderloading_t; } assetloaderloading_t;
typedef union { typedef union {
@@ -45,7 +45,7 @@ typedef union {
assettilesetoutput_t tileset; assettilesetoutput_t tileset;
assetlocaleoutput_t locale; assetlocaleoutput_t locale;
assetjsonoutput_t json; assetjsonoutput_t json;
assetchunkoutput_t chunk; assetanimationoutput_t animation;
} assetloaderoutput_t; } assetloaderoutput_t;
typedef union { typedef union {
@@ -53,7 +53,6 @@ typedef union {
assettilesetloaderinput_t tileset; assettilesetloaderinput_t tileset;
assetlocaleloaderinput_t locale; assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json; assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
} assetloaderinput_t; } assetloaderinput_t;
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
@@ -1,166 +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);
memoryCopy(out->tiles, data + offset, tileSize);
offset += tileSize;
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);
}
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;
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 3
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[CHUNK_TILE_COUNT];
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);
+19 -8
View File
@@ -48,8 +48,20 @@ errorret_t assetMeshLoaderAsync(assetloading_t *loading) {
uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8)); uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8));
meshvertex_t *vertices = NULL; meshvertex_t *vertices = NULL;
if(vertCount > 0) { if(vertCount > 0) {
vertices = memoryAllocate(vertCount * sizeof(meshvertex_t)); // 32-byte (cache-line) aligned: GX_SetArray + DCFlushRange on Dolphin
// require this for the DMA'd vertex data to actually reach the GPU
// coherently. Static compiled-in vertex arrays happen to get this from
// the linker; a plain memoryAllocate here would not.
vertices = memoryAlign(32, vertCount * sizeof(meshvertex_t));
memoryCopy(vertices, raw + 12, vertCount * sizeof(meshvertex_t)); memoryCopy(vertices, raw + 12, vertCount * sizeof(meshvertex_t));
for(uint32_t v = 0; v < vertCount; v++) {
vertices[v].uv[0] = endianLittleToHostFloat(vertices[v].uv[0]);
vertices[v].uv[1] = endianLittleToHostFloat(vertices[v].uv[1]);
vertices[v].pos[0] = endianLittleToHostFloat(vertices[v].pos[0]);
vertices[v].pos[1] = endianLittleToHostFloat(vertices[v].pos[1]);
vertices[v].pos[2] = endianLittleToHostFloat(vertices[v].pos[2]);
}
} }
memoryFree(raw); memoryFree(raw);
@@ -103,22 +115,18 @@ errorret_t assetMeshLoaderSync(assetloading_t *loading) {
out->vertices = NULL; out->vertices = NULL;
errorChain(ret); errorChain(ret);
} }
out->meshInitialized = true;
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount); ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
if(errorIsNotOk(ret)) { if(errorIsNotOk(ret)) {
loading->entry->state = ASSET_ENTRY_STATE_ERROR; loading->entry->state = ASSET_ENTRY_STATE_ERROR;
meshDispose(&out->mesh); meshDispose(&out->mesh);
out->meshInitialized = false;
memoryFree(out->vertices); memoryFree(out->vertices);
out->vertices = NULL; out->vertices = NULL;
errorChain(ret); errorChain(ret);
} }
#ifndef DUSK_OPENGL_LEGACY
// VBO owns the data now; CPU copy is no longer needed.
memoryFree(out->vertices);
out->vertices = NULL;
#endif
loading->entry->state = ASSET_ENTRY_STATE_LOADED; loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk(); errorOk();
} }
@@ -129,8 +137,11 @@ errorret_t assetMeshDispose(assetentry_t *entry) {
assertIsMainThread("Must be called from the main thread."); assertIsMainThread("Must be called from the main thread.");
assetmeshoutput_t *out = &entry->data.mesh; assetmeshoutput_t *out = &entry->data.mesh;
if(out->vertices != NULL) { if(out->meshInitialized) {
errorChain(meshDispose(&out->mesh)); errorChain(meshDispose(&out->mesh));
out->meshInitialized = false;
}
if(out->vertices != NULL) {
memoryFree(out->vertices); memoryFree(out->vertices);
out->vertices = NULL; out->vertices = NULL;
} }
@@ -30,6 +30,7 @@ typedef struct {
typedef struct { typedef struct {
mesh_t mesh; mesh_t mesh;
bool_t meshInitialized;
meshvertex_t *vertices; meshvertex_t *vertices;
} assetmeshoutput_t; } assetmeshoutput_t;
@@ -506,12 +506,17 @@ errorret_t assetLocaleGetString(
sizeof(lineBuffer) sizeof(lineBuffer)
); );
// Prime the reader with the first line before scanning; outBuffer holds
// uninitialized memory until the first Next() call fills it.
errorChain(assetFileLineReaderNext(&reader));
// Skip blanks, comments, etc and start looking for msgid's // Skip blanks, comments, etc and start looking for msgid's
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer)); errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
while(!reader.eof) { while(true) {
// Is this msgid? // Is this msgid?
if(memoryCompare(lineBuffer, "msgid", 5) != 0) { if(memoryCompare(lineBuffer, "msgid", 5) != 0) {
if(reader.eof) break;
errorChain(assetFileLineReaderNext(&reader)); errorChain(assetFileLineReaderNext(&reader));
msgidBuffer[0] = '\0'; msgidBuffer[0] = '\0';
continue; continue;
@@ -535,7 +540,7 @@ errorret_t assetLocaleGetString(
} }
// We are either going to see a msgstr or a msgid_plural // We are either going to see a msgstr or a msgid_plural
while(!reader.eof) { while(true) {
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer)); errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
// Is msgid_plural? // Is msgid_plural?
+3 -2
View File
@@ -17,7 +17,7 @@ console_t CONSOLE;
void consoleInit(void) { void consoleInit(void) {
memoryZero(&CONSOLE, sizeof(console_t)); memoryZero(&CONSOLE, sizeof(console_t));
CONSOLE.visible = true; CONSOLE.visible = false;
#ifdef DUSK_CONSOLE_POSIX #ifdef DUSK_CONSOLE_POSIX
threadMutexInit(&CONSOLE.printMutex); threadMutexInit(&CONSOLE.printMutex);
@@ -42,6 +42,7 @@ void consolePrint(const char_t *message, ...) {
(CONSOLE_HISTORY_MAX - 1) * CONSOLE_LINE_MAX (CONSOLE_HISTORY_MAX - 1) * CONSOLE_LINE_MAX
); );
memoryCopy(CONSOLE.line[CONSOLE_HISTORY_MAX - 1], buffer, len + 1); memoryCopy(CONSOLE.line[CONSOLE_HISTORY_MAX - 1], buffer, len + 1);
CONSOLE.dirty = true;
#ifdef DUSK_CONSOLE_POSIX #ifdef DUSK_CONSOLE_POSIX
threadMutexUnlock(&CONSOLE.printMutex); threadMutexUnlock(&CONSOLE.printMutex);
@@ -55,7 +56,7 @@ void consoleUpdate(void) {
if(TIME.dynamicUpdate) return; if(TIME.dynamicUpdate) return;
#endif #endif
if(inputPressed(INPUT_ACTION_CONSOLE)) { if(inputPressed(INPUT_BIND_CONSOLE)) {
CONSOLE.visible = !CONSOLE.visible; CONSOLE.visible = !CONSOLE.visible;
} }
} }
+5
View File
@@ -21,6 +21,11 @@ typedef struct {
char_t line[CONSOLE_HISTORY_MAX][CONSOLE_LINE_MAX]; char_t line[CONSOLE_HISTORY_MAX][CONSOLE_LINE_MAX];
bool_t visible; bool_t visible;
// Set whenever the history changes; consumers rendering the console
// (e.g. uiConsoleDraw) check and clear this to know when their own
// cached representation of the history needs rebuilding.
bool_t dirty;
#ifdef DUSK_CONSOLE_POSIX #ifdef DUSK_CONSOLE_POSIX
threadmutex_t printMutex; threadmutex_t printMutex;
#endif #endif
+2 -2
View File
@@ -12,8 +12,8 @@ mesh_t CUBE_MESH_SIMPLE;
meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT]; meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT];
errorret_t cubeInit() { errorret_t cubeInit() {
vec3 min = { 0.0f, 0.0f, 0.0f }; vec3 min = { -0.5f, -0.5f, -0.5f };
vec3 max = { 1.0f, 1.0f, 1.0f }; vec3 max = { 0.5f, 0.5f, 0.5f };
cubeBuffer(CUBE_MESH_SIMPLE_VERTICES, min, max); cubeBuffer(CUBE_MESH_SIMPLE_VERTICES, min, max);
errorChain(meshInit( errorChain(meshInit(
&CUBE_MESH_SIMPLE, &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]; 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. * @return Error for initialization of the cube mesh.
*/ */
+13 -7
View File
@@ -59,7 +59,8 @@ void planeBuffer(
switch(axis) { switch(axis) {
case PLANE_AXIS_XY: { 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]; const float_t z = min[2];
PLANE_VERT(0, min[0], min[1], z, u0, v0) PLANE_VERT(0, min[0], min[1], z, u0, v0)
PLANE_VERT(1, max[0], min[1], z, u1, v0) PLANE_VERT(1, max[0], min[1], z, u1, v0)
@@ -71,19 +72,24 @@ void planeBuffer(
} }
case PLANE_AXIS_XZ: { 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]; const float_t y = min[1];
PLANE_VERT(0, min[0], y, min[2], u0, v0) PLANE_VERT(0, min[0], y, min[2], u0, v0)
PLANE_VERT(1, max[0], y, min[2], u1, v0) PLANE_VERT(1, max[0], y, max[2], u1, v1)
PLANE_VERT(2, 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(3, min[0], y, min[2], u0, v0)
PLANE_VERT(4, max[0], y, max[2], u1, v1) PLANE_VERT(4, min[0], y, max[2], u0, v1)
PLANE_VERT(5, min[0], y, max[2], u0, v1) PLANE_VERT(5, max[0], y, max[2], u1, v1)
break; break;
} }
case PLANE_AXIS_YZ: { 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]; const float_t x = min[0];
PLANE_VERT(0, x, min[1], min[2], u0, v0) PLANE_VERT(0, x, min[1], min[2], u0, v0)
PLANE_VERT(1, x, max[1], min[2], u1, v0) PLANE_VERT(1, x, max[1], min[2], u1, v0)
+1
View File
@@ -21,6 +21,7 @@ errorret_t shaderInit(shader_t *shader, const shaderdefinition_t *def) {
errorret_t shaderBind(shader_t *shader) { errorret_t shaderBind(shader_t *shader) {
assertNotNull(shader, "Shader cannot be null"); assertNotNull(shader, "Shader cannot be null");
if(bound == shader) errorOk();
errorChain(shaderBindPlatform(shader)); errorChain(shaderBindPlatform(shader));
bound = shader; bound = shader;
errorOk(); errorOk();
@@ -39,3 +39,16 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition(
sprite.uvMax[1] = uv[3]; sprite.uvMax[1] = uv[3];
return sprite; 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 width,
const float_t height 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 # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
font.c
text.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 #pragma once
#include "error/error.h"
#include "display/texture/texture.h" #include "display/texture/texture.h"
#include "display/texture/tileset.h" #include "display/texture/tileset.h"
@@ -13,3 +14,53 @@ typedef struct {
texture_t *texture; texture_t *texture;
tileset_t *tileset; tileset_t *tileset;
} font_t; } 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 "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "display/spritebatch/spritebatch.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" #include "display/shader/shaderunlit.h"
font_t FONT_DEFAULT;
errorret_t textInit(void) { errorret_t textInit(void) {
assetloaderinput_t input = { .texture = TEXTURE_FORMAT_RGBA }; errorChain(fontInitDefault());
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;
errorOk(); errorOk();
} }
errorret_t textDispose(void) { errorret_t textDispose(void) {
FONT_DEFAULT.texture = NULL; errorChain(fontDisposeDefault());
FONT_DEFAULT.tileset = NULL;
assetUnlock("ui/minogram.png");
assetUnlock("ui/minogram.dtf");
errorOk(); errorOk();
} }
@@ -116,6 +97,89 @@ errorret_t textDraw(
errorOk(); 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( void textMeasure(
const char_t *text, const char_t *text,
const font_t *font, const font_t *font,
+48 -2
View File
@@ -12,8 +12,6 @@
#define TEXT_CHAR_START '!' #define TEXT_CHAR_START '!'
extern font_t FONT_DEFAULT;
/** /**
* Initializes the text system. * Initializes the text system.
* *
@@ -60,6 +58,54 @@ errorret_t textDraw(
font_t *font 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. * Measures the width and height of the given text string when rendered.
* *
+41 -14
View File
@@ -10,16 +10,23 @@
#include "time/time.h" #include "time/time.h"
#include "input/input.h" #include "input/input.h"
#include "locale/localemanager.h" #include "locale/localemanager.h"
#include "rpg/rpg.h"
#include "display/display.h" #include "display/display.h"
#include "scene/scene.h" #include "scene/scene.h"
#include "asset/asset.h" #include "asset/asset.h"
#include "script/scriptmanager.h"
#include "script/module/scene/modulescene.h"
#include "ui/ui.h" #include "ui/ui.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "network/network.h" #include "network/network.h"
#include "network/socket/client/client.h"
#include "network/socket/client/clientroster.h"
#include "network/socket/server/serveronline.h"
#include "game/game.h"
#include "system/system.h" #include "system/system.h"
#include "console/console.h" #include "console/console.h"
#include "save/save.h" #include "save/save.h"
#include "save/savesettings.h"
#include "log/log.h"
engine_t ENGINE; engine_t ENGINE;
@@ -37,13 +44,15 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorChain(systemInit()); errorChain(systemInit());
errorChain(inputInit()); errorChain(inputInit());
errorChain(assetInit()); errorChain(assetInit());
// errorChain(saveInit()); errorChain(scriptManagerInit());
errorChain(saveInit());
errorChain(saveSettingsLoad());
errorChain(localeManagerInit()); errorChain(localeManagerInit());
errorChain(displayInit()); errorChain(displayInit());
errorChain(uiInit()); errorChain(uiInit());
errorChain(rpgInit());
errorChain(networkInit()); errorChain(networkInit());
errorChain(sceneInit()); errorChain(sceneInit());
errorChain(gameInit());
consolePrint("Engine initialized"); consolePrint("Engine initialized");
@@ -53,8 +62,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
consolePrint("Assertions real"); consolePrint("Assertions real");
#endif #endif
sceneSet(SCENE_TYPE_OVERWORLD); // sceneSet(SCENE_TYPE_OVERWORLD);
errorOk(); errorOk();
} }
@@ -62,17 +70,30 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorret_t engineUpdate(void) { errorret_t engineUpdate(void) {
// Order here is important. // Order here is important.
errorChain(networkUpdate()); errorChain(networkUpdate());
errorChain(clientUpdate());
errorChain(serverOnlineUpdate());
timeUpdate(); timeUpdate();
inputUpdate();
consoleUpdate(); const systemdialogtype_t dialogType = systemGetActiveDialogType();
errorChain(rpgUpdate()); if(dialogType == SYSTEM_DIALOG_TYPE_NONE) {
errorChain(sceneUpdate()); inputUpdate();
errorChain(assetUpdate()); consoleUpdate();
errorChain(uiUpdate());
errorChain(gameUpdate());
errorChain(moduleSceneUpdateCurrent());
errorChain(sceneUpdate());
errorChain(assetUpdate());
errorChain(uiUpdate());
}
// Render // Render
errorChain(displayUpdate()); errorChain(displayUpdate());
if(inputPressed(INPUT_ACTION_RAGEQUIT)) ENGINE.running = false; if(
dialogType == SYSTEM_DIALOG_TYPE_NONE &&
inputPressed(INPUT_BIND_RAGEQUIT)
) {
ENGINE.running = false;
}
errorOk(); errorOk();
} }
@@ -81,14 +102,20 @@ void engineExit(void) {
} }
errorret_t engineDispose(void) { errorret_t engineDispose(void) {
errorChain(gameDispose());
errorChain(sceneDispose()); errorChain(sceneDispose());
errorChain(serverOnlineStop());
client_t *localClient = clientRosterFindLocal();
if(localClient) localClientDisconnect(&localClient->local);
errorChain(networkDispose()); errorChain(networkDispose());
errorChain(rpgDispose());
localeManagerDispose(); localeManagerDispose();
errorChain(uiDispose()); errorChain(uiDispose());
consoleDispose(); consoleDispose();
errorChain(displayDispose()); errorChain(displayDispose());
// errorChain(saveDispose()); errorChain(saveDispose());
errorChain(scriptManagerDispose());
errorChain(assetDispose()); errorChain(assetDispose());
errorOk(); errorOk();
-1
View File
@@ -37,4 +37,3 @@ errorret_t engineUpdate(void);
* Shuts down the engine. * Shuts down the engine.
*/ */
errorret_t engineDispose(void); errorret_t engineDispose(void);
+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);
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Subdirs
add_subdirectory(display)
add_subdirectory(physics)
add_subdirectory(trigger)
add_subdirectory(animation)
@@ -6,5 +6,5 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
assetvita.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
);
@@ -6,8 +6,7 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
npc.c entityposition.c
npcturn.c entitycamera.c
npcwalk.c entityrenderable.c
npcpath.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 # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
inputvita.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,5 +6,6 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
assetchunkloader.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"

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