Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42cb84b610 | |||
| f8607d114c | |||
| 56230dd340 | |||
| df9fdf26c8 | |||
| 8f8fa8f8d1 | |||
| 07f98c119a | |||
| d49194fc4d | |||
| e61914bad4 | |||
| 015d90519f | |||
| 7b4f0abfb8 | |||
| 0667a44e14 | |||
| 0852b8463b | |||
| 1436fda858 | |||
| 0826908068 | |||
| 7ceb9e571d | |||
| 38c2f6b6f9 | |||
| b9f06fef7a | |||
| 373f1c1010 | |||
| e80d693d85 | |||
| ef60ddf6a8 | |||
| de0ff2e263 | |||
| 13a435e9c1 | |||
| 725338cd5a | |||
| acd14f46a8 | |||
| ef78f023ed | |||
| 85b6109792 | |||
| 6c5738c863 | |||
| 1174e471f6 | |||
| a618ff46fe | |||
| 1fa5cd316e | |||
| 11e5d37563 | |||
| 9f86c20129 | |||
| 8fb1c9fb42 | |||
| 5f08337726 | |||
| 0bc80d5df3 | |||
| 2432443ea6 | |||
| fa138971e7 | |||
| d7982599d1 | |||
| f16c80aa0f | |||
| ef5adf8274 | |||
| 2ffc65b1b1 | |||
| d4a17bd98d | |||
| dcf5f434c5 | |||
| 78cd973a33 | |||
| ca02ee0352 | |||
| fbaa54145e | |||
| 28754ffbf2 | |||
| 470c0eba7a | |||
| 7098dcec43 | |||
| 07137f57af | |||
| 8b7491a3d3 | |||
| 8cfa8ddfeb | |||
| ef284a15a1 | |||
| 3723921573 | |||
| 195399635e | |||
| 46e2a924d3 | |||
| b693ea4102 | |||
| a73f55beb0 | |||
| 0bd2491ab7 | |||
| 860c797c9c | |||
| 38c5080f9f | |||
| fae191d8fe | |||
| d83a953e2d | |||
| 2dcf0d0f0d | |||
| 6dee37d8c1 | |||
| 3bad03afb3 | |||
| 6a6d8448f7 | |||
| 189babd2cf | |||
| 988a0f2294 | |||
| 85bf455731 | |||
| 589e4224f3 | |||
| bd7fa154b4 | |||
| a292f1992b | |||
| 88ddf429b7 | |||
| 7a1f6662df | |||
| afc68bccc6 | |||
| b4cdc4a64f | |||
| 3ad5afb81c | |||
| bed3f20118 | |||
| de67315178 | |||
| 8d5c0c7cad | |||
| a8271e01bd | |||
| 900b3f8558 | |||
| fdc4e056f9 | |||
| 7b98e40ccf | |||
| 01d89cf22c | |||
| 503a3c799a | |||
| 0b21388844 | |||
| 117bdf0c00 | |||
| 172dc5d37b |
@@ -0,0 +1,858 @@
|
||||
# Dusk Code Check
|
||||
|
||||
A working checklist for walking through `src/dusk/` (the core, platform-
|
||||
agnostic engine layer) one subsystem at a time and reviewing how it
|
||||
actually looks today -- not just what it's supposed to do. Pairs with
|
||||
`STATUS.md` (maturity snapshot) and `ROADMAP.md` (forward-looking work);
|
||||
this doc is about auditing what's already there.
|
||||
|
||||
Each section below: **Purpose**, **Key files**, and a **Review status**
|
||||
line to fill in as each system gets walked through (e.g. `Not started` /
|
||||
`In progress` / `Reviewed 2026-08-01: <one-line finding>`).
|
||||
|
||||
First full pass completed 2026-08-01 (all 23 sections below reviewed).
|
||||
See "Top cross-cutting findings" right after the index for the
|
||||
highest-priority items across all of them before diving into any one
|
||||
section -- this doc is long, don't make someone read all 23 to find
|
||||
the load-bearing bugs.
|
||||
|
||||
Game-specific code lives in `src/duskrpg/` (not covered here) and
|
||||
platform-specific implementations live in `src/dusk<platform>/` /
|
||||
`src/duskgl/` / `src/dusksdl2/` (also not covered here) -- see `CLAUDE.md`
|
||||
for the layer structure.
|
||||
|
||||
---
|
||||
|
||||
## Index
|
||||
|
||||
- [error](#error)
|
||||
- [assert](#assert)
|
||||
- [util](#util)
|
||||
- [event](#event)
|
||||
- [time](#time)
|
||||
- [thread](#thread)
|
||||
- [log](#log)
|
||||
- [system](#system)
|
||||
- [console](#console)
|
||||
- [asset](#asset)
|
||||
- [locale](#locale)
|
||||
- [save](#save)
|
||||
- [network](#network)
|
||||
- [display](#display)
|
||||
- [ui](#ui)
|
||||
- [entity](#entity)
|
||||
- [scene](#scene)
|
||||
- [physics](#physics)
|
||||
- [animation](#animation)
|
||||
- [script](#script)
|
||||
- [input](#input)
|
||||
- [engine](#engine)
|
||||
- [game](#game)
|
||||
|
||||
---
|
||||
|
||||
## Top cross-cutting findings
|
||||
|
||||
Ranked roughly by real-world impact, not by section order. Each links
|
||||
to its full writeup below.
|
||||
|
||||
1. **Bounds/capacity checks that matter at runtime are `assert*` calls,
|
||||
which compile to no-ops in release (`DUSK_ASSERTIONS_FAKED`, set
|
||||
whenever `NDEBUG` is defined) -- confirmed by reading `assert.h`
|
||||
directly.** This shows up in [entity](#entity) (`entitymanager.c`,
|
||||
`component.c` -- reachable from JerryScript via `moduleentity.c`
|
||||
with no return-value check) and [scene](#scene) (`sceneCreate`'s
|
||||
pool-exhaustion path, reachable from `new Scene()`). Untrusted
|
||||
script input can silently get back an invalid ID that later indexes
|
||||
a fixed array out of bounds in a release build -- the exact
|
||||
"validate untrusted data with errorThrow, not asserts" case
|
||||
CLAUDE.md/project memory already calls out, just not applied here.
|
||||
2. **FIXED 2026-08-01.** `util/ref.c`'s `refUnlock` decremented an
|
||||
unsigned `count` guarded only by a tautological `count >= 0` assert
|
||||
([util](#util)) -- a double-unlock silently underflowed to
|
||||
`UINT32_MAX` instead of tripping an assert. This wasn't just a
|
||||
`util/` nit: it's the lock/unlock primitive `asset/loader/assetentry.c`
|
||||
builds on (see [asset](#asset)), so an over-unlocked asset entry
|
||||
became permanently un-reapable rather than erroring loudly. Now
|
||||
asserts `count > 0` before decrementing; the pointless
|
||||
`count >= 0` check in `refLock` was removed too.
|
||||
3. **`texture.c:42-52`'s `textureInit()` reads `texture->format` to
|
||||
decide which `data` fields to validate *before* the struct is
|
||||
zeroed a few lines later** ([display](#display)) -- it's validating
|
||||
whatever stale value happened to be in the caller's (possibly
|
||||
stack) memory, not the `format` parameter actually being requested.
|
||||
4. **FIXED 2026-08-01 (confirmed real before fixing).** PSP's
|
||||
`timeGetRealTimeZonePSP` returned hours; every other platform
|
||||
(`timeGetRealTimeZoneLinux`/Dolphin) returns seconds, and
|
||||
`timeEpochInit`/`timeEpochGetHours` etc. add the timezone value
|
||||
directly to a Unix-epoch-seconds timestamp -- confirmed by reading
|
||||
all three platform implementations plus `timeepoch.c` directly, not
|
||||
just trusting the review agent's claim. The PSP function's own
|
||||
comment even said "Return timezone offset in hours". Now divides by
|
||||
`1000000.0` only (microseconds -> seconds), matching the other
|
||||
platforms. See [time](#time).
|
||||
5. **Discussed, not changed.** `consolePrint` aborts the process (via
|
||||
`stringFormatVA`'s `assert(ret < destSize)`) instead of truncating
|
||||
when a single debug line exceeds 511 bytes. This is a real, if rare,
|
||||
crash risk (any format producing >511 bytes of runtime-variable
|
||||
content -- a long path, a long error string -- takes the whole
|
||||
process down over a debug print), but changing `stringFormatVA`
|
||||
itself would affect every caller in the codebase, and a
|
||||
console-local truncating rewrite is easy to do wrong (vsnprintf
|
||||
directly, bypassing the project's string-utils convention) without
|
||||
more time to get right. Left as-is pending a decision on which
|
||||
fix shape is wanted. See [console](#console).
|
||||
6. **FIXED 2026-08-01.** `thread.c`'s `threadHandler` signaled
|
||||
`THREAD_STATE_STOPPED` before resetting `threadId = 0`, and
|
||||
`threadStartRequest` wasn't synchronized against that reset -- a
|
||||
real (if rare-window) race, not hypothetical: the test suite was
|
||||
already working around it by re-calling `threadInit()` instead of
|
||||
exercising true stop/start reuse. `threadId` is now reset inside the
|
||||
same mutex-locked section before the STOPPED signal/unlock;
|
||||
`test_thread_restart` now loops 20x on the same `thread_t` without
|
||||
re-initializing, which would have caught the old race. See
|
||||
[thread](#thread).
|
||||
7. **FIXED 2026-08-01.** `tools/color.py` emitted invalid C float
|
||||
literals (`0f`, `1f`, etc.) for any whole-number CSV channel --
|
||||
confirmed by actually running the generator against `color.csv`
|
||||
before and after. Was dormant only because `COLOR_*_3F`/`_4F` were
|
||||
unreferenced anywhere in the repo; the first real use of a float
|
||||
color variant wouldn't have compiled. Now routes channel values
|
||||
through Python `float()` before interpolating (always stringifies
|
||||
with a decimal point, e.g. `0.0`/`1.0`), and `test/display/test_color.c`
|
||||
gained two tests that reference `COLOR_BLACK_3F`/`COLOR_WHITE_4F`/
|
||||
`COLOR_RED_3B`/`COLOR_GRAY_3F` directly -- giving the previously-dead
|
||||
generated macros real compile-time + value coverage instead of
|
||||
silently bit-rotting again. See [display](#display).
|
||||
8. **`assetModelLoaderAsync` is confirmed fully dead** -- model loading
|
||||
is genuinely synchronous end-to-end (verified by tracing the state
|
||||
machine, not just repeating STATUS.md's claim). See [asset](#asset).
|
||||
9. **No cycle guard on prefab `extends` chains** in
|
||||
`scenePrefabResolveAndApply` -- a self-referencing or A->B->A prefab
|
||||
table is a stack-overflow crash from what looks like a harmless
|
||||
typo in hand-authored data. See [scene](#scene).
|
||||
10. **Untested-code hot spots worth prioritizing** if test investment
|
||||
is on the table: `util/ref.c`/`random.c`/`crypt.c`/`endian.c`
|
||||
(zero tests), `animation/easing.c` (13 of 16 functions untested),
|
||||
`event/`, `console/`, `system/`, `log/`, `input/`, `engine/`,
|
||||
`locale/`, `save/`, and most of `display/` (mesh, shader, texture,
|
||||
tileset, text, screen, framebuffer) have no dedicated tests at
|
||||
all.
|
||||
|
||||
Convention-only items (real, but lower stakes than the above): raw
|
||||
stdlib string functions (`strlen`/`strchr`/`strstr`/etc.) show up
|
||||
instead of `util/string.h` wrappers in `network/http/` and
|
||||
`asset/loader/locale/assetlocaleloader.c` ([network](#network),
|
||||
[asset](#asset)); several `display/mesh/` files use `//` header
|
||||
comments and `/* */` inline comments where CLAUDE.md wants the
|
||||
opposite ([display](#display)); `animation/`'s core files all use `//`
|
||||
copyright headers instead of `/** */` ([animation](#animation)).
|
||||
|
||||
---
|
||||
|
||||
## error
|
||||
|
||||
**Purpose:** The engine's single error-handling convention. Every
|
||||
fallible function returns `errorret_t`, built with `errorOk()` /
|
||||
`errorThrow(fmt, ...)` / `errorChain(call)` and consumed with
|
||||
`errorIsOk()`/`errorIsNotOk()`/`errorCatch()`. No `errno`, no raw error
|
||||
codes anywhere else in the engine.
|
||||
|
||||
**Key files:** `error.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Solid, and tested unusually well
|
||||
(`test/error/test_error.c` covers thread isolation and concurrent
|
||||
throws, not just happy path). Two issues: `errorChainImpl` (error.c:86-91)
|
||||
hand-rolls `snprintf`/`strlen` instead of `util/string.h`'s
|
||||
`stringFormat`/`stringFormatVA` wrappers that `errorThrowImpl` uses one
|
||||
function above it -- an inconsistency, not just a style nit, since
|
||||
CLAUDE.md requires the wrappers everywhere. `errorThrowWithCode`
|
||||
(error.h:100) is dead code -- zero call sites anywhere in `src/`/`test/`.
|
||||
|
||||
---
|
||||
|
||||
## assert
|
||||
|
||||
**Purpose:** Debug-time invariant checks -- `assertNotNull`, `assertTrue`,
|
||||
`assertFalse`, `assertUnreachable`, `assertIsMainThread`. Distinct from
|
||||
`error/`: asserts are for programmer mistakes/invariants, `errorret_t` is
|
||||
for runtime-recoverable failures (see `CLAUDE.md`'s note on validating
|
||||
untrusted data with `errorThrow` rather than asserting).
|
||||
|
||||
**Key files:** `assert.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Clean, no issues found. Impl
|
||||
functions + macros wrapping `__FILE__`/`__LINE__` and a proper
|
||||
`DUSK_ASSERTIONS_FAKED` no-op path for release builds;
|
||||
`assertNotNullImpl` (assert.c:109-111) does a real "touch" dereference
|
||||
after the null check as a belt-and-braces addition. Test coverage
|
||||
exercises both success and `expect_assert_failure` failure paths for
|
||||
every variant, including `assertDeprecated`/`assertIsMainThread`.
|
||||
|
||||
---
|
||||
|
||||
## util
|
||||
|
||||
**Purpose:** Small stand-alone helpers with no engine-state dependencies:
|
||||
`array` (dynamic array), `crypt` (hashing/checksum), `endian` (byte-order
|
||||
swap), `math` (clamp/lerp/pow2/etc.), `memory` (`memoryAllocate`/`Free`/
|
||||
`Zero`/`Copy`/`Compare` -- the only allocator the rest of the engine is
|
||||
allowed to call directly), `random`, `ref` (refcounted handle with
|
||||
lock/unlock callbacks), `sort` (bubble sort used for one-time static list
|
||||
ordering), `string` (`stringCopy`/`Compare`/`Format`/etc. -- required in
|
||||
place of libc `str*` functions per `CLAUDE.md`).
|
||||
|
||||
**Key files:** `array.c/h`, `crypt.c/h`, `endian.c/h`, `math.c/h`,
|
||||
`memory.c/h`, `random.c/h`, `ref.c/h`, `sort.c/h`, `string.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Several real issues, worst was
|
||||
in `ref.c`: `refUnlock` (ref.c:37-39) decremented `count` (`uint32_t`)
|
||||
guarded only by `assertTrue(ref->count >= 0, ...)` -- tautologically
|
||||
always true on an unsigned type, so a double-unlock silently underflowed
|
||||
to `UINT32_MAX` and kept invoking `onUnlock` instead of tripping an
|
||||
assert (confirmed by reading ref.c directly). **Fixed 2026-08-01** --
|
||||
`refUnlock` now asserts `count > 0` before decrementing; also fixed
|
||||
`refInit`'s doc comment, which claimed a fresh ref starts at count 1
|
||||
when the code has always started it at 0. Same tautological unsigned
|
||||
`>= 0` assert remains in `memory.c:98` (lower stakes -- `memoryFree`'s
|
||||
own counter, not a shared lock/unlock primitive other subsystems build
|
||||
on -- left as-is). `memory.h:11` declares
|
||||
`static size_t MEMORY_POINTERS_IN_USE = 0;` directly in the header --
|
||||
confirmed present -- meaning every TU including it gets its own private
|
||||
copy (only "works" because the accessors all live in memory.c).
|
||||
`random.c`'s `randomInt` (confirmed, random.c:15-16) does
|
||||
`rand() % (max - min)` with no `max > min` check (UB on `max <= min`),
|
||||
and `srand()` is never called anywhere in the repo -- unseeded,
|
||||
deterministic RNG. `sort.c:1` has `#include <stdlib.h>` before the
|
||||
copyright header (confirmed). Test coverage: array/math/memory/sort/
|
||||
string are thorough; `ref.c`, `random.c`, `crypt.c`, `endian.c` have
|
||||
**zero** tests. `randomInt`/`randomFloat` also appear unused
|
||||
engine-wide (dead code, not just untested).
|
||||
|
||||
---
|
||||
|
||||
## event
|
||||
|
||||
**Purpose:** Generic pub/sub primitive -- `event_t` wraps a caller-owned
|
||||
array of `eventcallback_t`/user-pointer pairs; `eventInit`/`eventSubscribe`/
|
||||
`eventInvoke`. Used for things like `uifullbox_t`'s transition-end
|
||||
callback and `uiloading_t`'s show/hide callbacks. Per `STATUS.md`: "Clean,
|
||||
small, finished", built to replace the old input system's callback
|
||||
pattern.
|
||||
|
||||
**Key files:** `event.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Well-formed -- correctly uses
|
||||
swap-with-last for O(1) unsubscribe, asserts on capacity overflow and
|
||||
duplicate-subscribe, no dead code (`eventUnsubscribe` used in 3 other
|
||||
files). No `errorret_t` needed here since these are programmer-error
|
||||
assertions rather than runtime-data validation, consistent with
|
||||
CLAUDE.md's assert-vs-error guidance. One gap: `test/event/` doesn't
|
||||
exist -- zero unit coverage, including the capacity-exceeded and
|
||||
double-subscribe assert paths.
|
||||
|
||||
---
|
||||
|
||||
## time
|
||||
|
||||
**Purpose:** Frame delta/elapsed time (`TIME.delta`, exposed to JS via
|
||||
`moduleTime`) plus `timeepoch_t` wall-clock timestamps (used by
|
||||
`uifps.c` for the FPS counter).
|
||||
|
||||
**Key files:** `time.c/h`, `timeepoch.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. `time.c:52-56` unconditionally
|
||||
formats an epoch string into a 256-byte stack buffer every single
|
||||
`timeUpdate()` call purely to feed a commented-out `consolePrint` --
|
||||
burns the leap-year/day-counting loop three times a frame for a
|
||||
discarded value. `timeepoch.c`'s day-counting loops have no
|
||||
handling/guard for negative epoch/timezone values (pre-1970). Test
|
||||
coverage is solid for `time.c` (mocked ticks, boundary/backwards-time
|
||||
cases) but `timeepoch.c`'s date math (leap year, rollover, format
|
||||
specifiers) has no dedicated tests despite being the more bug-prone
|
||||
half. The PSP-specific timezone unit bug flagged in the cross-cutting
|
||||
findings (#4) was confirmed and fixed 2026-08-01 --
|
||||
`timeGetRealTimeZonePSP` was returning hours where `timeepoch.c`
|
||||
expects seconds.
|
||||
|
||||
---
|
||||
|
||||
## thread
|
||||
|
||||
**Purpose:** Thin cross-platform thread/mutex/thread-local wrappers.
|
||||
Used by the asset system's background loading and the POSIX console's
|
||||
input-polling thread.
|
||||
|
||||
**Key files:** `thread.c/h`, `threadmutex.c/h`, `threadlocal.h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Conventions followed
|
||||
throughout. Real race condition, **fixed 2026-08-01**: `threadHandler`
|
||||
used to unlock `stateMutex` and signal `THREAD_STATE_STOPPED` *before*
|
||||
resetting `thread->threadId = 0`, but `threadStartRequest` asserts
|
||||
`threadId == 0` with no synchronization against that reset --
|
||||
`threadStop()` immediately followed by `threadStart()` on the same
|
||||
struct could intermittently trip the assert. Telling sign it was real,
|
||||
not theoretical: `test/thread/test_thread.c` already re-called
|
||||
`threadInit()` before restarting specifically "so threadId/state are
|
||||
reset" -- the test suite was working around the bug rather than
|
||||
exercising true reuse. `threadId` is now reset inside the same
|
||||
mutex-locked section, before the signal/unlock; the test now loops
|
||||
stop/start 20x on the same `thread_t` with no `threadInit()` in
|
||||
between. Otherwise test coverage is good (real pthreads, try-lock
|
||||
coordination, a 4-thread x 10k-iteration mutex contention test).
|
||||
|
||||
---
|
||||
|
||||
## log
|
||||
|
||||
**Purpose:** `logDebug`/`logError` -- the engine's only print/debug-output
|
||||
path. Header-only declarations; each platform provides the actual
|
||||
implementation (see `src/dusk<platform>/log/`).
|
||||
|
||||
**Key files:** `log.h` (declarations only, no `.c` in core).
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Clean 2-function API; all
|
||||
three platform implementations (`dusklinux`, `duskpsp`, `duskdolphin`)
|
||||
match the declared signatures with correct `va_copy` usage for the
|
||||
dual stdout+file writes. Dolphin's `logError` correctly falls back to
|
||||
raw VIDEO/console init when the framebuffer isn't ready yet and blocks
|
||||
on a START-button dismiss loop; PSP writes to
|
||||
`ms0:/PSP/GAME/Dusk/*.log`. No bugs found. No `test/log/` exists --
|
||||
understandable for I/O/side-effect code, but zero coverage.
|
||||
|
||||
---
|
||||
|
||||
## system
|
||||
|
||||
**Purpose:** Very early engine init (`systemInit`), the
|
||||
`systemplatform_t` enum (via the `systemplatformlist.h` X-macro pattern),
|
||||
and native OS dialog boxes (`systemdialogtype_t`: render-blocking vs
|
||||
tick-blocking).
|
||||
|
||||
**Key files:** `system.c/h`, `systemplatformlist.h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. X-macro platform enum and
|
||||
compile-time `#error` guards for missing platform macros used
|
||||
correctly; all three platforms define the required
|
||||
`systemInitPlatform`/`systemGetActiveDialogTypePlatform`/
|
||||
`systemGetCyclesPlatform`. Dead code confirmed: `systemPSPGetLanguage()`
|
||||
(`duskpsp/system/systempsp.c:51`) and `systemGetLanguageDolphin()`
|
||||
(`duskdolphin/system/systemdolphin.c:40`) are defined but never called
|
||||
anywhere -- likely intended for locale wiring that hasn't landed. No
|
||||
`test/system/` exists.
|
||||
|
||||
---
|
||||
|
||||
## console
|
||||
|
||||
**Purpose:** In-engine dev console: fixed-size scrollback
|
||||
(`CONSOLE_HISTORY_MAX` lines x `CONSOLE_LINE_MAX` chars), `consolePrint`,
|
||||
a `dirty` flag consumers (namely `ui/debug/uiconsole.c`) watch to know
|
||||
when to rebuild their own cached representation. POSIX builds also poll
|
||||
stdin on a background thread for interactive script execution.
|
||||
|
||||
**Key files:** `console.c/h`, `consoledefs.h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Two real issues. `consolePrint`
|
||||
(console.c:32-44) gets its length from `stringFormatVA(buffer,
|
||||
CONSOLE_LINE_MAX, ...)`, and `stringFormatVA` (util/string.c:100)
|
||||
**asserts** `ret < destSize` rather than truncating -- so any single
|
||||
debug line over 511 bytes aborts the process instead of just getting
|
||||
cut off, a harsh failure mode for a dev console. Separately,
|
||||
`consoleUpdate`'s doc ("Processes pending queued script lines") and
|
||||
`CONSOLE_EXEC_BUFFER_MAX` (consoledefs.h:12) describe a queued-script-
|
||||
execution feature that doesn't exist -- `console_t` has no exec-queue
|
||||
fields, `consoleUpdate` only toggles `visible`, and
|
||||
`CONSOLE_EXEC_BUFFER_MAX` is unreferenced elsewhere (dead constant,
|
||||
stale doc). No `test/console/` exists.
|
||||
|
||||
---
|
||||
|
||||
## asset
|
||||
|
||||
**Purpose:** The asset pipeline: `dusk.dsk` zip archive reading
|
||||
(`assetfile.c/h`), lock/ref-counted entries (`asset/loader/assetentry.c/h`,
|
||||
built on `util/ref.h`), batch loading (`assetbatch.c/h`), and the loader
|
||||
registry (`asset/loader/assetloader.c/h`, plus one subfolder per loader
|
||||
type: `dmf/` mesh+model, `display/` texture+tileset, `locale/`, `json/`,
|
||||
`animation/`). See `CLAUDE.md`'s "Adding a new asset loader type" for the
|
||||
extension pattern. Per `STATUS.md`: model loading's "async" path is
|
||||
actually synchronous -- the one known stub in core.
|
||||
|
||||
**Key files:** `asset.c/h`, `assetfile.c/h`, `assetbatch.c/h`,
|
||||
`loader/assetentry.c/h`, `loader/assetloader.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Core is well-structured and the
|
||||
multi-pass reaper correctly handles parent/child dependency chains.
|
||||
Found: `assetFileInit` (assetfile.c:29) has `if(!zip_stat(...) == 0)` --
|
||||
precedence makes this `(!zip_stat(...)) == 0`, which happens to
|
||||
evaluate correctly for zip's 0/nonzero convention but reads as broken
|
||||
and should just be `zip_stat(...) != 0`. More seriously,
|
||||
`assetEntryLock`/`Unlock` sit on top of `util/ref.c`'s confirmed
|
||||
double-unlock underflow bug (see **util** above) -- an over-unlocked
|
||||
asset entry silently becomes un-reapable forever instead of asserting.
|
||||
**Confirmed via code-trace** (not just citing STATUS.md):
|
||||
`assetModelLoaderAsync` (assetmodelloader.c:19-23) is genuinely dead --
|
||||
`assetEntryStartLoading` unconditionally sets
|
||||
`ASSET_ENTRY_STATE_PENDING_SYNC`, and `assetModelLoaderSync`'s state
|
||||
machine never transitions to `PENDING_ASYNC` the way mesh/texture/
|
||||
tileset/locale/json/animation all do, so model loading runs fully
|
||||
synchronously via `assetRequireLoaded`'s busy-loop; grep confirms
|
||||
`assetModelLoaderAsync` has no callers. `assetlocaleloader.c` uses raw
|
||||
stdlib (`strstr`/`strncmp`/`strchr`/`strtol`/`atoi`) instead of
|
||||
`util/string.h` wrappers throughout, and tests line-prefixes via
|
||||
`memoryCompare(lineBuffer, "msgid", 5)` without confirming the reused
|
||||
stack buffer actually holds 5 valid bytes at that point -- a short line
|
||||
could spuriously prefix-match against stale buffer contents. Test
|
||||
coverage: good breadth for entry lifecycle, tileset/JSON/animation
|
||||
loaders, and locale parsing; no dedicated tests for mesh/model/texture
|
||||
loader internals or `assetfile.c`'s line reader.
|
||||
|
||||
---
|
||||
|
||||
## locale
|
||||
|
||||
**Purpose:** Active-locale tracking (`LOCALE.locale`/`LOCALE.entry`) over
|
||||
`.po`-derived locale assets loaded through the asset system. See
|
||||
`assets/locale/en_US.po` -- per memory, currently placeholder/test
|
||||
content, not real shipped game text.
|
||||
|
||||
**Key files:** `localemanager.c/h`, `localeinfo.h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Clean, no bugs found --
|
||||
correctly balances `assetEntryLock`/`Unlock` in both `SetLocale` and
|
||||
`Dispose` (unlocks the old entry before adopting the new one). Minor
|
||||
nit: `localeinfo.h` declares `static const localeinfo_t` globals
|
||||
(`LOCALE_EN_US` etc.) directly in the header, so every including TU
|
||||
gets its own duplicate copy -- harmless given how few TUs include it.
|
||||
No `test/locale/` directory; `test/asset/test_assetlocale.c` tests the
|
||||
loader, not `localemanager.c` itself.
|
||||
|
||||
---
|
||||
|
||||
## save
|
||||
|
||||
**Purpose:** Slot-based save data (`SAVE_FILE_COUNT_MAX` fixed slots),
|
||||
yyjson documents persisted with size + CRC32 checksum. `saveLoad`/
|
||||
`saveSave` read/write JSON fresh every call; callers own the returned
|
||||
doc and must free it. Actual file I/O goes through platform stream hooks
|
||||
(`saveplatform_t`, one impl per platform) -- core `save.c` must never
|
||||
touch the filesystem directly. `savesettings.c/h` layers engine settings
|
||||
(display/audio/input/general) on top. Per `CLAUDE.md`: "mature, but
|
||||
undocumented" beyond the one paragraph there -- no tests.
|
||||
|
||||
**Key files:** `save.c/h`, `savefile.h`, `savestream.c/h`,
|
||||
`savesettings.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Clean and convention-compliant
|
||||
-- `save.c` genuinely never touches the filesystem directly (every I/O
|
||||
path goes through `saveXxxPlatform` macros, all three platforms define
|
||||
them), yyjson doc ownership is honored at every call site checked, and
|
||||
CRC32 is validated before parsing with every error path freeing `buf`.
|
||||
One real gap: `saveFileLoad`'s `memoryAllocate(file->size)`
|
||||
(savestream.c:122) allocates straight from an on-disk size field with
|
||||
no sanity bound before the allocation -- a corrupted/tampered save
|
||||
could request an oversized allocation, worth a max-size guard given the
|
||||
"never trust incoming data" principle (arguably applies to save files,
|
||||
not just network packets). Confirmed no `test/save/` exists. `saveDelete`/
|
||||
`saveExists`/`saveGet` are public API not yet called anywhere in the
|
||||
repo -- likely just unconsumed so far, not truly dead.
|
||||
|
||||
---
|
||||
|
||||
## network
|
||||
|
||||
**Purpose:** Connection-state layer only (`networkstate_t`:
|
||||
`DISCONNECTED`/`CONNECTING`/`CONNECTED`/`DISCONNECTING`) plus a one-off
|
||||
HTTP client (`network/http/`: request/header/URL/thread/process split
|
||||
across files). **Not** a multiplayer/replication protocol yet -- that's
|
||||
`ROADMAP.md` items 8-17. Platform-specific connection polling (PSP's
|
||||
`sceNetApctl`, GameCube/Wii's `if_config()`) lives under
|
||||
`src/dusk<platform>/network/`. Has HTTP tests only (`test/network/http/`).
|
||||
|
||||
**Key files:** `network.c/h`, `networkinfo.c/h`, `http/networkhttp.c/h`,
|
||||
`http/networkhttprequest.c/h`, `http/networkhttpthread.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Genuinely stays a thin
|
||||
connection-state layer (grepped for "packet"/"replicat"/"multiplayer"/
|
||||
"udp" -- no hits, no scope creep). The HTTP client correctly validates
|
||||
untrusted server input with `errorThrow` rather than trusting it
|
||||
(malformed status lines, oversized headers, bad `Content-Length`,
|
||||
early-closed connections all throw) -- exactly what CLAUDE.md calls
|
||||
out. `networkhttpthread.c`'s request handoff is actually correct
|
||||
despite request fields being written outside the mutex: the final
|
||||
`state = PENDING` write happens under the same mutex the worker
|
||||
acquires, giving a valid release/acquire handoff (worth a comment, not
|
||||
a bug). One recurring convention violation:
|
||||
`networkhttpprocess.c`/`networkhttpurl.c` use raw stdlib
|
||||
`strlen`/`strchr`/`strstr`/`strncasecmp`/`strcspn` instead of
|
||||
`util/string.h` wrappers in several places -- `util/string.h` currently
|
||||
has no direct substitute for `strchr`/`strstr`/`strcspn`, so this may
|
||||
be a missing-utility gap rather than pure oversight. No leaks found
|
||||
(request/response bodies freed exactly once via
|
||||
`networkHttpRequestReset`, redirect loop frees the intermediate body).
|
||||
Real test coverage exists: `test/network/http/test_networkhttp.c` runs
|
||||
six tests against a fake loopback server (GET/POST/PUT, query encoding,
|
||||
redirects, connection-refused), each asserting zero leaked allocations.
|
||||
|
||||
---
|
||||
|
||||
## display
|
||||
|
||||
**Purpose:** The engine's most mature, most-optimized subsystem --
|
||||
platform-agnostic rendering built on top of `duskgl`/`duskdolphin`.
|
||||
Subfolders: `mesh/` (primitive builders: cube/sphere/capsule/plane/
|
||||
triprism/quad, plus the shared `meshvertex_t`/`mesh_t` API), `shader/`
|
||||
(currently one material type, unlit), `spritebatch/` (the shared
|
||||
`SPRITEBATCH_SPRITES_MAX`-capacity quad batcher everything 2D goes
|
||||
through -- flushes on shader/material change), `texture/` (texture +
|
||||
tileset + palette), `text/` (`font_t`/`FONT_DEFAULT`'s baked-in bitmap
|
||||
glyphs, `textBuildSpriteCache`/`textDrawSpriteCache`'s build-once/
|
||||
translate-per-frame pattern), `screen/` (scan-safe area), `framebuffer/`.
|
||||
`color.csv` + `tools/color/csv/__main__.py` code-generate `color.h` --
|
||||
never hand-edit the generated header.
|
||||
|
||||
**Key files:** `display.c/h`, `mesh/mesh.c/h`, `spritebatch/spritebatch.c/h`,
|
||||
`spritebatch/spritebatchsprite.c/h`, `text/font.c/h`, `text/text.c/h`,
|
||||
`texture/texture.c/h`, `texture/tileset.c/h`, `shader/shader.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. "Most mature" oversold it
|
||||
slightly -- real bugs found, several confirmed by direct read:
|
||||
- **texture.c:42-52 (confirmed)** -- `textureInit()` branches on
|
||||
`texture->format` to validate the incoming `data` union *before*
|
||||
`memoryZero(texture, ...)` runs a few lines later, so it's reading
|
||||
whatever stale/uninitialized value was already in the caller's
|
||||
struct, not the `format` parameter actually being requested.
|
||||
Validation is effectively checking garbage.
|
||||
- `spritebatchsprite.c:20-25` -- `spriteBatchSpriteTilesetPosition`'s
|
||||
zero-width/height early return zeroes `sprite.min`/`max` but leaves
|
||||
`uvMin`/`uvMax` uninitialized (stack garbage); the existing
|
||||
zero-size test doesn't check uv fields, so this isn't caught.
|
||||
- `mesh.c:43` -- `meshFlush`'s offset assert (`vertexOffset <
|
||||
vertCount - 1`) wrongly rejects a valid offset at the very last
|
||||
vertex; off-by-one.
|
||||
- `text.c:34-37` -- bounds assert uses `tileIndex <= tileCount`
|
||||
(should be `<`), currently unreachable in practice since a preceding
|
||||
clamp already guards it, but the assert itself is wrong.
|
||||
- `framebuffer.c:23-28` -- `frameBufferBind(NULL)` recurses into
|
||||
binding the backbuffer without `errorChain`, discarding any failure
|
||||
and unconditionally returning `errorOk()`.
|
||||
- `displayInit()`'s six static "SIMPLE" primitive meshes
|
||||
(quad/cube/sphere/plane/capsule/triprism) are never disposed in
|
||||
`displayDispose()` -- one VBO+VAO leaked per primitive on the
|
||||
non-legacy GL path.
|
||||
- **Confirmed and fixed 2026-08-01.** `tools/color.py` (not the
|
||||
`tools/color/csv/__main__.py` path CLAUDE.md's doc comment names --
|
||||
that's a stale path, only a `__pycache__/` remnant exists there; the
|
||||
live script is `tools/color.py`) emitted invalid C float literals
|
||||
for whole-number CSV channels (`0`, `1` -- every black/white/primary
|
||||
color), e.g. `color3f(0f, 0f, 0f)`. Was harmless *only* because
|
||||
`COLOR_*_3F`/`_4F` were never referenced anywhere in `src/`/`test/`
|
||||
-- confirmed by actually running the generator before and after the
|
||||
fix, not just reading the source. Now routes channel values through
|
||||
Python `float()` first (always stringifies with a decimal point);
|
||||
`test/display/test_color.c` gained tests referencing
|
||||
`COLOR_BLACK_3F`/`COLOR_WHITE_4F`/`COLOR_RED_3B`/`COLOR_GRAY_3F`
|
||||
directly so the previously-dead macros now have real compile-time +
|
||||
value coverage.
|
||||
- Style drift: `mesh.h` uses `//` header comments instead of `/** */`;
|
||||
sphere/capsule/triprism use `/* */` block comments inside function
|
||||
bodies where CLAUDE.md mandates `//`; every primitive builder's
|
||||
JSDoc still documents a `@param color` that no longer exists in the
|
||||
signature (leftover from before color moved out of `meshvertex_t`).
|
||||
- The primitive `*Buffer()` flexible-geometry functions (sphereBuffer,
|
||||
capsuleBuffer, etc.) are confirmed unused outside their own
|
||||
`*Init()` -- only the fixed default mesh instances are actually
|
||||
consumed (via `script/module/display/modulemesh.c`).
|
||||
|
||||
Test coverage: `test/display/` only registers `test_color.c` and
|
||||
`test_spritebatchsprite.c` -- mesh/shader/texture/tileset/palette/text/
|
||||
font/screen/framebuffer all have zero unit tests.
|
||||
|
||||
---
|
||||
|
||||
## ui
|
||||
|
||||
**Purpose:** Flat, static, singleton-driven widget framework -- no scene
|
||||
graph. Top level is a compile-time X-macro registry
|
||||
(`uielementlist.h`/`uielement.c/h`) of `{init, update, draw, dispose,
|
||||
order}` tuples (screens: frame/confirm/settings/overlays/console/fps),
|
||||
spliced with `duskrpg`'s own list. Composition below that is `uimenu_t`
|
||||
(`widget/uimenu.c/h`) -- a flat tagged-union array of widgets laid out in
|
||||
a simple row/column grid via the `MENU_*` authoring macros; no nesting.
|
||||
Widgets: `uilabel`/`uiwidgetlabel` (cached glyph sprites, the pattern
|
||||
every other widget's text now uses), `uibutton`, `uicheckbox`,
|
||||
`uislider`, `uidropdown`, `uitab`, `uiscrolling` (empty placeholder).
|
||||
`focus/` is the gamepad/keyboard directional-navigation stack (no mouse/
|
||||
pointer hit-testing exists anywhere). `frame/` is the 9-slice box plus
|
||||
the confirm dialog and settings screens. `overlay/` is fullscreen
|
||||
fades/loading/crop bars. `debug/` is the FPS counter and dev console
|
||||
renderer.
|
||||
|
||||
As of 2026-07-31: slider/tab/frame (`uiFrameDrawCached`)/duskrpg's
|
||||
textbox all cache their sprite geometry instead of rebuilding it every
|
||||
frame; console uses a fixed-size vertex buffer instead of alloc/free.
|
||||
Still open: sprite-batch flushes are keyed on material/color, so
|
||||
per-widget highlight-color changes still force a flush per widget (see
|
||||
`ROADMAP.md` item 5 in the debt backlog). No JS bindings exist for UI
|
||||
yet. `test/ui/`/`test/display/test_spritebatchsprite.c` cover the new
|
||||
caching *logic* only -- nothing exercises real `uiXxxDraw()` calls, since
|
||||
those need a live GL context (`FONT_DEFAULT`/`UI_FRAME` textures) this
|
||||
repo's test binaries don't have.
|
||||
|
||||
**Key files:** `ui.c/h`, `uielement.c/h`, `focus/uifocus.c/h`,
|
||||
`widget/uimenu.c/h`, `widget/uilabel.c/h`, `frame/uiframe.c/h`,
|
||||
`debug/uiconsole.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-07-31 (this session's sprite-caching
|
||||
pass) -- see notes above; per-material flush granularity and hit-testing
|
||||
still open.
|
||||
|
||||
---
|
||||
|
||||
## entity
|
||||
|
||||
**Purpose:** Fixed-size-array ECS. `entitymanager_t` owns
|
||||
`ENTITY_COUNT_MAX` entities, each with up to `ENTITY_COMPONENT_COUNT_MAX`
|
||||
components stored in a tagged union (`componentdata_t` -- flagged in
|
||||
`PSP_OPTIMIZATION_PLAN.md` as the biggest PSP memory-waste candidate,
|
||||
since every entity reserves space for every component type's largest
|
||||
variant). `componentlist.h`'s `X()` macro auto-generates the component
|
||||
enum/union/dispatch table from `entity/component/**`; engine-level
|
||||
`entityprefablist.h` is an empty sentinel (real prefabs live in
|
||||
`duskrpg/entity/entityprefablist.h`, resolved via
|
||||
`entityPrefabResolveAndApply`). No JSON serialize/deserialize (removed by
|
||||
design in favor of C-coded prefabs or JerryScript `Entity`/`Component`).
|
||||
|
||||
**Key files:** `entitymanager.c/h`, `entity.c/h`, `component.c/h`,
|
||||
`componentlist.h`, `entityprefab.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Structurally sound (sentinel
|
||||
prefab list, X-macro shape both match CLAUDE.md), but there's a real
|
||||
security-relevant gap: **every capacity/bounds guard
|
||||
(`entitymanager.c:35`, `entity.c:56`, `component.c:37-39,58-62,75-76,
|
||||
140-141`) is an `assert*` call**, and `assert.h` compiles these to
|
||||
`((void)0)` under `DUSK_ASSERTIONS_FAKED` (auto-set whenever `NDEBUG`
|
||||
is defined -- confirmed by reading assert.h directly, this is a real
|
||||
release-mode no-op, not a hypothetical). `moduleentity.c:30,67` create
|
||||
entities/components from JerryScript with no return-value check, so a
|
||||
script that exceeds `ENTITY_COUNT_MAX`/the per-entity component cap
|
||||
silently gets back an invalid ID in release builds, which then indexes
|
||||
fixed arrays out-of-bounds later. This directly contradicts the
|
||||
project's own "untrusted-data limits need errorThrow, not asserts"
|
||||
guidance -- and script input is about as untrusted as it gets. Minor:
|
||||
`component.h:100`/`component.c:52` misplace the `*` in `void *
|
||||
componentGetData(`. Dead code: `entityUpdateRemove`/
|
||||
`entityDisposeRemove`/`entityDisposeAdd` have zero call sites.
|
||||
|
||||
---
|
||||
|
||||
## scene
|
||||
|
||||
**Purpose:** Same shape as `entity/` one level up -- `SCENE_COUNT_MAX`
|
||||
fixed-count scenes, each an isolated entity-manager pool; engine-level
|
||||
`sceneprefablist.h` is an empty sentinel (real prefabs in
|
||||
`duskrpg/scene/`). As of the `Scene.set()` work (2026-07-31), JerryScript
|
||||
can install a `{init, update, dispose}` module as the active scene via
|
||||
`modulescene.c`'s `moduleSceneSetStatic`/`moduleSceneUpdateCurrent`/
|
||||
`moduleSceneTeardownCurrent` (see `script` below) -- scene IDs are reused
|
||||
from a small pool immediately after `sceneDestroy`, so don't assume a
|
||||
freshly created scene has a "new" ID.
|
||||
|
||||
**Key files:** `scene.c/h`, `scenebase.h`, `sceneprefab.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Shares entity/'s assert-not-
|
||||
errorThrow bounds gap (`sceneCreate` hits `assertUnreachable` then
|
||||
falls through to `SCENE_ID_INVALID` on pool exhaustion in release --
|
||||
reachable from `new Scene()` in JS). Additional finding:
|
||||
`scenePrefabResolveAndApply`/`scenePrefabInit` (sceneprefab.c:31-54)
|
||||
recurse through a prefab's `extends` chain with **no cycle guard** -- a
|
||||
self-referencing or A→B→A prefab table causes unbounded recursion/
|
||||
stack overflow from what's otherwise a one-typo mistake in a
|
||||
hand-authored prefab list. No dead code, no use-after-free. Tests cover
|
||||
create/destroy/isolation/update cadence well but have zero coverage for
|
||||
pool exhaustion, `sceneRender()`, or `sceneprefab.c` (no prefab test
|
||||
file exists at all).
|
||||
|
||||
---
|
||||
|
||||
## physics
|
||||
|
||||
**Purpose:** `physicsworld_t` step simulation over `physicsbodytype_t`
|
||||
(static/dynamic/kinematic) and `physicshapetype_t` (cube/sphere/capsule/
|
||||
plane/custom, plus mesh shapes via `physicsshapemesh.c/h`).
|
||||
`triggersystem.c/h` layers trigger-volume enter/exit events on top. Per
|
||||
`STATUS.md`: "mature, recently churned" -- a revert/re-disable of "old
|
||||
ent code" suggests component wiring isn't fully settled yet. Well
|
||||
tested (`test/physics/`).
|
||||
|
||||
**Key files:** `physicsworld.c/h`, `physicsbodytype.h`, `physicsshape.h`,
|
||||
`physicsshapemesh.c/h`, `triggersystem.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. `STATUS.md`'s "component
|
||||
wiring isn't fully settled" caveat looks stale on closer look:
|
||||
`physicsWorldStep`/`triggerSystemStep` are live-wired in `scene.c:89-90`
|
||||
and `duskrpg`'s player prefab actively uses both PHYSICS and TRIGGER --
|
||||
no `#if 0`/TODO/commented-out code found; the disable-then-revert in
|
||||
history reads like a same-day whole-ECS revert, not something
|
||||
physics-specific. Two real bugs found instead:
|
||||
`physicsbodytype.h:23-27` documents kinematic bodies as capable of
|
||||
being a player controller, but `physicsworld.c:124,166-167` only ever
|
||||
writes `onGround` for the dynamic side of a collision pair -- a
|
||||
kinematic controller's `onGround` is permanently false (latent today,
|
||||
since the one existing player prefab uses DYNAMIC, not KINEMATIC).
|
||||
`physicstest.c:213`'s barycentric interior-test branch has no guard
|
||||
against a zero-area triangle (divide-by-zero/NaN risk in mesh-sphere
|
||||
collision). Dead code: `physicsShapeMeshCreate` (mesh-terrain
|
||||
collision) has zero production callers despite being well-tested in
|
||||
isolation; `entityPhysicsGetShape`/`GetCollideMask` are unwired to the
|
||||
JS module. Test depth is genuinely strong for what's implemented
|
||||
(gravity convergence, dynamic-vs-dynamic separation, mask filtering,
|
||||
mesh sphere/capsule resting) -- "well tested" is earned for the
|
||||
features that exist, though the unused mesh-collision path and
|
||||
missing trigger→JS bridge mean the subsystem's *integration* maturity
|
||||
is a bit narrower than its test depth suggests.
|
||||
|
||||
---
|
||||
|
||||
## animation
|
||||
|
||||
**Purpose:** Keyframe + easing only -- no blend trees or state machines
|
||||
yet. `keyframe_t`/`keyframeset_t` interpolate values over time via
|
||||
`easing.c/h`'s easing functions; `animation.c/h` ties a keyframe set to
|
||||
playback state. Per `STATUS.md`: terse commit history ("ANIM") suggests
|
||||
still iterating.
|
||||
|
||||
**Key files:** `animation.c/h`, `easing.c/h`, `keyframe.c/h`,
|
||||
`keyframeset.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. All 8 core files use `//`
|
||||
copyright headers instead of the required `/** */` block, inconsistent
|
||||
with the correctly-formatted `entityanimation.{c,h}` next door. Math
|
||||
checks out: all 16 easing functions satisfy f(0)=0/f(1)=1 with no
|
||||
precision issues, and keyframe interpolation clamps (doesn't
|
||||
extrapolate) outside the time range with no reachable divide-by-zero
|
||||
-- though it doesn't validate that keyframe times are ascending. 15 of
|
||||
16 `easing.h` declarations lack Javadoc. Biggest gap: **no
|
||||
`test_easing.c` exists** -- only linear/in-quad get incidental coverage
|
||||
via `test_keyframe.c`, so 13 of 16 easing functions are completely
|
||||
untested. Dead code (test-only, unused by any `duskrpg` game code):
|
||||
`keyframeSetGetValues`, `keyframeSetFireCallback`, `animationSetEvent`,
|
||||
`entityAnimationSetKeyframes`/`GetValue`.
|
||||
|
||||
---
|
||||
|
||||
## script
|
||||
|
||||
**Purpose:** Embedded JerryScript integration. `scriptmanager.c/h` owns
|
||||
the JerryScript context lifecycle and the exec/call helpers
|
||||
(`scriptManagerExec`/`ExecFile`/`CallGlobal`/`CallValue`, the last two
|
||||
sharing exception + promise-draining logic). `scriptproto.c/h` is the
|
||||
`scriptproto_t` prototype-wrapping helper every module builds on.
|
||||
`module/` holds each registered JS-facing module:
|
||||
- `modulebase.h` -- the shared macro toolkit (`moduleBaseFunction`,
|
||||
`moduleBaseRequireArgs`/`RequireString`/etc., `moduleBaseDefineProp`/
|
||||
`Func`/`StaticFunc`, `moduleBaseSetWrappedPointer`, `moduleBaseThrowError`).
|
||||
- `moduleplatform.h`, `entity/moduleentity.c/h` +
|
||||
`entity/component/*` (generic `Component` plus typed Position/Physics/
|
||||
Renderable wrappers via `modulecomponentlist.c/h`), `scene/modulescene.c/h`
|
||||
(`Scene`, including `Scene.set()`), `display/modulemesh.c/h`,
|
||||
`time/moduletime.c/h`, `require/modulerequire.c/h` (CommonJS-style
|
||||
`require()`/`module.exports`, with a directory stack for relative
|
||||
resolution -- `scriptManagerExecFile` now pushes its own file's
|
||||
directory before exec so a top-level entry script's relative
|
||||
`require()` calls resolve correctly).
|
||||
- `modulelist.c/h` -- registers/disposes every module above in order;
|
||||
`moduleListDispose()` is now actually called from
|
||||
`scriptManagerDispose()` (was a latent bug -- it wasn't, until
|
||||
2026-07-31).
|
||||
|
||||
Entry point convention: `assets/scripts/init.js` does
|
||||
`require('./scenefile.js')` + `Scene.set(module)`. Has unit tests
|
||||
(`test/script/`), including one that reads the real shipped
|
||||
`overworldscene.js` off disk rather than an embedded copy.
|
||||
|
||||
**Key files:** `scriptmanager.c/h`, `scriptproto.c/h`,
|
||||
`module/modulebase.h`, `module/modulelist.c/h`,
|
||||
`module/require/modulerequire.c/h`, `module/scene/modulescene.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-07-31 (this session's Scene.set() +
|
||||
require()-dir-stack work) -- see notes above.
|
||||
|
||||
---
|
||||
|
||||
## input
|
||||
|
||||
**Purpose:** Bind-based input (`inputaction.c/h` maps
|
||||
`INPUT_BIND_*` names to raw `inputbutton_t`s per platform), with a
|
||||
configurable analog deadzone. Platform button/axis backends live under
|
||||
`src/dusk<platform>/input/`; capability macros (`DUSK_INPUT_GAMEPAD`/
|
||||
`KEYBOARD`/`POINTER`) gate what's available per target. Pointer axis
|
||||
values are plumbed through (`DUSK_INPUT_POINTER`), but nothing in `ui/`
|
||||
consumes them yet -- no hit-testing exists (see `ui` above).
|
||||
|
||||
**Key files:** `input.c/h`, `inputaction.c/h`, `inputbutton.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Clean and convention-compliant;
|
||||
deadzone math (`inputDeadzone`, input.c:244) is correct and safe. One
|
||||
naming nit: `inputBind`'s param is named `act` in input.h:216 while its
|
||||
own doc comment calls it `action`. **Confirmed, not just cited from
|
||||
STATUS.md**: pointer input is genuinely wired end-to-end, not a
|
||||
dead-end at the platform layer -- SDL2's `inputUpdateSDL2` populates
|
||||
`INPUT.platform.mouseX/Y`/scroll from `SDL_GetMouseState`,
|
||||
`inputButtonGetValueSDL2` handles the pointer-axis cases, and
|
||||
`duskrpg/input/inputbindmap.h` binds `mouse_x`/`mouse_y` to
|
||||
`INPUT_BIND_POINTERX/Y`. The dead end is specifically at the
|
||||
*consumption* layer: grepping `src/dusk/ui/` for those binds or any
|
||||
pointer-axis read turns up nothing -- exactly as STATUS.md says, just
|
||||
now confirmed rather than assumed. No tests exist for `input/`.
|
||||
|
||||
---
|
||||
|
||||
## engine
|
||||
|
||||
**Purpose:** Top-level init/update/dispose orchestration
|
||||
(`engineInit`/`engineUpdate`/`engineDispose`) -- the thing `main.c` calls,
|
||||
which in turn calls into every subsystem above plus `game/gameInit` etc.
|
||||
`ENGINE.running`/`ENGINE.version` are the only bits of global state
|
||||
outside the per-subsystem structs.
|
||||
|
||||
**Key files:** `engine.c/h`.
|
||||
|
||||
**Review status:** Reviewed 2026-08-01, confirmed by direct read of
|
||||
`engineInit`/`engineDispose`. Init order is safe (time -> console ->
|
||||
system -> input -> asset -> scriptManager -> save/saveSettings ->
|
||||
locale -> display -> ui -> network -> scene -> game), with display
|
||||
before UI and scriptManager before scene/game so JS `Entity`/`Scene`
|
||||
wrappers exist before any script runs. `engineDispose` is **not** a
|
||||
clean reverse of that order, though: `localeManagerDispose()` runs
|
||||
*before* `uiDispose()`/`displayDispose()`, even though locale was
|
||||
initialized *before* both -- correct reverse order would tear down
|
||||
UI/display first, and as written there's a real risk of UI/display
|
||||
teardown code touching already-freed locale data (e.g. any localized
|
||||
string still held/logged during dispose). Separately, `void
|
||||
engineExit(void)` (engine.c:95) is defined but **not declared in
|
||||
engine.h**, and a repo-wide grep finds no caller anywhere -- dead,
|
||||
unreachable, and in violation of the "every public function must be
|
||||
declared in its header" rule. No tests exist for `engine/`.
|
||||
|
||||
---
|
||||
|
||||
## game
|
||||
|
||||
**Purpose:** Intentionally header-only in core -- just declares
|
||||
`gameInit`/`gameUpdate`/`gameDispose`. The real implementation is
|
||||
`src/duskrpg/game/game.c` (currently: runs `scripts/init.js` on init).
|
||||
|
||||
**Key files:** `game.h` (no `.c` in core).
|
||||
|
||||
**Review status:** Reviewed 2026-08-01. Confirmed: no `.c` file exists
|
||||
in `src/dusk/game/` (only `game.h` + `CMakeLists.txt`) -- a pure
|
||||
interface, as intended. The real `src/duskrpg/game/game.c`
|
||||
implementation is a thin JS-bootstrap shim: `gameInit` just does
|
||||
`errorChain(scriptManagerExecFile("scripts/init.js", NULL))`, while
|
||||
`gameUpdate`/`gameDispose` are both empty (`errorOk()` only). Not a
|
||||
bug -- consistent with the ongoing migration of game logic into JS --
|
||||
but worth flagging as "not yet fleshed out" rather than broken if
|
||||
anything is expected to happen there per-frame at the C layer. No
|
||||
tests exist for this layer.
|
||||
@@ -4,36 +4,6 @@ on:
|
||||
tags:
|
||||
- '*'
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,9 @@
|
||||
# Dusk — Claude Code rules
|
||||
|
||||
See `STATUS.md` for a periodically-refreshed inventory of subsystem
|
||||
maturity, test coverage gaps, and known open issues — check it before
|
||||
assuming a subsystem is fully wired up or before picking a next task.
|
||||
|
||||
## File headers
|
||||
Every C, H, and JS file starts with:
|
||||
|
||||
@@ -124,18 +128,16 @@ Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a 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/duskgl/ OpenGL abstraction (Linux, Knulli, PSP)
|
||||
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP)
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -190,21 +192,110 @@ simply left undefined — the core guards calls with `#ifdef`.
|
||||
## Adding a new entity component
|
||||
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
|
||||
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
|
||||
`entityMyCompDispose()`.
|
||||
2. Add the include to `src/dusk/entity/componentlist.h` header block.
|
||||
3. Add a row to `src/dusk/entity/componentlist.h`:
|
||||
`entityMyCompDispose()`, `entityMyCompRender()`.
|
||||
2. Add the include to `src/dusk/entity/componentlist.h` header block
|
||||
(or `src/duskrpg/entity/gamecomponentlist.h` for a game-specific
|
||||
component, appended after the engine's inbuilt ones).
|
||||
3. Add a row:
|
||||
```c
|
||||
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
|
||||
```
|
||||
This auto-generates the enum, union field, and definition entry.
|
||||
Params are `(enumName, type, field, init, dispose, render)` — pass
|
||||
`NULL` for any callback the component doesn't need. This
|
||||
auto-generates the enum, union field, and definition entry.
|
||||
4. If JS-facing, create the script module and `.d.ts` (see below).
|
||||
|
||||
Entities/components/scenes have no JSON serialize/deserialize path —
|
||||
that was removed in favor of building scenes from C-coded prefabs
|
||||
(below) or from JerryScript (`Entity`/`Component`/`Scene`, see
|
||||
"Adding a new script (JS) module").
|
||||
|
||||
---
|
||||
|
||||
## Adding a new entity/scene prefab
|
||||
Entity prefabs (`src/dusk/entity/entityprefab.h`) and scene prefabs
|
||||
(`src/dusk/scene/sceneprefab.h`) follow the same pattern:
|
||||
1. Write an apply function: `errorret_t entityPrefabXxxApply(mgr,
|
||||
entityId)` (or `errorret_t scenePrefabXxxApply(sceneId)`), building
|
||||
up the entity/scene with the normal component/entity APIs.
|
||||
2. Add an entry to the sentinel-terminated `ENTITY_PREFABS[]` (in
|
||||
`src/dusk/entity/entityprefablist.h`, or a game-specific list it
|
||||
includes) or `SCENE_PREFABS[]`:
|
||||
```c
|
||||
{ .name = "MY_PREFAB", .extends = "", .apply = entityPrefabXxxApply }
|
||||
```
|
||||
`extends` names another prefab to apply first (recurses through
|
||||
`entityPrefabResolveAndApply`/`scenePrefabResolveAndApply`), or `""`
|
||||
for none. Do not add an enum or count field — the array is iterated
|
||||
until `.name[0] == '\0'`.
|
||||
3. `entityPrefabResolveAndApply`/`scenePrefabResolveAndApply` only
|
||||
resolve names against the C-coded registry above — there is no JSON
|
||||
asset fallback. Throws if no prefab with that name is registered.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new cutscene item type
|
||||
1. Create `src/duskrpg/cutscene/item/<category>/cutsceneMyItem.h/.c`
|
||||
with a data struct (e.g. `cutscenemyitem_t`) and
|
||||
`cutsceneMyItemStart(item, data)` / `cutsceneMyItemUpdate(item,
|
||||
data)` (the latter returns `true` once the item has completed). Add
|
||||
a matching `cutscenemyitemdata_t` runtime-data struct only if the
|
||||
item needs per-run state across ticks (most don't).
|
||||
2. Add `CUTSCENE_ITEM_TYPE_MY_ITEM` to the enum and a union member to
|
||||
`cutsceneitem_t` (and `cutsceneitemdata_t` if it has runtime data) in
|
||||
`src/duskrpg/cutscene/item/cutsceneitem.h`.
|
||||
3. Register the `{ start, update }` pair in `CUTSCENE_ITEM_CALLBACKS[]`
|
||||
in `cutsceneitem.c`.
|
||||
4. Add an authoring macro to `src/duskrpg/cutscene/cutscene.h`:
|
||||
```c
|
||||
#define CUTSCENE_MY_ITEM(ARGS...) \
|
||||
{ .type = CUTSCENE_ITEM_TYPE_MY_ITEM, .myItem = { ARGS } }
|
||||
```
|
||||
used inside a `CUTSCENE(NAME, SIZE, PAUSE_TYPE, ...)` block.
|
||||
|
||||
---
|
||||
|
||||
## Save system
|
||||
Save data lives under `src/dusk/save/` (`save.h`/`savefile.h`/
|
||||
`saveplatform.h`). Slots are fixed-count (`SAVE_FILE_COUNT_MAX`), each
|
||||
holding a yyjson document persisted with its byte size and a CRC32
|
||||
checksum. `saveLoad`/`saveSave` read/write the JSON fresh every call —
|
||||
callers own the returned/passed `yyjson_doc`/`yyjson_mut_doc` and must
|
||||
free it themselves. Actual file I/O goes through platform-specific
|
||||
`saveplatform_t`/stream hooks (one implementation per platform under
|
||||
`src/dusk<platform>/save/`) — do not add direct filesystem calls to the
|
||||
core `save.c`, extend the platform stream hooks instead.
|
||||
|
||||
## Network system
|
||||
`src/dusk/network/` (`network.h`) is a connection-state layer only — a
|
||||
`networkstate_t` state machine (`DISCONNECTED`/`CONNECTING`/`CONNECTED`/
|
||||
`DISCONNECTING`) plus an HTTP client (`network/http/`) used for one-off
|
||||
requests. It is not a multiplayer/replication protocol — that layer
|
||||
doesn't exist yet (see `ROADMAP.md` items on the socket server/client
|
||||
and packet handlers). Platform-specific connection logic (e.g. PSP's
|
||||
`sceNetApctl` polling, GameCube/Wii's `if_config()`) lives under
|
||||
`src/dusk<platform>/network/`, wired through `networkplatform.h` macros
|
||||
the same way display/input are. Whenever this eventually grows a
|
||||
multiplayer protocol, apply `ROADMAP.md`'s principle: never trust
|
||||
incoming packet data — validate defensively with `errorret_t`/
|
||||
`errorThrow()`, not asserts.
|
||||
|
||||
## Adding a new script (JS) module
|
||||
Dusk embeds JerryScript (`src/dusk/script/`, fetched via
|
||||
`cmake/modules/Findjerryscript.cmake`). Today only `Entity`, the
|
||||
generic `Component` wrapper, and `Scene` are registered (see
|
||||
`src/dusk/script/module/modulelist.c`) — no per-component-type typed
|
||||
wrappers exist yet (e.g. no `.position` on a `POSITION` component);
|
||||
`entity.add(TYPE)`/`entity.getComponent(TYPE)` always return the
|
||||
generic `Component`.
|
||||
|
||||
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
|
||||
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
|
||||
- Use `moduleBaseFunction(name)` to define JS-callable functions.
|
||||
- Use `moduleBaseFunction(name)` to define JS-callable functions —
|
||||
these are the one exception to "no `static` in `.c` files": the
|
||||
macro itself expands to a `static jerry_value_t name(...)`
|
||||
JerryScript external-handler trampoline, never called by name
|
||||
from other C files, so it isn't declared in the `.h`.
|
||||
- Register props/funcs in `moduleMyModInit()` with
|
||||
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
|
||||
`scriptProtoDefineStaticFunc`.
|
||||
@@ -212,9 +303,12 @@ simply left undefined — the core guards calls with `#ifdef`.
|
||||
`src/dusk/script/module/modulelist.c` and call
|
||||
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
|
||||
`moduleListDispose()`).
|
||||
3. For component modules also register in
|
||||
`src/dusk/script/module/entity/component/modulecomponentlist.c`
|
||||
so `entity.add()` returns the typed wrapper.
|
||||
3. For a component module that adds a *typed* wrapper for a specific
|
||||
component type, create
|
||||
`src/dusk/script/module/entity/component/modulecomponentlist.c` (it
|
||||
doesn't exist yet — the first such module creates it) so
|
||||
`entity.add()` can return the typed wrapper instead of the generic
|
||||
`Component`.
|
||||
4. Create `types/<category>/mymod.d.ts` and add a
|
||||
`/// <reference path="..." />` line to `types/index.d.ts`.
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# PSP Optimization Plan
|
||||
|
||||
A survey of concrete memory and CPU (especially floating-point) optimization
|
||||
opportunities for the PSP target, done 2026-07-31 on branch `ac2` at commit
|
||||
`e61914ba` + this session's scripting work. Point-in-time findings, not a
|
||||
substitute for reading the referenced source before acting on it.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The PSP (Allegrex MIPS CPU, 222-333MHz, single core, 32-64MB main RAM, 2MB
|
||||
eDRAM, 16KB I-cache / 16KB D-cache, no virtual memory or memory protection)
|
||||
is the tightest-constrained target this engine ships to. Two rules guide
|
||||
everything below, both already correctly applied in one place in this
|
||||
codebase (`entityposition_t` caching its transform matrices instead of
|
||||
recomputing them from position/rotation/scale every read — see "Already
|
||||
correct" below):
|
||||
|
||||
- **Memory is finite and cannot be compacted.** No VM means a fragmented
|
||||
heap after a few minutes of play can fail an allocation even with
|
||||
"enough" total free bytes. Prefer static/pool allocation over
|
||||
malloc/free churn; prefer trading memory for CPU only when the memory
|
||||
cost is bounded and paid once.
|
||||
- **CPU is finite and floating-point math is not free**, especially
|
||||
trig/sqrt on the plain scalar FPU. Prefer caching a computed result
|
||||
behind a dirty flag over recomputing it unconditionally; prefer
|
||||
avoiding a sqrt via squared-distance comparison wherever only a
|
||||
yes/no or ordering result is needed.
|
||||
|
||||
## Priority 1 — Entity/component memory: a union tax paid on every slot, times 4 scenes
|
||||
|
||||
**The single biggest finding.** `entitymanager_t` (`src/dusk/entity/entitymanager.h:11-15`)
|
||||
is a flat, statically-sized struct:
|
||||
|
||||
```c
|
||||
typedef struct entitymanager_t {
|
||||
entity_t entities[ENTITY_COUNT_MAX]; // 64
|
||||
component_t components[ENTITY_COUNT_MAX * ENTITY_COMPONENT_COUNT_MAX]; // 64*16 = 1024
|
||||
componentid_t entitiesWithComponent[COMPONENT_TYPE_COUNT * ENTITY_COUNT_MAX];
|
||||
} entitymanager_t;
|
||||
```
|
||||
|
||||
`component_t` (`src/dusk/entity/component.h:69-72`) holds a **tagged union**
|
||||
(`componentdata_t`) sized to its largest variant. That variant is
|
||||
`entityrenderable_t`'s spritebatch payload (`entityrenderable.h:25-66`):
|
||||
`spritebatchsprite_t sprites[64]` at 40 bytes each (`vec3 min + vec3 max +
|
||||
vec2 uvMin + vec2 uvMax`) = **2560 bytes**, versus ~28-300 bytes for every
|
||||
other component type (camera, physics, animation, position, trigger).
|
||||
|
||||
Because the union is embedded by value in a flat array — not behind a
|
||||
pointer, not sparse — **every one of the 1024 component slots in every
|
||||
entity manager costs ~2580 bytes, whether it holds a 28-byte camera or
|
||||
nothing at all.** `1024 * 2580 ≈ 2.52MB` per `entitymanager_t`, confirmed
|
||||
by the engine's own startup log every run: `Entity manager size: 2684992
|
||||
bytes (2622.06 KB)`.
|
||||
|
||||
`scene_t` (`src/dusk/scene/scene.h:14-18`) embeds `entitymanager_t` by
|
||||
value too, and `SCENE_COUNT_MAX = 4` (`scene/scenebase.h:11`) with
|
||||
`SCENE_MANAGER` declared as a plain global (`scene/scene.c:20`) — so this
|
||||
~2.52MB exists as static BSS from process start for **all 4 scene slots**,
|
||||
used or not. **Total: ~10.1MB reserved permanently for entity storage
|
||||
alone** — 16-31% of PSP main RAM — before a single texture, mesh, or audio
|
||||
asset is loaded.
|
||||
|
||||
**Options to fix (roughly increasing effort/risk):**
|
||||
1. Pull `entityrenderablespritebatch_t` out of the component union entirely
|
||||
— store spritebatch entities via a pointer/handle into a separate,
|
||||
smaller fixed pool sized to how many spritebatch-renderable entities
|
||||
actually coexist (almost certainly far fewer than 64 per entity, and
|
||||
far fewer entities need spritebatch at all vs. mesh/material
|
||||
rendering). This alone would shrink the union's dominant variant from
|
||||
~2560 bytes to whatever the next-largest variant is (~300 bytes,
|
||||
`entitytrigger_t`) — an ~88% reduction, dropping the ~10.1MB down to
|
||||
roughly ~1.2MB.
|
||||
2. Reduce `SCENE_COUNT_MAX` from 4 if 4 concurrent scenes were never a
|
||||
deliberate requirement (check with the project owner — this may just
|
||||
be a round-number default nobody revisited), or make scene storage
|
||||
pointer-based/lazily allocated so unused scene slots cost ~0 instead
|
||||
of a full `entitymanager_t`.
|
||||
3. Reduce `ENTITY_COMPONENT_COUNT_MAX` (16) if entities realistically use
|
||||
far fewer distinct component types simultaneously — check actual
|
||||
usage across `entityprefablist.h`/`gameprefablist.h` prefabs.
|
||||
|
||||
Do (1) first — it's the highest-leverage, most contained change (touches
|
||||
`entityrenderable.h`'s data layout and its render/dispose paths, not the
|
||||
general entity/component system), and re-measure via the same startup
|
||||
log line before deciding whether (2)/(3) are still worth doing.
|
||||
|
||||
## Priority 2 — VFPU is completely unused; all vec/mat math is scalar
|
||||
|
||||
The PSP's Allegrex CPU has a **VFPU** (vector floating-point unit) capable
|
||||
of fast SIMD-style 4-wide float ops and hardware-accelerated matrix
|
||||
operations, exposed by pspsdk's `pspvfpu`/GU "Geometry Utility" (`gum_*`)
|
||||
helpers. This engine links `pspvfpu` (`cmake/targets/psp.cmake`) but
|
||||
**never calls into it** — confirmed via a zero-hit grep for
|
||||
`vfpu|pspmath|gum_|vcst|gu_matrix` across `src/duskpsp/` and `src/duskgl/`.
|
||||
|
||||
All vector/matrix math instead goes through cglm (`glm_vec3_*`,
|
||||
`glm_mat4_*`), which auto-detects SIMD only for x86 (`CGLM_SSE2`/`AVX`) or
|
||||
ARM (`CGLM_NEON`) — neither applies to MIPS, and no `CGLM_*` macros are
|
||||
set anywhere in this repo (confirmed zero hits). **Every `glm_mat4_mul`,
|
||||
every position/rotation rebuild, every physics vector op runs on the
|
||||
plain scalar MIPS FPU with the VFPU sitting idle.**
|
||||
|
||||
This is the single largest *available* CPU win identified in this survey
|
||||
— larger than any specific hot-path fix below, because it's a multiplier
|
||||
on all of them. Concretely: route `entityposition.c`'s matrix
|
||||
rebuild/multiply path and `physicsworld.c`'s per-body vector math through
|
||||
`pspvfpu`/`gum_*` on the PSP build specifically (behind `#ifdef DUSK_PSP`,
|
||||
matching the project's existing platform-guard convention), while keeping
|
||||
cglm as the portable fallback for Linux/Knulli/GameCube/Wii. This is a
|
||||
genuinely large effort (new platform-specific math backend, careful
|
||||
correctness verification since VFPU has its own quirks around
|
||||
pipelining/hazards) — scope it as its own project, not a quick pass.
|
||||
|
||||
## Priority 3 — UI/spritebatch rebuilds and redraws every vertex, every frame
|
||||
|
||||
Already flagged in `STATUS.md`/`ROADMAP.md` (item 4/5) as a known open
|
||||
issue; this session's research adds concrete numbers. `meshvertex_t`
|
||||
(`display/mesh/meshvertex.h:14-17`) is `{ float uv[2]; float pos[3]; }` =
|
||||
**20 bytes/vertex**, no packing. `SPRITEBATCH_SPRITES_MAX = 512`,
|
||||
`SPRITEBATCH_FLUSH_COUNT = 16` → 32 sprites/flush = 192 vertices = **3,840
|
||||
bytes rebuilt and redrawn per flush**, with a flush forced on every
|
||||
shader/material change (`spritebatch.c:38-50`) and used unconditionally
|
||||
by every widget except `uiconsole.c` (`uitab.c:56`, `uislider.c:183/199/231`,
|
||||
final flush in `ui.c:51`). A UI screen with ~50-100 sprites and 2-3
|
||||
material changes costs an estimated **8-15KB of vertex rebuild + GU
|
||||
submission every single frame**, regardless of whether the UI changed
|
||||
since the last frame.
|
||||
|
||||
Confirmed on the PSP legacy-GL path specifically: there's no GPU buffer
|
||||
re-upload cost (`meshFlushGL` is a literal no-op comment: "we use the
|
||||
glClientState stuff" — `meshgl.c:91-93`; `meshDrawGL` just points
|
||||
`glVertexPointer` at the CPU-side array each call), so the real cost is
|
||||
(a) the CPU-side per-sprite vertex rewrite every frame and (b) GU
|
||||
re-transforming the full vertex stream from RAM on every draw with no
|
||||
skip for unchanged geometry.
|
||||
|
||||
**Fix direction** (already captured in `ROADMAP.md`'s debt backlog item 5):
|
||||
extend `uiconsole.c`'s cached-mesh, dirty-flag-gated rebuild pattern to
|
||||
the rest of the widget tree. This plan adds one refinement: also consider
|
||||
packing `meshvertex_t` down (next item) since it multiplies this cost.
|
||||
|
||||
## Priority 4 — All-float vertex format wastes bandwidth and T&L cost
|
||||
|
||||
`meshvertex_t` uses two `float` fields (20 bytes) for every mesh and
|
||||
sprite vertex, mesh-wide, with no normal or per-vertex color (color is
|
||||
already handled at the material level, so that part is already optimal).
|
||||
GU natively supports fixed-point 16-bit positions/UVs
|
||||
(`GU_VERTEX_16BIT`/`GU_TEXTURE_16BIT`), which would roughly halve
|
||||
per-vertex size and the corresponding vertex-fetch/transform cost, at the
|
||||
cost of position precision (fine for UI/sprite work and most world
|
||||
geometry at this engine's scale; worth checking against the largest
|
||||
world coordinates actually used before committing). Pair with Priority 3
|
||||
so the packing benefit compounds with the dirty-tracking benefit instead
|
||||
of just reducing the cost of a rebuild that still happens every frame.
|
||||
|
||||
## Priority 5 — Running gameplay logic in JerryScript has a real per-frame cost on PSP
|
||||
|
||||
This is new since last session's work, not a pre-existing issue: `assets/
|
||||
scripts/overworldscene.js`'s `update()` is now called every frame via
|
||||
`scriptManagerCallGlobal("update")` (wired into `engine.c`'s
|
||||
`engineUpdate()`), replacing what used to be a direct `cosf`/`sinf` C
|
||||
callback (`entityUpdateAdd`). Per call, `scriptManagerCallGlobal`
|
||||
(`scriptmanager.c:91-144`) does: a global-object property lookup (key is
|
||||
cached, but the `jerry_object_get` dispatch still runs), full JS
|
||||
interpreter call dispatch (frame setup, argument marshaling) for what's a
|
||||
two-line function, and an unconditional `jerry_value_is_promise` check
|
||||
every call even though `update()` is synchronous. Inside, `Math.cos`/
|
||||
`Math.sin` run as JS builtin calls rather than direct libm calls.
|
||||
|
||||
Additionally: this project's JerryScript fork is already patched to use
|
||||
32-bit float internally (`JERRY_NUMBER_TYPE_FLOAT64=0`,
|
||||
`Findjerryscript.cmake`) — a deliberate, already-correct optimization for
|
||||
this exact concern — but the *public* engine API (`jerry_value_as_number()`)
|
||||
still returns `double`, so every native↔JS boundary crossing (every
|
||||
`moduleBaseArgFloat`, every `moduleBaseVec3ToObject`) still pays a
|
||||
float→double→float round trip. Also note `JERRY_MATH` is off, so
|
||||
`Math.sin`/`cos` fall through to whatever generic libm the platform
|
||||
provides rather than JerryScript's own fdlibm implementation — worth
|
||||
checking whether turning it on changes anything measurable on PSP.
|
||||
|
||||
**This is one `update()` call for one scene-level script today — cheap in
|
||||
absolute terms.** It becomes a real problem only if the pattern scales:
|
||||
giving many individual entities their own per-frame JS update callback
|
||||
would multiply all of the above per entity. **Recommendation: keep
|
||||
high-frequency, hot per-entity logic (movement, camera math, physics
|
||||
response) in native C update callbacks (`entityUpdateAdd`, the existing
|
||||
mechanism), and reserve JS for one-time setup, infrequent/event-driven
|
||||
logic, and coarse-grained per-scene orchestration** — which is exactly
|
||||
what `require()` and the typed component wrappers built this session are
|
||||
suited for, not a per-entity-per-frame hot path. This is a design
|
||||
guideline to apply going forward, not a regression to fix in the current
|
||||
`overworldscene.js` (one scene-level `update()` call per frame is fine).
|
||||
|
||||
## Priority 6 — No pooling/arena allocator; plain malloc/free everywhere
|
||||
|
||||
`memoryAllocate`/`memoryFree` (`util/memory.c`) are direct `malloc`/`free`
|
||||
passthroughs (`memoryAlign`→`memalign`, `memoryReallocate`/`memoryResize`→
|
||||
`realloc`), with the only extra behavior being a global allocation-count
|
||||
tracker used solely for test leak detection. No pool, arena, free-list,
|
||||
or size-class allocator exists anywhere.
|
||||
|
||||
This survey found **no rogue per-frame heap allocations** in the hot
|
||||
paths checked (render dispatch in `entityrenderable.c`, all of
|
||||
`physics/*.c`) — so this is not an active bug today. But it's a
|
||||
structural risk for a no-VM platform over a long play session: any future
|
||||
code that does frequent small alloc/free (dynamic lists, string
|
||||
building, temp buffers) will fragment the 32-64MB heap with no OS-level
|
||||
recovery mechanism. **Recommendation: before adding any new subsystem
|
||||
that allocates/frees frequently at runtime (not just at load time),
|
||||
default to a fixed-size pool or arena for it**, following the same
|
||||
"static, bounded" philosophy already used for `ENTITY_COUNT_MAX`/
|
||||
`SCENE_COUNT_MAX`/`ASSET_ENTRY_COUNT_MAX` elsewhere in the engine, rather
|
||||
than reaching for `memoryAllocate` per-instance.
|
||||
|
||||
## Already correct — don't touch without new evidence
|
||||
|
||||
- **`entityposition_t`'s matrix caching** (the pattern the project owner
|
||||
called out as the reason for writing this plan). Verified: a full
|
||||
"dirty" recompute chain (decompose + rebuild local + rebuild world) is
|
||||
~10 trig calls (`asinf`/`cosf`/`atan2f`) plus at most one 4x4 matrix
|
||||
multiply, and `entityPositionEnsurePRS`/`EnsureLocal`/`EnsureWorld`
|
||||
(`entityposition.c:596-669`) all early-return on a flag check, only
|
||||
doing that work when something actually changed. The cache is correct
|
||||
and clearly worth its ~128-byte-per-entity matrix storage cost.
|
||||
- **Physics narrow-phase sqrt avoidance.** All three `sqrtf` call sites
|
||||
in `physicstest.c`/`physicsshapemesh.c` already sit behind a
|
||||
squared-distance early-reject and only compute the real (linear)
|
||||
distance once, when actually needed for penetration depth — no further
|
||||
"use squared distance instead" opportunity was found here.
|
||||
- **No allocations in the render/physics hot loop** (Priority 6) — this
|
||||
is good and worth preserving as new code is added to those files.
|
||||
- **Asset decompression already runs off the main thread.** Assets are
|
||||
DEFLATE-compressed (not stored) in `dusk.dsk`, so loading pays a real
|
||||
CPU cost for decompression — but every `*LoaderAsync` function
|
||||
(`asset/loader/*/*.c`) runs via `ASSET.loadThread`
|
||||
(`assertNotMainThread` in `asset.c:365`), so this cost is already kept
|
||||
off the main thread. Low priority to change; if load-time CPU cost
|
||||
becomes a measured problem later, revisit stored-vs-compressed as a
|
||||
build-time flag rather than assuming compression is free.
|
||||
|
||||
## Suggested approach
|
||||
|
||||
1. **Measure before changing.** None of the above have been profiled on
|
||||
real PSP hardware in this pass — this is a source-level survey, not a
|
||||
profile. Before investing in Priority 1 or 2 especially, confirm with
|
||||
an actual PSP build/run (or at minimum the existing "Entity manager
|
||||
size" startup log plus a frame-time counter) that these are the real
|
||||
bottlenecks, not just the largest numbers on paper.
|
||||
2. **Priority 1 first** — it's the most contained (one component's data
|
||||
layout), has the clearest before/after metric (the startup log line),
|
||||
and doesn't require new platform-specific code.
|
||||
3. **Priority 3 next** (extend the console's dirty-tracking pattern to
|
||||
the rest of the UI) — already scoped in `ROADMAP.md`, no new design
|
||||
needed, just implementation.
|
||||
4. **Priority 2 (VFPU) as a dedicated project**, not a quick pass — it's
|
||||
the largest potential win but touches core math plumbing and needs
|
||||
careful correctness verification on real hardware.
|
||||
5. Treat Priority 5 as a standing design guideline for all future
|
||||
scripting work, not a one-time fix.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Dusk Roadmap
|
||||
|
||||
Tracking upcoming milestones for the engine. See `PSP_OPTIMIZATION_PLAN.md`
|
||||
for a memory/CPU optimization survey specifically targeting the PSP build
|
||||
(milestone 4 below is its Priority 3).
|
||||
|
||||
## Upcoming milestones
|
||||
|
||||
1. Add a very basic physics engine, moving away from the current
|
||||
tile-based movement.
|
||||
2. Give entities full freedom of movement (no longer locked to tile
|
||||
grid positions).
|
||||
3. Update entity interaction, triggers, chunk management, and other
|
||||
systems that currently assume tile-based positioning so they work
|
||||
with the new 3D positioning/movement code.
|
||||
4. Investigate and fix poor UI rendering performance. First pass done
|
||||
(2026-07-31): slider/tab/frame/textbox now cache their sprite
|
||||
geometry instead of rebuilding it every frame, and the console's
|
||||
vertex buffer is fixed-size instead of alloc/free churn (see
|
||||
`STATUS.md`'s "UI rendering performance" note for detail). Still
|
||||
open: per-widget material/color changes still force a sprite-batch
|
||||
flush each time (color is per-material, not per-vertex), so a row of
|
||||
alternating highlighted widgets still costs one draw call per
|
||||
widget.
|
||||
5. Create UI elements for displaying status indicators, e.g. network
|
||||
connection state and save-in-progress.
|
||||
6. Fully test saving end-to-end on all supported platforms.
|
||||
7. Remove the tile system from chunks in favor of meshes, with
|
||||
dynamic hitboxes per chunk loaded in from the chunk file data.
|
||||
8. Create UI elements for network status: a connecting modal, an
|
||||
error state, and a connected flag. Retire the test HTTP request
|
||||
once these are in place.
|
||||
9. Build the socket server and client implementation, including
|
||||
handlers for the different packet types.
|
||||
10. Add a dedicated multiplayer entity type, `clientplayer`, alongside
|
||||
the existing `npc` and `player` types. Limit to 16 (defined
|
||||
constant, see SERVER_CLIENT_COUNT_MAX/CLIENT_COUNT_MAX in
|
||||
src/dusk/network/socket/) for now.
|
||||
11. Send and receive `clientplayer` position over the network.
|
||||
12. Create a UI menu for creating a server and joining a server. For
|
||||
now, join IPs are hard-coded (testing against a fixed IP of
|
||||
10.0.0.94).
|
||||
13. Create "handshake" packets. For now, just send the username,
|
||||
enforced to be under 10 characters long.
|
||||
14. Server tracks all players' positions and broadcasts them to all
|
||||
connected clients.
|
||||
15. Server sends disconnect packets for users who leave.
|
||||
16. Server assigns each client a UUID; all clients know every other
|
||||
client's UUID (used to reference them across position updates,
|
||||
disconnect packets, etc).
|
||||
17. Server notifies all clients (by UUID) when a user joins, leaves,
|
||||
or is disconnected, so clients can spawn or remove the
|
||||
corresponding `clientplayer` entity in the world.
|
||||
|
||||
## Infrastructure / debt backlog
|
||||
|
||||
Found during a full-codebase inventory pass (2026-07-30, see `STATUS.md`
|
||||
for the full survey). These aren't new feature milestones so much as
|
||||
loose ends worth closing, roughly in order of how cheap/safe they are to
|
||||
fix:
|
||||
|
||||
1. Re-enable `test/item` (currently commented out in
|
||||
`test/CMakeLists.txt`) -- confirm it still passes and turn it back on.
|
||||
2. Restore `itemgive.c/h` in `src/duskrpg/item/CMakeLists.txt` -- the
|
||||
textbox UI dependency it was waiting on (`ui/textbox/`) is already
|
||||
back.
|
||||
3. Decide the fate of the `vita` target: `scripts/build-vita.sh` and
|
||||
`docker/vita/` reference `-DDUSK_TARGET_SYSTEM=vita`, but no
|
||||
`cmake/targets/vita.cmake` exists, so the build is currently broken.
|
||||
Either implement it or remove the dangling scripts/Dockerfile.
|
||||
4. Per-PR CI build coverage for non-Linux targets: first pass added
|
||||
2026-07-31 as `run-tests-gamecube-dolphin`/`run-tests-wii-dolphin`
|
||||
(build the ISO, boot it in Dolphin under Xvfb) and
|
||||
`run-tests-psp-ppsspp` (build the EBOOT, boot it in PPSSPPHeadless)
|
||||
in `.github/workflows/test.yml`, plus matching
|
||||
`scripts/test-*-dolphin.sh`/`scripts/test-psp-ppsspp.sh` (+ `-docker`
|
||||
variants). Marked `continue-on-error: true` since none of these have
|
||||
actually been exercised on a real runner yet -- verify they pass
|
||||
before relying on them, and drop the flag once they do. Knulli still
|
||||
has no per-PR coverage at all.
|
||||
5. Milestone 4 above (poor UI rendering performance) has a first pass
|
||||
in (2026-07-31, see `STATUS.md`) -- remaining concrete lead: sprite
|
||||
batch flushes are keyed on shader+material (including tint color),
|
||||
so per-widget color changes (e.g. focus highlight) still force a
|
||||
flush per widget; moving tint to per-vertex color would let a whole
|
||||
row of widgets batch into one draw call regardless of highlight
|
||||
state.
|
||||
6. Revisit the GameCube/Wii networking static-IP workaround in
|
||||
`networkdolphin.c` -- it's standing in for an unresolved suspected
|
||||
memory-corruption bug in `if_config()`'s DHCP path.
|
||||
7. Decide whether the PSP dialog-based network connect UI needs to come
|
||||
back -- it was removed entirely (not fixed) when the original
|
||||
dialog-tearing bug proved hard to resolve.
|
||||
8. Backfill unit tests for the biggest untested surfaces: `ui/` (whole
|
||||
widget framework), `save/`, `script/` (JerryScript bindings), `event/`.
|
||||
|
||||
## Principles
|
||||
|
||||
- Never trust the network implicitly. Neither side (server or client)
|
||||
should assume the other's packets are well-formed or benign --
|
||||
validate all incoming packet data defensively, since either side
|
||||
may send garbage or malicious data. Use `errorret_t` /
|
||||
`errorThrow()` for these runtime checks, not assert macros --
|
||||
asserts are debug-only and won't guard release builds against
|
||||
malformed or malicious packet data.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Dusk Engine - Status Snapshot
|
||||
|
||||
This is a point-in-time inventory of the codebase's maturity, test coverage,
|
||||
and known gaps. It is not auto-maintained -- re-survey periodically (or
|
||||
whenever picking a new roadmap milestone) rather than trusting it blindly.
|
||||
See `ROADMAP.md` for the ordered feature milestones this status feeds into,
|
||||
and `CLAUDE.md` for coding conventions.
|
||||
|
||||
Last surveyed: 2026-07-31, at commit `8f8fa8f8`.
|
||||
|
||||
## Core engine (`src/dusk/`)
|
||||
|
||||
| Subsystem | Maturity | Notes |
|
||||
|------------|------------------------------------|-------|
|
||||
| asset | Mature, fully wired | Model loading's "async" path is actually synchronous (`assetmodelloader.h`) -- only real stub found in core. |
|
||||
| script | Mature for its current scope | Registered modules: `modulePlatform`, `moduleComponent`/`moduleComponentList` (typed Position/Physics/Renderable wrappers), `moduleEntity`, `moduleScene` (with `Scene.set()` module lifecycle), `moduleMesh`, `moduleTime`, `moduleRequire` (CommonJS-style `require()`/`module.exports`). Entry point is `scripts/init.js`, which does `require('./overworldscene.js')` + `Scene.set()`. Has unit tests (`test/script/`). |
|
||||
| entity | Mature | Engine-level prefab lists are empty sentinels; all real prefabs live in `duskrpg`. |
|
||||
| scene | Mature | Same prefab-delegation pattern as entity. No JSON serialize/deserialize (removed by design). |
|
||||
| save | Mature, but undocumented | Slot-based, yyjson + CRC32, platform stream hooks. Not covered in `CLAUDE.md`, no tests. |
|
||||
| network | Connection-state layer only | HTTP client + connection state machine; no multiplayer/replication protocol (expected -- that's roadmap items 8-17). Not covered in `CLAUDE.md`, has HTTP tests only. |
|
||||
| physics | Mature, recently churned | Recent revert/re-disable of "old ent code" suggests component wiring around physics isn't fully settled. Well tested. |
|
||||
| animation | Early/mid-stage | Keyframes + easing only, no blend trees or state machines. Terse commit history ("ANIM") suggests still iterating. |
|
||||
| display | Most mature/battle-tested | Backbone of the engine; dominated by platform optimization commits. |
|
||||
| ui | Actively churning, perf pass done | Widget framework has had features added and ripped out repeatedly (story/battle UI added then removed). No JS bindings yet (from-scratch surface if that's picked up). Sprite-cache pass landed for slider/tab/frame/textbox (see roadmap item 4 below); still no scripted pointer/hit-testing. Has unit tests for the new caching logic (`test/ui/`), but nothing exercises actual draw calls -- see below. |
|
||||
| console | Small, finished for its scope | Fixed-size cached mesh (no more alloc/free churn), same pattern the rest of `ui/` now follows. |
|
||||
| event | Clean, small, finished | Pub/sub, rebuilt to replace the old input system. |
|
||||
| game | Intentionally header-only | Real implementation lives in `duskrpg/game/game.c`. |
|
||||
|
||||
### UI rendering performance (roadmap item 4)
|
||||
First pass landed 2026-07-31: `uislider`/`uitab` now cache their non-text
|
||||
quads (track/fill/markers, tab background) relative to origin and only
|
||||
rebuild on state change, translating into position at draw time.
|
||||
`uiframe.c` grew `uiFrameDrawCached()`, which skips rebuilding its 9-slice
|
||||
sprites when x/y/width/height match the last call; wired into the
|
||||
confirm dialog, settings panel, and textbox (each owns its own
|
||||
`uiframecache_t`). `uitextbox` no longer does one `spriteBatchBuffer` call
|
||||
per visible glyph -- it builds a per-page glyph cache once and slices a
|
||||
prefix by scroll each frame. `uiconsole` dropped its alloc/free vertex
|
||||
buffer for a fixed 512-glyph array. `uifps` skips its label rebuild when
|
||||
the formatted FPS string hasn't changed.
|
||||
|
||||
Still open: every widget still issues its own `spriteBatchBuffer` call
|
||||
per material/color, so a row of alternating highlighted/plain widgets
|
||||
still forces a GPU flush per widget (color is applied via material, not
|
||||
per-vertex) -- that's the next real win if PSP framerate is still an
|
||||
issue. No scripted pointer/hit-testing exists either; input is still
|
||||
100% gamepad/keyboard directional-focus (`ui/focus/`).
|
||||
|
||||
Test coverage caveat: `test/ui/` and `test/display/test_spritebatchsprite.c`
|
||||
cover the new caching *logic* (geometry math, exercised by constructing
|
||||
widget structs directly) but cannot exercise `uiXxxDraw()`/`uiFrameDraw()`
|
||||
themselves -- those need `FONT_DEFAULT`/`UI_FRAME`'s GL texture, which
|
||||
needs a live GL context this test binary doesn't have (confirmed: calling
|
||||
`fontInitDefault()` in a test asserts in `texturegl.c`). This is true of
|
||||
the whole rendering layer, not something newly introduced -- there's no
|
||||
GL-backed test harness anywhere in the repo yet.
|
||||
|
||||
## Game layer (`src/duskrpg/`)
|
||||
|
||||
Actively maintained, not orphaned (despite an old "remove rpg" commit deep
|
||||
in history) -- last touched the same day as this survey.
|
||||
|
||||
- **cutscene/** -- actively developed, matches `CLAUDE.md`'s documented
|
||||
recipe exactly. 16 registered item types.
|
||||
- **entity/, scene/** -- overworld player/camera/interactable components
|
||||
and prefabs, active.
|
||||
- **item/** -- `item.c`/`inventory.c`/`backpack.c` built via a working
|
||||
`item.json` -> `itemdef.h` codegen pipeline. `itemgive.c/h` exist on
|
||||
disk but are explicitly excluded from the CMake build pending textbox
|
||||
UI restoration -- **that UI (`ui/textbox/`) is already restored**, so
|
||||
this looks like an overdue follow-up, not a real blocker.
|
||||
- **input/** -- headers only, no implementation. Contains the one
|
||||
genuine TODO found in the whole `duskrpg` tree: `// TODO: Wiimote, USB
|
||||
Keyboard, probably more.`
|
||||
- **ui/** -- textbox restored and built; `uitestlabel.c/h` is an
|
||||
intentional smoke-test scaffold, not dead code.
|
||||
|
||||
## Platform layers
|
||||
|
||||
| Platform | Status |
|
||||
|--------------|--------|
|
||||
| duskgl / dusksdl2 | Complete, shared by Linux/Knulli/PSP, no gaps found. |
|
||||
| dusklinux | Complete, well-trodden. |
|
||||
| duskpsp | Complete for current design. The historical dialog/tearing bug (see memory `project_psp_dialog_tearing` etc.) was **not fixed -- the dialog-based connect UI was removed entirely** (commit `d7982599`, "Simplified PSP network") in favor of a silent profile-based connect. Revisit if the dialog UX is still wanted. |
|
||||
| duskdolphin (GameCube/Wii) | Functional, not a stub -- real GX-based display/mesh/shader/texture. Wii input uses only the GameCube `PAD_*` library; no WPAD/Wiimote support (`inputdolphin.h` has `#error "Wii not implemented"` gated behind macros that are never defined, so currently dormant, not a live build break). `networkdolphin.c` hardcodes a static IP as an explicit temporary workaround for a suspected DHCP-related memory-corruption bug in `if_config()` -- root cause still open. |
|
||||
| vita | **Referenced by `scripts/build-vita.sh` and `docker/vita/` but no `cmake/targets/vita.cmake` exists** -- the build target is broken/unfinished at the CMake level. |
|
||||
|
||||
### CI coverage gap
|
||||
`.github/workflows/test.yml` only builds+tests Linux, on PRs to `main`.
|
||||
`build.yml` builds all other platforms (PSP, Knulli, GameCube, Wii +
|
||||
ISO variants) but **only on tag push** (release time). Vita and Dolphin
|
||||
aren't in CI at all. Net effect: regressions on 4+ platforms can land
|
||||
silently until a release tag is cut.
|
||||
|
||||
## Test coverage gaps
|
||||
|
||||
Core `src/dusk/` subsystems with **zero** unit tests: `console`,
|
||||
`engine`, `event`, `game`, `input`, `log`, `save`, `script` (+ all JS
|
||||
module bindings), `system`, `ui` (entire widget framework).
|
||||
|
||||
`src/duskrpg/` has almost no test coverage: `test/item/test_inventory.c`
|
||||
exists but **`add_subdirectory(item)` is commented out in
|
||||
`test/CMakeLists.txt`**, so even that one test never runs. `cutscene`,
|
||||
`entity`, `game`, `input`, `scene`, `ui` under `duskrpg` have no tests at
|
||||
all.
|
||||
|
||||
No stale tests were found referencing deleted systems (old JSON
|
||||
serialize/deserialize, old event/cutscene modules) -- the test suite is
|
||||
internally consistent with current code, just incomplete in coverage.
|
||||
|
||||
## Build/tooling gaps worth knowing about
|
||||
|
||||
- `assetsraw/` -> `assets/` (chunk JSON -> `.dcf`) is a manual/editor-only
|
||||
step (`tools.asset.chunk`), not part of the CMake build -- committed
|
||||
`.dcf` files can silently drift from their raw source.
|
||||
- Several Python tool packages exist but aren't wired into any build:
|
||||
`tools/color/csv/`, `tools/asset/chunk_json/`, `tools/asset/dmf/`,
|
||||
`tools/asset/tiles/`, `tools/input/csv/`. Some may be superseded by
|
||||
`tools/color.py`/`tools/item.py`; worth a pass to confirm which are
|
||||
live vs leftover.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
const platformNames = {
|
||||
[System.PLATFORM_LINUX]: 'Linux',
|
||||
[System.PLATFORM_KNULLI]: 'Knulli',
|
||||
[System.PLATFORM_PSP]: 'PSP',
|
||||
[System.PLATFORM_GAMECUBE]: 'GameCube',
|
||||
[System.PLATFORM_WII]: 'Wii',
|
||||
};
|
||||
|
||||
Console.print('Platform: ' + (platformNames[System.platform] || 'Unknown'));
|
||||
|
||||
UIFullboxOver.setColor(Color.BLACK);
|
||||
|
||||
requireAsync('testscene.js').then(Scene.set).catch(err => {
|
||||
Console.print('Error loading scene: ' + err);
|
||||
Engine.exit();
|
||||
});
|
||||
+49
-43
@@ -10,51 +10,57 @@ msgid "ui.title"
|
||||
msgstr ""
|
||||
"Welcome"
|
||||
|
||||
#: ui/user.c:22
|
||||
msgid "ui.greeting"
|
||||
msgstr "Hello, %s!"
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.general"
|
||||
msgstr "General"
|
||||
|
||||
#: ui/files.c:40
|
||||
msgid "ui.file_status"
|
||||
msgstr "%s has %d files."
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.input"
|
||||
msgstr "Input"
|
||||
|
||||
#: ui/cart.c:55
|
||||
msgid "cart.item_count"
|
||||
msgid_plural "cart.item_count"
|
||||
msgstr[0] "%d item"
|
||||
msgstr[1] "%d items (dual)"
|
||||
msgstr[2] "%d items (few)"
|
||||
msgstr[3] "%d items (many)"
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.display"
|
||||
msgstr "Display"
|
||||
|
||||
#: ui/notifications.c:71
|
||||
msgid ""
|
||||
"ui.multiline_help"
|
||||
msgstr ""
|
||||
"Line one of the help text.\n"
|
||||
"Line two continues here.\n"
|
||||
"Line three ends here."
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.tabs.audio"
|
||||
msgstr "Audio"
|
||||
|
||||
#: ui/errors.c:90
|
||||
msgid ""
|
||||
"error.upload_failed.long"
|
||||
msgstr ""
|
||||
"Upload failed for file \"%s\".\n"
|
||||
"Please try again later or contact support."
|
||||
msgid "ui.settings.input.deadzone"
|
||||
msgstr "Deadzone"
|
||||
|
||||
#: ui/messages.c:110
|
||||
msgid ""
|
||||
"user.invite_status"
|
||||
msgid_plural ""
|
||||
"user.invite_status"
|
||||
msgstr[0] ""
|
||||
"%s invited %d user.\n"
|
||||
"Please review the request."
|
||||
msgstr[1] ""
|
||||
"%s invited %d users (dual).\n"
|
||||
"Please review the requests."
|
||||
msgstr[2] ""
|
||||
"%s invited %d users (few).\n"
|
||||
"Please review the requests."
|
||||
msgstr[3] ""
|
||||
"%s invited %d users (many).\n"
|
||||
"Please review the requests."
|
||||
#: src/dusk/ui/frame/settings/uisettingsgeneral.c
|
||||
msgid "ui.settings.general.language"
|
||||
msgstr "Language"
|
||||
|
||||
msgid "ui.settings.general.language_detail"
|
||||
msgstr "Takes effect after restarting the application."
|
||||
|
||||
#: src/dusk/ui/frame/settings/uisettings.c
|
||||
msgid "ui.settings.apply"
|
||||
msgstr "Apply"
|
||||
|
||||
#: src/dusk/ui/frame/uiconfirm.c
|
||||
msgid "ui.confirm.discard_changes"
|
||||
msgstr "Discard unsaved changes?"
|
||||
|
||||
#: src/dusk/ui/frame/game/uigamemenu.c
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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.
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
const PLAYER_SPEED = 5.0;
|
||||
// 1 world unit = 16 pixels.
|
||||
const PIXEL_SCALE = 1.0 / 16.0;
|
||||
// Player sprite is 32x32 px (test.png dimensions).
|
||||
const PLAYER_W = 32 * PIXEL_SCALE;
|
||||
const PLAYER_H = 32 * PIXEL_SCALE;
|
||||
|
||||
var player = {};
|
||||
|
||||
player.getAssets = () => {
|
||||
return [
|
||||
{ path: 'test.png', type: Asset.TYPE_TEXTURE, format: Texture.FORMAT_RGBA }
|
||||
];
|
||||
}
|
||||
|
||||
player.init = function(scene) {
|
||||
var texture = scene.assets.getAssetByPath('test.png');
|
||||
Console.print('Player init: got texture ' + texture);
|
||||
|
||||
_entity = Entity.create();
|
||||
_position = _entity.add(Component.POSITION);
|
||||
_physics = _entity.add(Component.PHYSICS);
|
||||
|
||||
_physics.bodyType = Physics.DYNAMIC;
|
||||
_physics.shape = Physics.SHAPE_CUBE;
|
||||
_physics.gravityScale = 1.0;
|
||||
|
||||
var r = _entity.add(Component.RENDERABLE);
|
||||
r.texture = texture.texture;
|
||||
r.type = Renderable.SPRITEBATCH;
|
||||
r.color = new Color(220, 80, 80);
|
||||
// Upright quad centered on X, bottom-aligned on Y.
|
||||
r.sprites = [[-PLAYER_W/2, 0, 0, PLAYER_W/2, PLAYER_H, 0, 0, 1, 1, 0]];
|
||||
|
||||
_position.localPosition = new Vec3(0, PLAYER_H, 0);
|
||||
};
|
||||
|
||||
player.getPosition = function() {
|
||||
return _position;
|
||||
};
|
||||
|
||||
player.update = function() {
|
||||
if(!_physics) return;
|
||||
var vx = Input.axis(INPUT_ACTION_LEFT, INPUT_ACTION_RIGHT) * PLAYER_SPEED;
|
||||
var vz = Input.axis(INPUT_ACTION_DOWN, INPUT_ACTION_UP) * PLAYER_SPEED;
|
||||
// Preserve vertical velocity so gravity and landing work correctly.
|
||||
var vy = _physics.velocity.y;
|
||||
_physics.velocity = new Vec3(vx, vy, vz);
|
||||
};
|
||||
|
||||
player.dispose = function() {
|
||||
Entity.dispose(_entity);
|
||||
_entity = null;
|
||||
_position = null;
|
||||
_physics = null;
|
||||
};
|
||||
|
||||
module.exports = player;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
var scene = {};
|
||||
|
||||
// Pokemon DS-style camera: ~34 degrees elevation (atan(6/9)).
|
||||
// CAM_HEIGHT / CAM_DIST ratio controls the tilt - keep it under 0.7 for
|
||||
// the characteristically shallow DS angle.
|
||||
const CAM_HEIGHT = 6;
|
||||
const CAM_DIST = 9;
|
||||
|
||||
scene.init = async function() {
|
||||
// Camera
|
||||
scene.cam = Entity.create();
|
||||
var camPos = scene.cam.add(Component.POSITION);
|
||||
var cam = scene.cam.add(Component.CAMERA);
|
||||
camPos.localPosition = new Vec3(3, 3, 3);
|
||||
camPos.lookAt(new Vec3(0, 0, 0));
|
||||
|
||||
// Floor - large flat slab, no texture needed.
|
||||
scene.floor = Entity.create();
|
||||
var floorPos = scene.floor.add(Component.POSITION);
|
||||
var floorR = scene.floor.add(Component.RENDERABLE);
|
||||
floorR.type = Renderable.SHADER_MATERIAL;
|
||||
floorR.color = Color.BLUE;
|
||||
// floorPos.localScale = new Vec3(16, 0.2, 16);
|
||||
// floorPos.localPosition = new Vec3(0, -0.1, 0);
|
||||
|
||||
await UIFullboxOver.transition(Color.BLACK, Color.TRANSPARENT, 1.0);
|
||||
};
|
||||
|
||||
scene.update = function() {
|
||||
};
|
||||
|
||||
scene.dispose = function() {
|
||||
Entity.dispose(scene.floor);
|
||||
Entity.dispose(scene.cam);
|
||||
};
|
||||
|
||||
module.exports = scene;
|
||||
@@ -1,6 +0,0 @@
|
||||
module = {
|
||||
render() {
|
||||
Text.draw(0, 0, "Hello World");
|
||||
SpriteBatch.flush();
|
||||
}
|
||||
};
|
||||
@@ -1464,7 +1464,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -1473,7 +1473,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -1482,7 +1482,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -1491,7 +1491,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -1500,7 +1500,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -1509,7 +1509,7 @@
|
||||
10,
|
||||
0
|
||||
],
|
||||
"type": 1,
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
@@ -2267,6 +2267,276 @@
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
6,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
7,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
8,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
9,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
10,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
11,
|
||||
9,
|
||||
1
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
6,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
7,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
8,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
9,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
10,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
11,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"type": 4,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
6,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
7,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
8,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
9,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
10,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
11,
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
6,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
7,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
8,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
9,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
10,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
11,
|
||||
6,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
6,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
7,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
8,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
9,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
10,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
},
|
||||
{
|
||||
"pos": [
|
||||
11,
|
||||
7,
|
||||
3
|
||||
],
|
||||
"type": 1,
|
||||
"tile": 0
|
||||
}
|
||||
],
|
||||
"meshes": []
|
||||
|
||||
@@ -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})
|
||||
@@ -16,6 +16,9 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
DOL=1
|
||||
ISO=2
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
|
||||
# Link libraries
|
||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PRIVATE
|
||||
# bba
|
||||
bba
|
||||
)
|
||||
|
||||
# ISO post-build: produce NTSC-J, NTSC-U and PAL disc images
|
||||
|
||||
@@ -36,7 +36,7 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
# DUSK_OPENGL_LEGACY
|
||||
DUSK_LINUX
|
||||
DUSK_DISPLAY_SIZE_DYNAMIC
|
||||
DUSK_DISPLAY_WIDTH_DEFAULT=640
|
||||
DUSK_DISPLAY_WIDTH_DEFAULT=854
|
||||
DUSK_DISPLAY_HEIGHT_DEFAULT=480
|
||||
DUSK_DISPLAY_SCREEN_HEIGHT=240
|
||||
DUSK_INPUT_KEYBOARD
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
set(CMAKE_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
||||
set(CMAKE_RANLIB "$ENV{PSPDEV}/bin/psp-ranlib" CACHE FILEPATH "" FORCE)
|
||||
set(CMAKE_C_COMPILER_AR "$ENV{PSPDEV}/bin/psp-ar" CACHE FILEPATH "" FORCE)
|
||||
@@ -55,15 +59,9 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||
DUSK_DISPLAY_WIDTH=480
|
||||
DUSK_DISPLAY_HEIGHT=272
|
||||
DUSK_THREAD_PTHREAD
|
||||
DUSK_TIME_DYNAMIC
|
||||
DUSK_DISPLAY_OVERSCAN=6
|
||||
)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||
DUSK_ASSERTIONS_FAKED
|
||||
)
|
||||
endif()
|
||||
|
||||
# Postbuild, create .pbp file for PSP.
|
||||
create_pbp_file(
|
||||
TARGET "${DUSK_BINARY_TARGET_NAME}"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -17,6 +17,10 @@ const ChunkTerrain = (() => {
|
||||
const CHUNK_DEPTH = 4;
|
||||
const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH;
|
||||
|
||||
// World-space Z distance of one Z-layer/story, in world-float space.
|
||||
// Must match WORLD_LAYER_HEIGHT in src/dusk/rpg/overworld/worldpos.h.
|
||||
const WORLD_LAYER_HEIGHT = Math.SQRT1_2;
|
||||
|
||||
const TILE_SHAPE_NULL = 0;
|
||||
const TILE_SHAPE_GROUND = 1;
|
||||
const TILE_SHAPE_RAMP_NORTH = 2;
|
||||
@@ -88,7 +92,53 @@ const ChunkTerrain = (() => {
|
||||
const v1 = (y + 1) / CHUNK_HEIGHT;
|
||||
const [sw, se, ne, nw] = corners;
|
||||
|
||||
pushTileQuad(out, u0, u1, v0, v1, x, y, z + sw, z + se, z + ne, z + nw);
|
||||
pushTileQuad(
|
||||
out, u0, u1, v0, v1, x, y,
|
||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
||||
(z + se) * WORLD_LAYER_HEIGHT,
|
||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
||||
(z + nw) * WORLD_LAYER_HEIGHT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Float32Array(out);
|
||||
}
|
||||
|
||||
// Four edges (SW-SE, SE-NE, NE-NW, NW-SW) of a tile's quad as line
|
||||
// segments, lifted slightly above the terrain surface to avoid
|
||||
// z-fighting - same corner-height convention as pushTileQuad().
|
||||
function pushTileGridLines(out, fx, fy, swZ, seZ, neZ, nwZ) {
|
||||
const eps = 0.01;
|
||||
const sw = [fx, fy, swZ + eps];
|
||||
const se = [fx + 1, fy, seZ + eps];
|
||||
const ne = [fx + 1, fy + 1, neZ + eps];
|
||||
const nw = [fx, fy + 1, nwZ + eps];
|
||||
const edges = [sw, se, se, ne, ne, nw, nw, sw];
|
||||
for(const [x, y, z] of edges) out.push(0, 0, x, y, z);
|
||||
}
|
||||
|
||||
// Generate grid-outline line segments for every non-null tile, following
|
||||
// the same per-corner heights as buildTerrainVerts() so lines hug ramps
|
||||
// instead of cutting through them. Returns a Float32Array of interleaved
|
||||
// [u, v, x, y, z] vertices meant to be drawn with gl.LINES (pairs).
|
||||
function buildGridLines(tiles) {
|
||||
const out = [];
|
||||
for(let z = 0; z < CHUNK_DEPTH; z++) {
|
||||
for(let y = 0; y < CHUNK_HEIGHT; y++) {
|
||||
for(let x = 0; x < CHUNK_WIDTH; x++) {
|
||||
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
|
||||
const corners = RAMP_CORNERS[type];
|
||||
if(!corners) continue;
|
||||
|
||||
const [sw, se, ne, nw] = corners;
|
||||
pushTileGridLines(
|
||||
out, x, y,
|
||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
||||
(z + se) * WORLD_LAYER_HEIGHT,
|
||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
||||
(z + nw) * WORLD_LAYER_HEIGHT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +146,7 @@ const ChunkTerrain = (() => {
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT,
|
||||
CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT, WORLD_LAYER_HEIGHT,
|
||||
TILE_SHAPE_NULL, TILE_SHAPE_GROUND,
|
||||
TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST,
|
||||
TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST,
|
||||
@@ -104,6 +154,6 @@ const ChunkTerrain = (() => {
|
||||
TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST,
|
||||
TILE_SHAPE_RAMP_NORTHEAST_INNER, TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
||||
TILE_SHAPE_RAMP_SOUTHEAST_INNER, TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
||||
RAMP_CORNERS, tileIndex, buildTerrainVerts,
|
||||
RAMP_CORNERS, tileIndex, buildTerrainVerts, buildGridLines,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 512px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.tile-swatch {
|
||||
|
||||
@@ -41,16 +41,15 @@
|
||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER]: { dx: -1, dy: 1 },
|
||||
};
|
||||
|
||||
// Draws an arrow centered at (cx, cy) pointing toward the ramp's high
|
||||
// side, so ramp direction is visible at a glance on both the palette
|
||||
// swatches and the tile grid.
|
||||
function drawArrow(ctx, cx, cy, size, dx, dy) {
|
||||
const mag = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / mag, uy = dy / mag;
|
||||
const len = size * 0.32;
|
||||
const tipX = cx + ux * len, tipY = cy + uy * len;
|
||||
const tailX = cx - ux * len, tailY = cy - uy * len;
|
||||
const INNER_CORNER_SHAPES = new Set([
|
||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER,
|
||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER,
|
||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
||||
]);
|
||||
|
||||
// Draws a single-headed arrow from (tailX, tailY) to (tipX, tipY).
|
||||
function drawArrowSegment(ctx, tailX, tailY, tipX, tipY, size) {
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
||||
@@ -62,7 +61,7 @@
|
||||
ctx.stroke();
|
||||
|
||||
const headLen = size * 0.18;
|
||||
const angle = Math.atan2(uy, ux);
|
||||
const angle = Math.atan2(tipY - tailY, tipX - tailX);
|
||||
const leftAngle = angle + Math.PI * 0.8;
|
||||
const rightAngle = angle - Math.PI * 0.8;
|
||||
ctx.beginPath();
|
||||
@@ -73,6 +72,54 @@
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Draws an arrow centered at (cx, cy) pointing toward the ramp's high
|
||||
// side, so ramp direction is visible at a glance on both the palette
|
||||
// swatches and the tile grid. Used for cardinal ramps and outer-corner
|
||||
// ramps (a single raised corner).
|
||||
function drawArrow(ctx, cx, cy, size, dx, dy) {
|
||||
const mag = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / mag, uy = dy / mag;
|
||||
const len = size * 0.32;
|
||||
drawArrowSegment(ctx, cx - ux * len, cy - uy * len, cx + ux * len, cy + uy * len, size);
|
||||
}
|
||||
|
||||
// Draws a plain right-angle bracket - two line segments meeting at 90
|
||||
// degrees at the tile's corner, one running along each of the two edges
|
||||
// adjacent to it - so inner-corner ramps (three corners raised, one
|
||||
// dropped) read as "the adjacent sides meeting in a 90-degree corner",
|
||||
// distinct from the single diagonal arrow used for outer-corner ramps.
|
||||
function drawCornerBracket(ctx, cx, cy, size, dx, dy) {
|
||||
const cornerDist = size * 0.42;
|
||||
const armLen = size * 0.34;
|
||||
const cornerX = cx + dx * cornerDist;
|
||||
const cornerY = cy + dy * cornerDist;
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cornerX - dx * armLen, cornerY);
|
||||
ctx.lineTo(cornerX, cornerY);
|
||||
ctx.lineTo(cornerX, cornerY - dy * armLen);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draws the direction icon for a ramp tile `type` in a `size`x`size` area
|
||||
// centered at (cx, cy): a diagonal arrow for cardinal/outer-corner ramps,
|
||||
// or a right-angle bracket for inner-corner ramps. No-op for shapes with
|
||||
// no direction (ground, erase).
|
||||
function drawShapeIcon(ctx, cx, cy, size, type) {
|
||||
const dir = SHAPE_DIRECTIONS[type];
|
||||
if(!dir) return;
|
||||
if(INNER_CORNER_SHAPES.has(type)) {
|
||||
drawCornerBracket(ctx, cx, cy, size, dir.dx, dir.dy);
|
||||
} else {
|
||||
drawArrow(ctx, cx, cy, size, dir.dx, dir.dy);
|
||||
}
|
||||
}
|
||||
|
||||
// Draws a small pencil icon centered in a `size`x`size` canvas.
|
||||
function drawPencilIcon(ctx, size) {
|
||||
ctx.save();
|
||||
@@ -146,6 +193,7 @@
|
||||
|
||||
let mapRenderer = null;
|
||||
let terrainMesh = null;
|
||||
let gridLinesMesh = null;
|
||||
let terrainTexturePromise = null;
|
||||
let modelIndex = null;
|
||||
let neighborChunks = [];
|
||||
@@ -262,6 +310,13 @@
|
||||
return modelIndex;
|
||||
}
|
||||
|
||||
// Scales a mesh's stored [x, y, z] offset for preview, matching the
|
||||
// Z scaling mapChunkLoaded() applies at runtime (chunk.meshOffsets[m][2]
|
||||
// * WORLD_LAYER_HEIGHT) - x/y stay 1:1 since only Z is height-scaled.
|
||||
function scaledMeshOffset(pos) {
|
||||
return [pos[0], pos[1], pos[2] * ChunkTerrain.WORLD_LAYER_HEIGHT];
|
||||
}
|
||||
|
||||
// Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to
|
||||
// its model JSON by basename, mirroring find_model() in
|
||||
// tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare
|
||||
@@ -330,7 +385,7 @@
|
||||
for(const m of neighborMeshes) {
|
||||
try {
|
||||
const resolved = await resolveModelForMeshFile(m.file);
|
||||
if(resolved) models.push({ ...resolved, offset: m.pos });
|
||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
||||
} catch(e) {
|
||||
console.warn("Failed to load neighbor mesh preview", m.file, e);
|
||||
}
|
||||
@@ -426,13 +481,12 @@
|
||||
buildPalette();
|
||||
});
|
||||
|
||||
const dir = SHAPE_DIRECTIONS[s.type];
|
||||
if(dir) {
|
||||
if(SHAPE_DIRECTIONS[s.type]) {
|
||||
const icon = document.createElement("canvas");
|
||||
icon.width = 32;
|
||||
icon.height = 32;
|
||||
icon.className = "tile-swatch-icon";
|
||||
drawArrow(icon.getContext("2d"), 16, 16, 32, dir.dx, dir.dy);
|
||||
drawShapeIcon(icon.getContext("2d"), 16, 16, 32, s.type);
|
||||
btn.appendChild(icon);
|
||||
}
|
||||
|
||||
@@ -489,11 +543,10 @@
|
||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell;
|
||||
ctx.fillStyle = SHAPE_COLORS[type] || "#15171b";
|
||||
ctx.fillRect(x * cell, sy, cell, cell);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
|
||||
ctx.strokeRect(x * cell, sy, cell, cell);
|
||||
|
||||
const dir = SHAPE_DIRECTIONS[type];
|
||||
if(dir) drawArrow(ctx, x * cell + cell / 2, sy + cell / 2, cell, dir.dx, dir.dy);
|
||||
drawShapeIcon(ctx, x * cell + cell / 2, sy + cell / 2, cell, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,6 +645,9 @@
|
||||
function rebuildTerrainMesh() {
|
||||
const floats = ChunkTerrain.buildTerrainVerts(tiles);
|
||||
terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
|
||||
|
||||
const lineFloats = ChunkTerrain.buildGridLines(tiles);
|
||||
gridLinesMesh = lineFloats.length ? mapRenderer.createMesh(lineFloats) : null;
|
||||
}
|
||||
|
||||
// Keeps the preview canvas's backing-store resolution matched to its
|
||||
@@ -619,7 +675,7 @@
|
||||
for(const m of meshes) {
|
||||
try {
|
||||
const resolved = await resolveModelForMeshFile(m.file);
|
||||
if(resolved) models.push({ ...resolved, offset: m.pos });
|
||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
||||
} catch(e) {
|
||||
console.warn("Failed to load mesh preview", m.file, e);
|
||||
}
|
||||
@@ -637,13 +693,15 @@
|
||||
models: n.models,
|
||||
}));
|
||||
|
||||
const zLevelHeight = currentZLevel * ChunkTerrain.WORLD_LAYER_HEIGHT;
|
||||
mapRenderer.render({
|
||||
target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, currentZLevel],
|
||||
target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, zLevelHeight],
|
||||
worldH: zoomWorldH,
|
||||
terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null,
|
||||
gridLines: gridLinesMesh ? { mesh: gridLinesMesh } : null,
|
||||
models,
|
||||
neighbors,
|
||||
highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: currentZLevel } : null,
|
||||
highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: zLevelHeight } : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ const MapRenderer = (() => {
|
||||
// scene = {
|
||||
// target: [x, y, z], worldH: number,
|
||||
// terrain: { mesh, texture } | null,
|
||||
// gridLines: { mesh } | null,
|
||||
// models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
|
||||
// neighbors: [{
|
||||
// offset: [x,y,z],
|
||||
@@ -248,6 +249,10 @@ const MapRenderer = (() => {
|
||||
drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
|
||||
}
|
||||
|
||||
if(scene.gridLines) {
|
||||
drawMesh(scene.gridLines.mesh, identity, null, [0, 0, 0, 0.35], false, gl.LINES);
|
||||
}
|
||||
|
||||
for(const model of scene.models) {
|
||||
const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
|
||||
drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
|
||||
|
||||
Executable
+3
@@ -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"
|
||||
Executable
+45
@@ -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)."
|
||||
Executable
+3
@@ -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"
|
||||
Executable
+42
@@ -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)."
|
||||
Executable
+3
@@ -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"
|
||||
Executable
+45
@@ -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
@@ -4,6 +4,7 @@
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
add_subdirectory(dusk)
|
||||
add_subdirectory(duskrpg)
|
||||
|
||||
if(DUSK_TARGET_SYSTEM STREQUAL "linux" OR DUSK_TARGET_SYSTEM STREQUAL "knulli")
|
||||
add_subdirectory(dusklinux)
|
||||
@@ -15,11 +16,6 @@ elseif(DUSK_TARGET_SYSTEM STREQUAL "psp")
|
||||
add_subdirectory(dusksdl2)
|
||||
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")
|
||||
add_subdirectory(duskdolphin)
|
||||
|
||||
|
||||
+13
-1
@@ -32,6 +32,15 @@ if(NOT yyjson_FOUND)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT jerryscript_FOUND)
|
||||
find_package(jerryscript REQUIRED)
|
||||
target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||
jerryscript::core
|
||||
jerryscript::ext
|
||||
jerryscript::port
|
||||
)
|
||||
endif()
|
||||
|
||||
if(DUSK_BACKTRACE)
|
||||
target_link_options(${DUSK_LIBRARY_TARGET_NAME} PUBLIC -rdynamic)
|
||||
target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC
|
||||
@@ -58,17 +67,20 @@ add_subdirectory(assert)
|
||||
add_subdirectory(asset)
|
||||
add_subdirectory(console)
|
||||
add_subdirectory(display)
|
||||
add_subdirectory(entity)
|
||||
add_subdirectory(log)
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(error)
|
||||
add_subdirectory(game)
|
||||
add_subdirectory(input)
|
||||
add_subdirectory(locale)
|
||||
add_subdirectory(rpg)
|
||||
add_subdirectory(scene)
|
||||
add_subdirectory(system)
|
||||
add_subdirectory(time)
|
||||
add_subdirectory(ui)
|
||||
add_subdirectory(network)
|
||||
add_subdirectory(physics)
|
||||
add_subdirectory(save)
|
||||
add_subdirectory(script)
|
||||
add_subdirectory(util)
|
||||
add_subdirectory(thread)
|
||||
@@ -6,5 +6,7 @@
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
easing.c
|
||||
keyframe.c
|
||||
keyframeset.c
|
||||
animation.c
|
||||
)
|
||||
|
||||
@@ -5,48 +5,70 @@
|
||||
|
||||
#include "animation.h"
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "util/math.h"
|
||||
#include "time/time.h"
|
||||
|
||||
void animationInit(
|
||||
animation_t *anim,
|
||||
keyframe_t *keyframes,
|
||||
uint16_t keyframeCount
|
||||
keyframe_t **tracks,
|
||||
uint16_t *trackCounts,
|
||||
uint16_t trackCount
|
||||
) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
||||
|
||||
anim->keyframes = keyframes;
|
||||
anim->keyframeCount = keyframeCount;
|
||||
keyframeSetInit(
|
||||
&anim->keyframes, tracks, trackCounts, NULL, NULL, trackCount, NULL
|
||||
);
|
||||
anim->time = 0.0f;
|
||||
anim->speed = 1.0f;
|
||||
anim->loop = false;
|
||||
anim->playing = false;
|
||||
anim->eventTime = 0.0f;
|
||||
anim->eventCallback = NULL;
|
||||
anim->eventUser = NULL;
|
||||
}
|
||||
|
||||
float_t animationGetValue(animation_t *anim, const float_t time) {
|
||||
void animationUpdate(animation_t *anim) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
assertNotNull(anim->keyframes, "Keyframes pointer cannot be null.");
|
||||
assertTrue(anim->keyframeCount > 0, "Keyframe count invalid.");
|
||||
assertTrue(time >= 0, "Time must be non-negative.");
|
||||
if(!anim->playing) return;
|
||||
|
||||
keyframe_t *start;
|
||||
keyframe_t *end;
|
||||
keyframe_t *last = anim->keyframes + anim->keyframeCount - 1;
|
||||
keyframe_t *current = anim->keyframes;
|
||||
start = current;
|
||||
float_t duration = keyframeSetGetDuration(&anim->keyframes);
|
||||
float_t prevTime = anim->time;
|
||||
anim->time += TIME.delta * anim->speed;
|
||||
|
||||
do {
|
||||
if(current->time > time) {
|
||||
end = current;
|
||||
break;
|
||||
}
|
||||
start = current;
|
||||
current++;
|
||||
// Loops that overshoot duration wrap back into [0, duration) -- an event
|
||||
// near the end must still be checked across that wrap, as two segments:
|
||||
// (prevTime, duration] then [0, newTime].
|
||||
bool_t wrapped = anim->loop && duration > 0.0f && anim->time >= duration;
|
||||
|
||||
if(current > last) {
|
||||
end = start;
|
||||
break;
|
||||
}
|
||||
} while(true);
|
||||
if(anim->loop) {
|
||||
if(duration > 0.0f) anim->time = mathModFloat(anim->time, duration);
|
||||
} else if(anim->time >= duration) {
|
||||
anim->time = duration;
|
||||
anim->playing = false;
|
||||
}
|
||||
|
||||
float_t t = (time - start->time) / (end->time - start->time);
|
||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
||||
if(!anim->eventCallback) return;
|
||||
|
||||
bool_t crossed = wrapped
|
||||
? (prevTime < anim->eventTime || anim->time >= anim->eventTime)
|
||||
: (prevTime < anim->eventTime && anim->time >= anim->eventTime);
|
||||
if(crossed) anim->eventCallback(anim, anim->eventUser);
|
||||
}
|
||||
|
||||
void animationSetEvent(
|
||||
animation_t *anim,
|
||||
const float_t time,
|
||||
const animationeventcallback_t callback,
|
||||
void *user
|
||||
) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
anim->eventTime = time;
|
||||
anim->eventCallback = callback;
|
||||
anim->eventUser = user;
|
||||
}
|
||||
|
||||
float_t animationGetValue(animation_t *anim, const uint16_t trackIndex) {
|
||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
||||
return keyframeSetGetValue(&anim->keyframes, trackIndex, anim->time);
|
||||
}
|
||||
@@ -4,31 +4,99 @@
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
#pragma once
|
||||
#include "keyframe.h"
|
||||
#include "keyframeset.h"
|
||||
|
||||
typedef struct {
|
||||
keyframe_t *keyframes;
|
||||
uint16_t keyframeCount;
|
||||
typedef struct animation_t animation_t;
|
||||
|
||||
/**
|
||||
* Callback fired once when animationUpdate() advances time past
|
||||
* eventTime (see animationSetEvent()).
|
||||
*
|
||||
* @param anim The animation whose event fired.
|
||||
* @param user The user pointer passed to animationSetEvent().
|
||||
*/
|
||||
typedef void (*animationeventcallback_t)(animation_t *anim, void *user);
|
||||
|
||||
typedef struct animation_t {
|
||||
/** The animation's tracks/channels and their raw keyframe data. */
|
||||
keyframeset_t keyframes;
|
||||
|
||||
/** Current playback position, in seconds. */
|
||||
float_t time;
|
||||
|
||||
/** Playback rate multiplier; 1.0 = normal speed. */
|
||||
float_t speed;
|
||||
|
||||
/** True if animationUpdate() should wrap time back to 0 on reaching the
|
||||
* final keyframe, rather than holding there and clearing playing. */
|
||||
bool_t loop;
|
||||
|
||||
/** True while animationUpdate() should advance time each call. */
|
||||
bool_t playing;
|
||||
|
||||
/** Time (seconds) at which eventCallback fires; see animationSetEvent(). */
|
||||
float_t eventTime;
|
||||
/** Callback fired once when time advances past eventTime, or NULL. */
|
||||
animationeventcallback_t eventCallback;
|
||||
/** User pointer passed to eventCallback unchanged. */
|
||||
void *eventUser;
|
||||
} animation_t;
|
||||
|
||||
/**
|
||||
* Initializes an animation.
|
||||
* Initializes an animation: time 0, speed 1.0, not looping, not playing.
|
||||
* See keyframeSetInit() -- tracks/trackCounts and the keyframe_t arrays
|
||||
* they point to are not copied, and must outlive this animation.
|
||||
*
|
||||
* @param anim The animation to initialize.
|
||||
* @param keyframes The keyframes to use for the animation.
|
||||
* @param keyframeCount The number of keyframes in the animation.
|
||||
* @param tracks Array of trackCount keyframe_t arrays.
|
||||
* @param trackCounts Array of trackCount keyframe counts, matching tracks.
|
||||
* @param trackCount The number of tracks/channels in this animation.
|
||||
*/
|
||||
void animationInit(
|
||||
animation_t *anim,
|
||||
keyframe_t *keyframes,
|
||||
uint16_t keyframeCount
|
||||
keyframe_t **tracks,
|
||||
uint16_t *trackCounts,
|
||||
uint16_t trackCount
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the value of the animation at a given time.
|
||||
* 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 time The time at which to get the value, in seconds.
|
||||
* @return The value of the animation at the given time.
|
||||
* @param trackIndex The track to evaluate, in [0, trackCount).
|
||||
* @return The interpolated value of that track at the current time.
|
||||
*/
|
||||
float_t animationGetValue(animation_t *anim, const float_t time);
|
||||
float_t animationGetValue(animation_t *anim, const uint16_t trackIndex);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -11,3 +11,17 @@ typedef struct {
|
||||
float_t value;
|
||||
easingtype_t easing;
|
||||
} 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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -8,6 +8,13 @@
|
||||
#pragma once
|
||||
#include "dusk.h"
|
||||
|
||||
// Release-style CMake configs (Release, RelWithDebInfo, MinSizeRel) define
|
||||
// NDEBUG automatically; fake assertions there rather than requiring every
|
||||
// platform's CMakeLists to set DUSK_ASSERTIONS_FAKED itself.
|
||||
#if defined(NDEBUG) && !defined(DUSK_ASSERTIONS_FAKED)
|
||||
#define DUSK_ASSERTIONS_FAKED
|
||||
#endif
|
||||
|
||||
#ifdef DUSK_TEST_ASSERT
|
||||
#include <cmocka.h>
|
||||
|
||||
|
||||
+48
-57
@@ -59,20 +59,28 @@ assetentry_t * assetGetEntry(
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
|
||||
// We did not find one existing, Find first available slot.
|
||||
entry = ASSET.entries;
|
||||
do {
|
||||
if(entry->type != ASSET_LOADER_TYPE_NULL) {
|
||||
entry++;
|
||||
continue;
|
||||
}
|
||||
// We did not find one existing. Find first available slot, reaping
|
||||
// zero-ref entries to make room if none are immediately available.
|
||||
bool_t reaped = false;
|
||||
for(;;) {
|
||||
entry = ASSET.entries;
|
||||
do {
|
||||
if(entry->type != ASSET_LOADER_TYPE_NULL) {
|
||||
entry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(entry->state == ASSET_ENTRY_STATE_NOT_STARTED) {
|
||||
assetEntryInit(entry, name, type, input);
|
||||
return entry;
|
||||
}
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
if(entry->state == ASSET_ENTRY_STATE_NOT_STARTED) {
|
||||
assetEntryInit(entry, name, type, input);
|
||||
return entry;
|
||||
}
|
||||
entry++;
|
||||
} while(entry < ASSET.entries + ASSET_ENTRY_COUNT_MAX);
|
||||
|
||||
if(reaped) break;
|
||||
reaped = true;
|
||||
errorCatch(assetReapUnused());
|
||||
}
|
||||
|
||||
assertUnreachable("No available asset entry slots.");
|
||||
return NULL;
|
||||
@@ -191,6 +199,32 @@ void assetUnlockEntry(assetentry_t *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) {
|
||||
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);
|
||||
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -413,26 +423,7 @@ errorret_t assetDispose(void) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
threadStop(&ASSET.loadThread);
|
||||
|
||||
// Drain-dispose: 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 *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);
|
||||
errorChain(assetReapUnused());
|
||||
|
||||
// Cleanup zip file.
|
||||
if(ASSET.zip != NULL) {
|
||||
|
||||
+12
-2
@@ -23,8 +23,8 @@
|
||||
#define ASSET_FILE_NAME "dusk.dsk"
|
||||
#define ASSET_HEADER_SIZE 3
|
||||
|
||||
#define ASSET_LOADING_COUNT_MAX 20
|
||||
#define ASSET_ENTRY_COUNT_MAX 128
|
||||
#define ASSET_LOADING_COUNT_MAX 10
|
||||
#define ASSET_ENTRY_COUNT_MAX 64
|
||||
|
||||
typedef struct asset_s {
|
||||
zip_t *zip;
|
||||
@@ -112,6 +112,16 @@ void assetUnlock(const char_t *name);
|
||||
*/
|
||||
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
|
||||
* is fully loaded.
|
||||
|
||||
@@ -15,5 +15,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
add_subdirectory(display)
|
||||
add_subdirectory(locale)
|
||||
add_subdirectory(json)
|
||||
add_subdirectory(chunk)
|
||||
add_subdirectory(dmf)
|
||||
add_subdirectory(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);
|
||||
@@ -46,9 +46,9 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
|
||||
.dispose = assetJsonDispose
|
||||
},
|
||||
|
||||
[ASSET_LOADER_TYPE_CHUNK] = {
|
||||
.loadSync = assetChunkLoaderSync,
|
||||
.loadAsync = assetChunkLoaderAsync,
|
||||
.dispose = assetChunkDispose
|
||||
[ASSET_LOADER_TYPE_ANIMATION] = {
|
||||
.loadSync = assetAnimationLoaderSync,
|
||||
.loadAsync = assetAnimationLoaderAsync,
|
||||
.dispose = assetAnimationDispose
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include "asset/loader/display/assettilesetloader.h"
|
||||
#include "asset/loader/locale/assetlocaleloader.h"
|
||||
#include "asset/loader/json/assetjsonloader.h"
|
||||
#include "asset/loader/chunk/assetchunkloader.h"
|
||||
#include "asset/loader/animation/assetanimationloader.h"
|
||||
|
||||
typedef enum {
|
||||
ASSET_LOADER_TYPE_NULL,
|
||||
@@ -23,7 +23,7 @@ typedef enum {
|
||||
ASSET_LOADER_TYPE_TILESET,
|
||||
ASSET_LOADER_TYPE_LOCALE,
|
||||
ASSET_LOADER_TYPE_JSON,
|
||||
ASSET_LOADER_TYPE_CHUNK,
|
||||
ASSET_LOADER_TYPE_ANIMATION,
|
||||
|
||||
ASSET_LOADER_TYPE_COUNT
|
||||
} assetloadertype_t;
|
||||
@@ -35,7 +35,7 @@ typedef union {
|
||||
assettilesetloaderloading_t tileset;
|
||||
assetlocaleloaderloading_t locale;
|
||||
assetjsonloaderloading_t json;
|
||||
assetchunkloaderloading_t chunk;
|
||||
assetanimationloaderloading_t animation;
|
||||
} assetloaderloading_t;
|
||||
|
||||
typedef union {
|
||||
@@ -45,7 +45,7 @@ typedef union {
|
||||
assettilesetoutput_t tileset;
|
||||
assetlocaleoutput_t locale;
|
||||
assetjsonoutput_t json;
|
||||
assetchunkoutput_t chunk;
|
||||
assetanimationoutput_t animation;
|
||||
} assetloaderoutput_t;
|
||||
|
||||
typedef union {
|
||||
@@ -53,7 +53,6 @@ typedef union {
|
||||
assettilesetloaderinput_t tileset;
|
||||
assetlocaleloaderinput_t locale;
|
||||
assetjsonloaderinput_t json;
|
||||
assetchunkloaderinput_t chunk;
|
||||
} assetloaderinput_t;
|
||||
|
||||
typedef struct assetloading_s assetloading_t;
|
||||
|
||||
@@ -1,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);
|
||||
@@ -48,8 +48,20 @@ errorret_t assetMeshLoaderAsync(assetloading_t *loading) {
|
||||
uint32_t vertCount = endianLittleToHost32(*(uint32_t *)(raw + 8));
|
||||
meshvertex_t *vertices = NULL;
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
@@ -103,22 +115,18 @@ errorret_t assetMeshLoaderSync(assetloading_t *loading) {
|
||||
out->vertices = NULL;
|
||||
errorChain(ret);
|
||||
}
|
||||
out->meshInitialized = true;
|
||||
|
||||
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
|
||||
if(errorIsNotOk(ret)) {
|
||||
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
|
||||
meshDispose(&out->mesh);
|
||||
out->meshInitialized = false;
|
||||
memoryFree(out->vertices);
|
||||
out->vertices = NULL;
|
||||
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;
|
||||
errorOk();
|
||||
}
|
||||
@@ -129,8 +137,11 @@ errorret_t assetMeshDispose(assetentry_t *entry) {
|
||||
assertIsMainThread("Must be called from the main thread.");
|
||||
|
||||
assetmeshoutput_t *out = &entry->data.mesh;
|
||||
if(out->vertices != NULL) {
|
||||
if(out->meshInitialized) {
|
||||
errorChain(meshDispose(&out->mesh));
|
||||
out->meshInitialized = false;
|
||||
}
|
||||
if(out->vertices != NULL) {
|
||||
memoryFree(out->vertices);
|
||||
out->vertices = NULL;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ typedef struct {
|
||||
|
||||
typedef struct {
|
||||
mesh_t mesh;
|
||||
bool_t meshInitialized;
|
||||
meshvertex_t *vertices;
|
||||
} assetmeshoutput_t;
|
||||
|
||||
|
||||
@@ -506,12 +506,17 @@ errorret_t assetLocaleGetString(
|
||||
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
|
||||
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
|
||||
|
||||
while(!reader.eof) {
|
||||
while(true) {
|
||||
// Is this msgid?
|
||||
if(memoryCompare(lineBuffer, "msgid", 5) != 0) {
|
||||
if(reader.eof) break;
|
||||
errorChain(assetFileLineReaderNext(&reader));
|
||||
msgidBuffer[0] = '\0';
|
||||
continue;
|
||||
@@ -535,7 +540,7 @@ errorret_t assetLocaleGetString(
|
||||
}
|
||||
|
||||
// We are either going to see a msgstr or a msgid_plural
|
||||
while(!reader.eof) {
|
||||
while(true) {
|
||||
errorChain(assetLocaleLineSkipBlanks(&reader, lineBuffer));
|
||||
|
||||
// Is msgid_plural?
|
||||
|
||||
@@ -17,7 +17,7 @@ console_t CONSOLE;
|
||||
|
||||
void consoleInit(void) {
|
||||
memoryZero(&CONSOLE, sizeof(console_t));
|
||||
CONSOLE.visible = true;
|
||||
CONSOLE.visible = false;
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadMutexInit(&CONSOLE.printMutex);
|
||||
@@ -42,6 +42,7 @@ void consolePrint(const char_t *message, ...) {
|
||||
(CONSOLE_HISTORY_MAX - 1) * CONSOLE_LINE_MAX
|
||||
);
|
||||
memoryCopy(CONSOLE.line[CONSOLE_HISTORY_MAX - 1], buffer, len + 1);
|
||||
CONSOLE.dirty = true;
|
||||
|
||||
#ifdef DUSK_CONSOLE_POSIX
|
||||
threadMutexUnlock(&CONSOLE.printMutex);
|
||||
@@ -55,7 +56,7 @@ void consoleUpdate(void) {
|
||||
if(TIME.dynamicUpdate) return;
|
||||
#endif
|
||||
|
||||
if(inputPressed(INPUT_ACTION_CONSOLE)) {
|
||||
if(inputPressed(INPUT_BIND_CONSOLE)) {
|
||||
CONSOLE.visible = !CONSOLE.visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ typedef struct {
|
||||
char_t line[CONSOLE_HISTORY_MAX][CONSOLE_LINE_MAX];
|
||||
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
|
||||
threadmutex_t printMutex;
|
||||
#endif
|
||||
|
||||
@@ -12,8 +12,8 @@ mesh_t CUBE_MESH_SIMPLE;
|
||||
meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT];
|
||||
|
||||
errorret_t cubeInit() {
|
||||
vec3 min = { 0.0f, 0.0f, 0.0f };
|
||||
vec3 max = { 1.0f, 1.0f, 1.0f };
|
||||
vec3 min = { -0.5f, -0.5f, -0.5f };
|
||||
vec3 max = { 0.5f, 0.5f, 0.5f };
|
||||
cubeBuffer(CUBE_MESH_SIMPLE_VERTICES, min, max);
|
||||
errorChain(meshInit(
|
||||
&CUBE_MESH_SIMPLE,
|
||||
|
||||
@@ -17,7 +17,9 @@ extern mesh_t CUBE_MESH_SIMPLE;
|
||||
extern meshvertex_t CUBE_MESH_SIMPLE_VERTICES[CUBE_VERTEX_COUNT];
|
||||
|
||||
/**
|
||||
* Initializes the simple unit cube mesh (0,0,0) to (1,1,1).
|
||||
* Initializes the simple unit cube mesh, centered at (0,0,0), spanning
|
||||
* (-0.5,-0.5,-0.5) to (0.5,0.5,0.5) -- matching the centered convention
|
||||
* physics shapes and the sphere mesh use (position = center).
|
||||
*
|
||||
* @return Error for initialization of the cube mesh.
|
||||
*/
|
||||
|
||||
@@ -59,7 +59,8 @@ void planeBuffer(
|
||||
|
||||
switch(axis) {
|
||||
case PLANE_AXIS_XY: {
|
||||
/* Flat in XY at z = min[2]; spans X and Y. */
|
||||
// Flat in XY at z = min[2]; spans X and Y.
|
||||
// +Z normal: CCW when viewed from +Z (matches cube.c's front face).
|
||||
const float_t z = min[2];
|
||||
PLANE_VERT(0, min[0], min[1], z, u0, v0)
|
||||
PLANE_VERT(1, max[0], min[1], z, u1, v0)
|
||||
@@ -71,19 +72,24 @@ void planeBuffer(
|
||||
}
|
||||
|
||||
case PLANE_AXIS_XZ: {
|
||||
/* Flat in XZ at y = min[1]; spans X and Z. */
|
||||
// Flat in XZ at y = min[1]; spans X and Z.
|
||||
// +Y normal: CCW when viewed from +Y (matches cube.c's top face).
|
||||
// X and Z swap handedness relative to XY/YZ (right-hand rule with Y
|
||||
// up), so the corner order here is deliberately not a straight copy
|
||||
// of the XY/YZ pattern -- copying it as-is would flip this to -Y.
|
||||
const float_t y = min[1];
|
||||
PLANE_VERT(0, min[0], y, min[2], u0, v0)
|
||||
PLANE_VERT(1, max[0], y, min[2], u1, v0)
|
||||
PLANE_VERT(2, max[0], y, max[2], u1, v1)
|
||||
PLANE_VERT(1, max[0], y, max[2], u1, v1)
|
||||
PLANE_VERT(2, max[0], y, min[2], u1, v0)
|
||||
PLANE_VERT(3, min[0], y, min[2], u0, v0)
|
||||
PLANE_VERT(4, max[0], y, max[2], u1, v1)
|
||||
PLANE_VERT(5, min[0], y, max[2], u0, v1)
|
||||
PLANE_VERT(4, min[0], y, max[2], u0, v1)
|
||||
PLANE_VERT(5, max[0], y, max[2], u1, v1)
|
||||
break;
|
||||
}
|
||||
|
||||
case PLANE_AXIS_YZ: {
|
||||
/* Flat in YZ at x = min[0]; spans Y and Z. */
|
||||
// Flat in YZ at x = min[0]; spans Y and Z.
|
||||
// +X normal: CCW when viewed from +X (matches cube.c's right face).
|
||||
const float_t x = min[0];
|
||||
PLANE_VERT(0, x, min[1], min[2], u0, v0)
|
||||
PLANE_VERT(1, x, max[1], min[2], u1, v0)
|
||||
|
||||
@@ -21,6 +21,7 @@ errorret_t shaderInit(shader_t *shader, const shaderdefinition_t *def) {
|
||||
|
||||
errorret_t shaderBind(shader_t *shader) {
|
||||
assertNotNull(shader, "Shader cannot be null");
|
||||
if(bound == shader) errorOk();
|
||||
errorChain(shaderBindPlatform(shader));
|
||||
bound = shader;
|
||||
errorOk();
|
||||
|
||||
@@ -39,3 +39,16 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition(
|
||||
sprite.uvMax[1] = uv[3];
|
||||
return sprite;
|
||||
}
|
||||
|
||||
spritebatchsprite_t spriteBatchSpriteTranslate(
|
||||
const spritebatchsprite_t *sprite,
|
||||
const float_t x,
|
||||
const float_t y
|
||||
) {
|
||||
spritebatchsprite_t out = *sprite;
|
||||
out.min[0] += x;
|
||||
out.min[1] += y;
|
||||
out.max[0] += x;
|
||||
out.max[1] += y;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -38,3 +38,19 @@ spritebatchsprite_t spriteBatchSpriteTilesetPosition(
|
||||
const float_t width,
|
||||
const float_t height
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a copy of sprite translated by (x, y). Used to reposition a
|
||||
* sprite cached relative to origin (0,0) at draw time, instead of
|
||||
* re-deriving its geometry from scratch every frame.
|
||||
*
|
||||
* @param sprite The cached sprite, relative to origin (0,0).
|
||||
* @param x X offset to translate by.
|
||||
* @param y Y offset to translate by.
|
||||
* @returns The translated sprite.
|
||||
*/
|
||||
spritebatchsprite_t spriteBatchSpriteTranslate(
|
||||
const spritebatchsprite_t *sprite,
|
||||
const float_t x,
|
||||
const float_t y
|
||||
);
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
font.c
|
||||
text.c
|
||||
)
|
||||
@@ -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();
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "error/error.h"
|
||||
#include "display/texture/texture.h"
|
||||
#include "display/texture/tileset.h"
|
||||
|
||||
@@ -13,3 +14,53 @@ typedef struct {
|
||||
texture_t *texture;
|
||||
tileset_t *tileset;
|
||||
} font_t;
|
||||
|
||||
/**
|
||||
* Pixel width/height of a single default-font glyph tile. Matches the
|
||||
* glyph grid baked into the (now retired) ui/minogram.png + .dtf asset
|
||||
* pair this data was extracted from.
|
||||
*/
|
||||
#define FONT_DEFAULT_TILE_WIDTH 6
|
||||
#define FONT_DEFAULT_TILE_HEIGHT 10
|
||||
|
||||
/** Grid layout of the generated default-font texture, in tiles. */
|
||||
#define FONT_DEFAULT_COLUMNS 16
|
||||
#define FONT_DEFAULT_ROWS 6
|
||||
|
||||
/**
|
||||
* Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS *
|
||||
* FONT_DEFAULT_ROWS), covering the printable ASCII range starting at
|
||||
* TEXT_CHAR_START ('!') plus a couple of unused trailing tiles.
|
||||
*/
|
||||
#define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS)
|
||||
|
||||
extern font_t FONT_DEFAULT;
|
||||
|
||||
/**
|
||||
* Hard coded bitmap data for the built-in default font, extracted
|
||||
* pixel-for-pixel from the original ui/minogram.png glyph atlas (alpha
|
||||
* >= 128 counts as set). Indexed [glyph][row], where glyph 0 corresponds
|
||||
* to TEXT_CHAR_START ('!') and glyphs run consecutively through the
|
||||
* printable ASCII range. Each row byte holds FONT_DEFAULT_TILE_WIDTH bit
|
||||
* flags, one per pixel column: bit (FONT_DEFAULT_TILE_WIDTH - 1) is the
|
||||
* leftmost pixel and bit 0 is the rightmost; 1 means the pixel is set,
|
||||
* 0 means it is not.
|
||||
*/
|
||||
extern const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
|
||||
FONT_DEFAULT_TILE_HEIGHT
|
||||
];
|
||||
|
||||
/**
|
||||
* Builds the built-in default font (texture + tileset) directly from
|
||||
* FONT_DEFAULT_GLYPHS, without going through the asset system.
|
||||
*
|
||||
* @return Either an error or success result.
|
||||
*/
|
||||
errorret_t fontInitDefault(void);
|
||||
|
||||
/**
|
||||
* Disposes of the built-in default font created by fontInitDefault.
|
||||
*
|
||||
* @return Either an error or success result.
|
||||
*/
|
||||
errorret_t fontDisposeDefault(void);
|
||||
|
||||
@@ -9,34 +9,15 @@
|
||||
#include "assert/assert.h"
|
||||
#include "util/memory.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "asset/asset.h"
|
||||
#include "asset/loader/display/assettextureloader.h"
|
||||
#include "asset/loader/display/assettilesetloader.h"
|
||||
#include "display/shader/shaderunlit.h"
|
||||
|
||||
font_t FONT_DEFAULT;
|
||||
|
||||
errorret_t textInit(void) {
|
||||
assetloaderinput_t input = { .texture = TEXTURE_FORMAT_RGBA };
|
||||
assetentry_t *entryTexture = assetLock(
|
||||
"ui/minogram.png", ASSET_LOADER_TYPE_TEXTURE, &input
|
||||
);
|
||||
assetentry_t *entryTileset = assetLock(
|
||||
"ui/minogram.dtf", ASSET_LOADER_TYPE_TILESET, NULL
|
||||
);
|
||||
errorChain(assetRequireLoaded(entryTexture));
|
||||
errorChain(assetRequireLoaded(entryTileset));
|
||||
|
||||
FONT_DEFAULT.texture = &entryTexture->data.texture;
|
||||
FONT_DEFAULT.tileset = &entryTileset->data.tileset;
|
||||
errorChain(fontInitDefault());
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t textDispose(void) {
|
||||
FONT_DEFAULT.texture = NULL;
|
||||
FONT_DEFAULT.tileset = NULL;
|
||||
assetUnlock("ui/minogram.png");
|
||||
assetUnlock("ui/minogram.dtf");
|
||||
errorChain(fontDisposeDefault());
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -116,6 +97,89 @@ errorret_t textDraw(
|
||||
errorOk();
|
||||
}
|
||||
|
||||
uint32_t textBuildSpriteCache(
|
||||
const char_t *text,
|
||||
const font_t *font,
|
||||
spritebatchsprite_t *sprites,
|
||||
const uint32_t spritesMax,
|
||||
int32_t *outWidth,
|
||||
int32_t *outHeight
|
||||
) {
|
||||
assertNotNull(text, "Text cannot be NULL");
|
||||
assertNotNull(font, "Font cannot be NULL");
|
||||
assertNotNull(sprites, "Sprites cannot be NULL");
|
||||
assertNotNull(outWidth, "Output width cannot be NULL");
|
||||
assertNotNull(outHeight, "Output height cannot be NULL");
|
||||
|
||||
uint32_t count = 0;
|
||||
float_t posX = 0.0f;
|
||||
float_t posY = 0.0f;
|
||||
int32_t width = 0;
|
||||
int32_t lineWidth = 0;
|
||||
int32_t height = font->tileset->tileHeight;
|
||||
|
||||
char_t c;
|
||||
int32_t i = 0;
|
||||
while((c = text[i++]) != '\0') {
|
||||
if(c == '\n') {
|
||||
if(lineWidth > width) width = lineWidth;
|
||||
lineWidth = 0;
|
||||
posX = 0.0f;
|
||||
posY += font->tileset->tileHeight;
|
||||
height += font->tileset->tileHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(c == ' ') {
|
||||
posX += font->tileset->tileWidth;
|
||||
lineWidth += font->tileset->tileWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
assertTrue(count < spritesMax, "Text produces too many sprites");
|
||||
sprites[count++] = textGetSprite((vec2){ posX, posY }, c, font);
|
||||
|
||||
posX += font->tileset->tileWidth;
|
||||
lineWidth += font->tileset->tileWidth;
|
||||
}
|
||||
|
||||
if(lineWidth > width) width = lineWidth;
|
||||
*outWidth = width;
|
||||
*outHeight = height;
|
||||
return count;
|
||||
}
|
||||
|
||||
errorret_t textDrawSpriteCache(
|
||||
const spritebatchsprite_t *sprites,
|
||||
const uint32_t spriteCount,
|
||||
spritebatchsprite_t *scratch,
|
||||
const float_t x,
|
||||
const float_t y,
|
||||
const color_t color,
|
||||
texture_t *texture
|
||||
) {
|
||||
assertNotNull(scratch, "Scratch buffer cannot be NULL");
|
||||
assertNotNull(texture, "Texture cannot be NULL");
|
||||
if(spriteCount == 0) errorOk();
|
||||
|
||||
for(uint32_t i = 0; i < spriteCount; i++) {
|
||||
scratch[i] = sprites[i];
|
||||
scratch[i].min[0] += x;
|
||||
scratch[i].min[1] += y;
|
||||
scratch[i].max[0] += x;
|
||||
scratch[i].max[1] += y;
|
||||
}
|
||||
|
||||
shadermaterial_t material = {
|
||||
.unlit = {
|
||||
.color = color,
|
||||
.texture = texture
|
||||
}
|
||||
};
|
||||
errorChain(spriteBatchBuffer(scratch, spriteCount, &SHADER_UNLIT, material));
|
||||
errorOk();
|
||||
}
|
||||
|
||||
void textMeasure(
|
||||
const char_t *text,
|
||||
const font_t *font,
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
#define TEXT_CHAR_START '!'
|
||||
|
||||
extern font_t FONT_DEFAULT;
|
||||
|
||||
/**
|
||||
* Initializes the text system.
|
||||
*
|
||||
@@ -60,6 +58,54 @@ errorret_t textDraw(
|
||||
font_t *font
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds a cache of sprites (glyph geometry + UVs, relative to origin
|
||||
* 0,0) for a string of text. Callers that redraw the same text every
|
||||
* frame (e.g. UI labels/widgets) should build this once and reuse it
|
||||
* via textDrawSpriteCache, instead of re-deriving glyph geometry every
|
||||
* frame the way textDraw does.
|
||||
*
|
||||
* @param text The null-terminated string to build sprites for.
|
||||
* @param font Font to use for tile lookup.
|
||||
* @param sprites Destination array to write sprites into.
|
||||
* @param spritesMax Capacity of the sprites array.
|
||||
* @param outWidth Pointer to store the measured width in pixels.
|
||||
* @param outHeight Pointer to store the measured height in pixels.
|
||||
* @return The number of sprites written.
|
||||
*/
|
||||
uint32_t textBuildSpriteCache(
|
||||
const char_t *text,
|
||||
const font_t *font,
|
||||
spritebatchsprite_t *sprites,
|
||||
const uint32_t spritesMax,
|
||||
int32_t *outWidth,
|
||||
int32_t *outHeight
|
||||
);
|
||||
|
||||
/**
|
||||
* Draws a previously-built sprite cache (see textBuildSpriteCache) at the
|
||||
* given position in a single batched draw call.
|
||||
*
|
||||
* @param sprites Cached sprites, relative to origin 0,0.
|
||||
* @param spriteCount Number of sprites in the cache.
|
||||
* @param scratch Caller-owned scratch buffer, at least spriteCount
|
||||
* entries, used to translate the cached sprites into position.
|
||||
* @param x The x-coordinate to draw the text at.
|
||||
* @param y The y-coordinate to draw the text at.
|
||||
* @param color The color to draw the text in.
|
||||
* @param texture The font's texture to sample glyphs from.
|
||||
* @return Either an error or success result.
|
||||
*/
|
||||
errorret_t textDrawSpriteCache(
|
||||
const spritebatchsprite_t *sprites,
|
||||
const uint32_t spriteCount,
|
||||
spritebatchsprite_t *scratch,
|
||||
const float_t x,
|
||||
const float_t y,
|
||||
const color_t color,
|
||||
texture_t *texture
|
||||
);
|
||||
|
||||
/**
|
||||
* Measures the width and height of the given text string when rendered.
|
||||
*
|
||||
|
||||
+41
-14
@@ -10,16 +10,23 @@
|
||||
#include "time/time.h"
|
||||
#include "input/input.h"
|
||||
#include "locale/localemanager.h"
|
||||
#include "rpg/rpg.h"
|
||||
#include "display/display.h"
|
||||
#include "scene/scene.h"
|
||||
#include "asset/asset.h"
|
||||
#include "script/scriptmanager.h"
|
||||
#include "script/module/scene/modulescene.h"
|
||||
#include "ui/ui.h"
|
||||
#include "assert/assert.h"
|
||||
#include "network/network.h"
|
||||
#include "network/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 "console/console.h"
|
||||
#include "save/save.h"
|
||||
#include "save/savesettings.h"
|
||||
#include "log/log.h"
|
||||
|
||||
engine_t ENGINE;
|
||||
|
||||
@@ -37,13 +44,15 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
errorChain(systemInit());
|
||||
errorChain(inputInit());
|
||||
errorChain(assetInit());
|
||||
// errorChain(saveInit());
|
||||
errorChain(scriptManagerInit());
|
||||
errorChain(saveInit());
|
||||
errorChain(saveSettingsLoad());
|
||||
errorChain(localeManagerInit());
|
||||
errorChain(displayInit());
|
||||
errorChain(uiInit());
|
||||
errorChain(rpgInit());
|
||||
errorChain(networkInit());
|
||||
errorChain(sceneInit());
|
||||
errorChain(gameInit());
|
||||
|
||||
consolePrint("Engine initialized");
|
||||
|
||||
@@ -53,8 +62,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
consolePrint("Assertions real");
|
||||
#endif
|
||||
|
||||
sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
|
||||
// sceneSet(SCENE_TYPE_OVERWORLD);
|
||||
|
||||
errorOk();
|
||||
}
|
||||
@@ -62,17 +70,30 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
||||
errorret_t engineUpdate(void) {
|
||||
// Order here is important.
|
||||
errorChain(networkUpdate());
|
||||
errorChain(clientUpdate());
|
||||
errorChain(serverOnlineUpdate());
|
||||
timeUpdate();
|
||||
inputUpdate();
|
||||
consoleUpdate();
|
||||
errorChain(rpgUpdate());
|
||||
errorChain(sceneUpdate());
|
||||
errorChain(assetUpdate());
|
||||
errorChain(uiUpdate());
|
||||
|
||||
const systemdialogtype_t dialogType = systemGetActiveDialogType();
|
||||
if(dialogType == SYSTEM_DIALOG_TYPE_NONE) {
|
||||
inputUpdate();
|
||||
consoleUpdate();
|
||||
|
||||
errorChain(gameUpdate());
|
||||
errorChain(moduleSceneUpdateCurrent());
|
||||
errorChain(sceneUpdate());
|
||||
errorChain(assetUpdate());
|
||||
errorChain(uiUpdate());
|
||||
}
|
||||
|
||||
// Render
|
||||
errorChain(displayUpdate());
|
||||
if(inputPressed(INPUT_ACTION_RAGEQUIT)) ENGINE.running = false;
|
||||
if(
|
||||
dialogType == SYSTEM_DIALOG_TYPE_NONE &&
|
||||
inputPressed(INPUT_BIND_RAGEQUIT)
|
||||
) {
|
||||
ENGINE.running = false;
|
||||
}
|
||||
errorOk();
|
||||
}
|
||||
|
||||
@@ -81,14 +102,20 @@ void engineExit(void) {
|
||||
}
|
||||
|
||||
errorret_t engineDispose(void) {
|
||||
errorChain(gameDispose());
|
||||
errorChain(sceneDispose());
|
||||
|
||||
errorChain(serverOnlineStop());
|
||||
client_t *localClient = clientRosterFindLocal();
|
||||
if(localClient) localClientDisconnect(&localClient->local);
|
||||
|
||||
errorChain(networkDispose());
|
||||
errorChain(rpgDispose());
|
||||
localeManagerDispose();
|
||||
errorChain(uiDispose());
|
||||
consoleDispose();
|
||||
errorChain(displayDispose());
|
||||
// errorChain(saveDispose());
|
||||
errorChain(saveDispose());
|
||||
errorChain(scriptManagerDispose());
|
||||
errorChain(assetDispose());
|
||||
|
||||
errorOk();
|
||||
|
||||
@@ -37,4 +37,3 @@ errorret_t engineUpdate(void);
|
||||
* Shuts down the engine.
|
||||
*/
|
||||
errorret_t engineDispose(void);
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
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
|
||||
);
|
||||
+3
-4
@@ -6,8 +6,7 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
npc.c
|
||||
npcturn.c
|
||||
npcwalk.c
|
||||
npcpath.c
|
||||
entityposition.c
|
||||
entitycamera.c
|
||||
entityrenderable.c
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "entity/entitymanager.h"
|
||||
#include "entity/entity.h"
|
||||
#include "entity/component/display/entityposition.h"
|
||||
#include "display/screen/screen.h"
|
||||
|
||||
void entityCameraInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entitycamera_t *cam = (entitycamera_t *)componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_CAMERA
|
||||
);
|
||||
cam->nearClip = 0.1f;
|
||||
cam->farClip = 5000.0f;
|
||||
cam->projType = ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE;
|
||||
cam->perspective.fov = glm_rad(45.0f);
|
||||
}
|
||||
|
||||
void entityCameraGetProjection(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 out
|
||||
) {
|
||||
entitycamera_t *cam = (entitycamera_t *)componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_CAMERA
|
||||
);
|
||||
|
||||
if(
|
||||
cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE ||
|
||||
cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED
|
||||
) {
|
||||
glm_mat4_identity(out);
|
||||
glm_perspective(
|
||||
cam->perspective.fov,
|
||||
SCREEN.aspect,
|
||||
cam->nearClip,
|
||||
cam->farClip,
|
||||
out
|
||||
);
|
||||
|
||||
if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED) {
|
||||
out[1][1] *= -1.0f;
|
||||
}
|
||||
} else if(cam->projType == ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC) {
|
||||
glm_mat4_identity(out);
|
||||
glm_ortho(
|
||||
cam->orthographic.left,
|
||||
cam->orthographic.right,
|
||||
cam->orthographic.top,
|
||||
cam->orthographic.bottom,
|
||||
cam->nearClip,
|
||||
cam->farClip,
|
||||
out
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entityid_t entityCameraGetCurrent(entitymanager_t *mgr) {
|
||||
entityid_t camEnts[ENTITY_COUNT_MAX];
|
||||
componentid_t camComps[ENTITY_COUNT_MAX];
|
||||
entityid_t count = componentGetEntitiesWithComponent(
|
||||
mgr, COMPONENT_TYPE_CAMERA, camEnts, camComps
|
||||
);
|
||||
if(count == 0) return ENTITY_ID_INVALID;
|
||||
return camEnts[0];
|
||||
}
|
||||
|
||||
void entityCameraGetForward(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
vec2 out
|
||||
) {
|
||||
componentid_t posComp = entityGetComponent(
|
||||
mgr, entityId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
mat4 transform;
|
||||
entityPositionGetTransform(mgr, entityId, posComp, transform);
|
||||
// transform is an object->world matrix; column 2 is the entity's local Z
|
||||
// axis expressed in world space. Cameras look down their local -Z.
|
||||
float_t fx = -transform[2][0];
|
||||
float_t fz = -transform[2][2];
|
||||
float_t len = sqrtf(fx * fx + fz * fz);
|
||||
if(len > 1e-6f) { fx /= len; fz /= len; }
|
||||
out[0] = fx;
|
||||
out[1] = fz;
|
||||
}
|
||||
|
||||
void entityCameraGetRight(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
vec2 out
|
||||
) {
|
||||
componentid_t posComp = entityGetComponent(
|
||||
mgr, entityId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
mat4 transform;
|
||||
entityPositionGetTransform(mgr, entityId, posComp, transform);
|
||||
// transform is an object->world matrix; column 0 is the entity's local X
|
||||
// (right) axis expressed in world space.
|
||||
float_t rx = transform[0][0];
|
||||
float_t rz = transform[0][2];
|
||||
float_t len = sqrtf(rx * rx + rz * rz);
|
||||
if(len > 1e-6f) { rx /= len; rz /= len; }
|
||||
out[0] = rx;
|
||||
out[1] = rz;
|
||||
}
|
||||
|
||||
void entityCameraLookAtPixelPerfect(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t ent,
|
||||
const componentid_t posComp,
|
||||
const componentid_t camComp,
|
||||
const vec3 point,
|
||||
const vec3 eyeOffset,
|
||||
const float_t scale
|
||||
) {
|
||||
entitycamera_t *cam = (entitycamera_t *)componentGetData(
|
||||
mgr, ent, camComp, COMPONENT_TYPE_CAMERA
|
||||
);
|
||||
float_t dist = (
|
||||
(float_t)SCREEN.height / (2.0f * scale * tanf(cam->perspective.fov * 0.5f))
|
||||
);
|
||||
|
||||
vec3 eye = {
|
||||
point[0] + eyeOffset[0],
|
||||
point[1] + dist + eyeOffset[1],
|
||||
point[2] + eyeOffset[2]
|
||||
};
|
||||
vec3 up = { 0.0f, 0.0f, -1.0f };
|
||||
entityPositionLookAt(mgr, ent, posComp, eye, (float_t *)point, up);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "entity/entitybase.h"
|
||||
#include "error/error.h"
|
||||
|
||||
typedef enum {
|
||||
ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE,
|
||||
ENTITY_CAMERA_PROJECTION_TYPE_PERSPECTIVE_FLIPPED,
|
||||
ENTITY_CAMERA_PROJECTION_TYPE_ORTHOGRAPHIC
|
||||
} entitycameraprojectiontype_t;
|
||||
|
||||
typedef struct {
|
||||
union {
|
||||
struct {
|
||||
float_t fov;
|
||||
} perspective;
|
||||
|
||||
struct {
|
||||
float_t left;
|
||||
float_t right;
|
||||
float_t top;
|
||||
float_t bottom;
|
||||
} orthographic;
|
||||
};
|
||||
|
||||
float_t nearClip;
|
||||
float_t farClip;
|
||||
entitycameraprojectiontype_t projType;
|
||||
} entitycamera_t;
|
||||
|
||||
/**
|
||||
* Initializes an entity camera component.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
*/
|
||||
void entityCameraInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders out the projection matrix for the given camera.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param out The output projection matrix.
|
||||
*/
|
||||
void entityCameraGetProjection(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 out
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the entity ID of the first active camera, or ENTITY_ID_INVALID if
|
||||
* none are active.
|
||||
*
|
||||
* @param mgr The entity manager to search.
|
||||
*/
|
||||
entityid_t entityCameraGetCurrent(entitymanager_t *mgr);
|
||||
|
||||
/**
|
||||
* Gets the camera's horizontal forward direction (XZ plane) from its
|
||||
* position component. Automatically finds the position component on the
|
||||
* entity.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The camera entity ID.
|
||||
* @param out Output vec2: {forwardX, forwardZ} normalized.
|
||||
*/
|
||||
void entityCameraGetForward(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
vec2 out
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the camera's horizontal right direction (XZ plane) from its position
|
||||
* component. Automatically finds the position component on the entity.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The camera entity ID.
|
||||
* @param out Output vec2: {rightX, rightZ} normalized.
|
||||
*/
|
||||
void entityCameraGetRight(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
vec2 out
|
||||
);
|
||||
|
||||
/**
|
||||
* Positions the camera to look at a 3D point at a pixel-perfect distance
|
||||
* derived from the camera's FOV and screen height.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param ent The camera entity ID.
|
||||
* @param posComp The position component ID.
|
||||
* @param camComp The camera component ID.
|
||||
* @param point World position to look at.
|
||||
* @param eyeOffset Offset added to the eye position only (not the target).
|
||||
* @param scale Pixels per world unit. 1.0 = pixel perfect, 2.0 = 2px per unit.
|
||||
*/
|
||||
void entityCameraLookAtPixelPerfect(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t ent,
|
||||
const componentid_t posComp,
|
||||
const componentid_t camComp,
|
||||
const vec3 point,
|
||||
const vec3 eyeOffset,
|
||||
const float_t scale
|
||||
);
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "entity/entitymanager.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
void entityPositionInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
|
||||
pos->flags = 0;
|
||||
pos->parentEntityId = ENTITY_ID_INVALID;
|
||||
pos->parentComponentId = COMPONENT_ID_INVALID;
|
||||
pos->childCount = 0;
|
||||
glm_vec3_zero(pos->position);
|
||||
glm_vec3_zero(pos->rotation);
|
||||
glm_vec3_one(pos->scale);
|
||||
glm_mat4_identity(pos->localTransform);
|
||||
glm_mat4_identity(pos->worldTransform);
|
||||
}
|
||||
|
||||
void entityPositionLookAt(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 eye,
|
||||
vec3 target,
|
||||
vec3 up
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
// glm_lookat() produces a view matrix (world -> eye space). Every other
|
||||
// setter treats localTransform as this entity's placement in world space
|
||||
// (eye -> world), so invert it here to keep that meaning consistent --
|
||||
// callers that need a view matrix (e.g. scene rendering) invert it back.
|
||||
mat4 view;
|
||||
glm_lookat(eye, target, up, view);
|
||||
glm_mat4_inv(view, pos->localTransform);
|
||||
// localTransform is now authoritative; PRS cache is stale.
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_PRS_DIRTY)
|
||||
& ~(ENTITY_POSITION_FLAG_ROTATION_DIRTY |
|
||||
ENTITY_POSITION_FLAG_POSITION_DIRTY);
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionGetTransform(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureWorld(mgr, pos);
|
||||
glm_mat4_copy(
|
||||
pos->parentEntityId == ENTITY_ID_INVALID
|
||||
? pos->localTransform : pos->worldTransform,
|
||||
dest
|
||||
);
|
||||
}
|
||||
|
||||
void entityPositionGetLocalTransform(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureLocal(pos);
|
||||
glm_mat4_copy(pos->localTransform, dest);
|
||||
}
|
||||
|
||||
void entityPositionGetLocalPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->position, dest);
|
||||
}
|
||||
|
||||
void entityPositionGetWorldPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->position, dest);
|
||||
return;
|
||||
}
|
||||
entityPositionEnsureWorld(mgr, pos);
|
||||
dest[0] = pos->worldTransform[3][0];
|
||||
dest[1] = pos->worldTransform[3][1];
|
||||
dest[2] = pos->worldTransform[3][2];
|
||||
}
|
||||
|
||||
void entityPositionSetWorldPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 position
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
glm_vec3_copy(position, pos->position);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
return;
|
||||
}
|
||||
entityposition_t *parent = componentGetData(
|
||||
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureWorld(mgr, parent);
|
||||
mat4 invParent;
|
||||
glm_mat4_inv(parent->worldTransform, invParent);
|
||||
vec3 localPos;
|
||||
glm_mat4_mulv3(invParent, position, 1.0f, localPos);
|
||||
glm_vec3_copy(localPos, pos->position);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionSetLocalPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 position
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
glm_vec3_copy(position, pos->position);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_POSITION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionGetLocalRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->rotation, dest);
|
||||
}
|
||||
|
||||
void entityPositionGetWorldRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->rotation, dest);
|
||||
return;
|
||||
}
|
||||
entityPositionEnsureWorld(mgr, pos);
|
||||
const float_t (*wt)[4] = pos->worldTransform;
|
||||
const float_t sx = sqrtf(
|
||||
wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2]
|
||||
);
|
||||
const float_t sy = sqrtf(
|
||||
wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2]
|
||||
);
|
||||
const float_t sz = sqrtf(
|
||||
wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2]
|
||||
);
|
||||
const float_t r00 = sx > 0.0f ? wt[0][0]/sx : 0.0f;
|
||||
const float_t r10 = sy > 0.0f ? wt[1][0]/sy : 0.0f;
|
||||
const float_t r20 = sz > 0.0f ? wt[2][0]/sz : 0.0f;
|
||||
const float_t r01 = sx > 0.0f ? wt[0][1]/sx : 0.0f;
|
||||
const float_t r11 = sy > 0.0f ? wt[1][1]/sy : 0.0f;
|
||||
const float_t r21 = sz > 0.0f ? wt[2][1]/sz : 0.0f;
|
||||
const float_t r22 = sz > 0.0f ? wt[2][2]/sz : 0.0f;
|
||||
const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f);
|
||||
dest[1] = asinf(sinBeta);
|
||||
const float_t cosBeta = cosf(dest[1]);
|
||||
if(fabsf(cosBeta) > 1e-6f) {
|
||||
dest[0] = atan2f(-r21, r22);
|
||||
dest[2] = atan2f(-r10, r00);
|
||||
} else {
|
||||
dest[2] = 0.0f;
|
||||
dest[0] = (sinBeta > 0.0f) ? atan2f(r01, r11) : -atan2f(r01, r11);
|
||||
}
|
||||
}
|
||||
|
||||
void entityPositionSetLocalRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 rotation
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
glm_vec3_copy(rotation, pos->rotation);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionSetWorldRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 rotation
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
glm_vec3_copy(rotation, pos->rotation);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
return;
|
||||
}
|
||||
entityposition_t *parent = componentGetData(
|
||||
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureWorld(mgr, parent);
|
||||
|
||||
// Build target world rotation matrix (unit scale) from XYZ euler.
|
||||
const float_t c0 = cosf(rotation[0]), s0 = sinf(rotation[0]);
|
||||
const float_t c1 = cosf(rotation[1]), s1 = sinf(rotation[1]);
|
||||
const float_t c2 = cosf(rotation[2]), s2 = sinf(rotation[2]);
|
||||
const float_t s0s1 = s0*s1, c0s1 = c0*s1;
|
||||
// Named wr[col_stored][row_stored] matching cglm column-major layout.
|
||||
const float_t wr00 = c1*c2;
|
||||
const float_t wr01 = c0*s2 + s0s1*c2;
|
||||
const float_t wr02 = s0*s2 - c0s1*c2;
|
||||
const float_t wr10 = -c1*s2;
|
||||
const float_t wr11 = c0*c2 - s0s1*s2;
|
||||
const float_t wr12 = s0*c2 + c0s1*s2;
|
||||
const float_t wr20 = s1;
|
||||
const float_t wr21 = -s0*c1;
|
||||
const float_t wr22 = c0*c1;
|
||||
|
||||
// Normalize parent world columns to extract pure rotation.
|
||||
const float_t (*pt)[4] = parent->worldTransform;
|
||||
const float_t psx = sqrtf(
|
||||
pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2]
|
||||
);
|
||||
const float_t psy = sqrtf(
|
||||
pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2]
|
||||
);
|
||||
const float_t psz = sqrtf(
|
||||
pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2]
|
||||
);
|
||||
const float_t pr00 = psx > 0.f ? pt[0][0]/psx : 0.f;
|
||||
const float_t pr01 = psx > 0.f ? pt[0][1]/psx : 0.f;
|
||||
const float_t pr02 = psx > 0.f ? pt[0][2]/psx : 0.f;
|
||||
const float_t pr10 = psy > 0.f ? pt[1][0]/psy : 0.f;
|
||||
const float_t pr11 = psy > 0.f ? pt[1][1]/psy : 0.f;
|
||||
const float_t pr12 = psy > 0.f ? pt[1][2]/psy : 0.f;
|
||||
const float_t pr20 = psz > 0.f ? pt[2][0]/psz : 0.f;
|
||||
const float_t pr21 = psz > 0.f ? pt[2][1]/psz : 0.f;
|
||||
const float_t pr22 = psz > 0.f ? pt[2][2]/psz : 0.f;
|
||||
|
||||
// local_R = parent_R^T * world_R (R^-1 == R^T for orthogonal matrices).
|
||||
// Compute only the 7 entries of the local rotation matrix needed for XYZ
|
||||
// euler extraction (stored column-major: [col][row] = math [row][col]).
|
||||
// sinBeta = stored[2][0] = math[0][2]
|
||||
// r21/r22 = stored[2][1..2] = math[1..2][2]
|
||||
// r10/r00 = stored[1][0], stored[0][0] = math[0][1], math[0][0]
|
||||
// gimbal = stored[0][1], stored[1][1] = math[1][0], math[1][1]
|
||||
const float_t lr00 = pr00*wr00 + pr01*wr10 + pr02*wr20; // math[0][0]
|
||||
const float_t lr10 = pr00*wr01 + pr01*wr11 + pr02*wr21; // math[0][1]
|
||||
const float_t lr20 = pr00*wr02 + pr01*wr12 + pr02*wr22; // [0][2] -> sinBeta
|
||||
const float_t lr01 = pr10*wr00 + pr11*wr10 + pr12*wr20; // math[1][0]
|
||||
const float_t lr11 = pr10*wr01 + pr11*wr11 + pr12*wr21; // math[1][1]
|
||||
const float_t lr21 = pr10*wr02 + pr11*wr12 + pr12*wr22; // [1][2] -> r21
|
||||
const float_t lr22 = pr20*wr02 + pr21*wr12 + pr22*wr22; // [2][2] -> r22
|
||||
|
||||
const float_t sinBeta = glm_clamp(lr20, -1.0f, 1.0f);
|
||||
pos->rotation[1] = asinf(sinBeta);
|
||||
const float_t cosBeta = cosf(pos->rotation[1]);
|
||||
if(fabsf(cosBeta) > 1e-6f) {
|
||||
pos->rotation[0] = atan2f(-lr21, lr22);
|
||||
pos->rotation[2] = atan2f(-lr10, lr00);
|
||||
} else {
|
||||
pos->rotation[2] = 0.0f;
|
||||
pos->rotation[0] = (sinBeta > 0.0f)
|
||||
? atan2f(lr01, lr11) : -atan2f(lr01, lr11);
|
||||
}
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionGetLocalScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->scale, dest);
|
||||
}
|
||||
|
||||
void entityPositionGetWorldScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
entityPositionEnsurePRS(pos);
|
||||
glm_vec3_copy(pos->scale, dest);
|
||||
return;
|
||||
}
|
||||
entityPositionEnsureWorld(mgr, pos);
|
||||
const float_t (*wt)[4] = pos->worldTransform;
|
||||
dest[0] = sqrtf(wt[0][0]*wt[0][0] + wt[0][1]*wt[0][1] + wt[0][2]*wt[0][2]);
|
||||
dest[1] = sqrtf(wt[1][0]*wt[1][0] + wt[1][1]*wt[1][1] + wt[1][2]*wt[1][2]);
|
||||
dest[2] = sqrtf(wt[2][0]*wt[2][0] + wt[2][1]*wt[2][1] + wt[2][2]*wt[2][2]);
|
||||
}
|
||||
|
||||
void entityPositionSetLocalScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 scale
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
glm_vec3_copy(scale, pos->scale);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionSetWorldScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 scale
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(pos->parentEntityId == ENTITY_ID_INVALID) {
|
||||
glm_vec3_copy(scale, pos->scale);
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
return;
|
||||
}
|
||||
entityposition_t *parent = componentGetData(
|
||||
mgr, pos->parentEntityId, pos->parentComponentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureWorld(mgr, parent);
|
||||
const float_t (*pt)[4] = parent->worldTransform;
|
||||
const float_t psx = sqrtf(
|
||||
pt[0][0]*pt[0][0] + pt[0][1]*pt[0][1] + pt[0][2]*pt[0][2]
|
||||
);
|
||||
const float_t psy = sqrtf(
|
||||
pt[1][0]*pt[1][0] + pt[1][1]*pt[1][1] + pt[1][2]*pt[1][2]
|
||||
);
|
||||
const float_t psz = sqrtf(
|
||||
pt[2][0]*pt[2][0] + pt[2][1]*pt[2][1] + pt[2][2]*pt[2][2]
|
||||
);
|
||||
pos->scale[0] = psx > 0.0f ? scale[0] / psx : scale[0];
|
||||
pos->scale[1] = psy > 0.0f ? scale[1] / psy : scale[1];
|
||||
pos->scale[2] = psz > 0.0f ? scale[2] / psz : scale[2];
|
||||
pos->flags = (pos->flags | ENTITY_POSITION_FLAG_ROTATION_DIRTY)
|
||||
& ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionSetParent(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityid_t parentEntityId,
|
||||
const componentid_t parentComponentId
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
|
||||
// Remove from old parent's child list.
|
||||
if(pos->parentEntityId != ENTITY_ID_INVALID) {
|
||||
entityposition_t *oldParent = componentGetData(
|
||||
mgr, pos->parentEntityId, pos->parentComponentId,
|
||||
COMPONENT_TYPE_POSITION
|
||||
);
|
||||
for(uint8_t i = 0; i < oldParent->childCount; i++) {
|
||||
if(
|
||||
oldParent->childEntityIds[i] == entityId &&
|
||||
oldParent->childComponentIds[i] == componentId
|
||||
) {
|
||||
oldParent->childCount--;
|
||||
for(uint8_t j = i; j < oldParent->childCount; j++) {
|
||||
oldParent->childEntityIds[j] = oldParent->childEntityIds[j + 1];
|
||||
oldParent->childComponentIds[j] = oldParent->childComponentIds[j + 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos->parentEntityId = parentEntityId;
|
||||
pos->parentComponentId = parentComponentId;
|
||||
|
||||
// Register with new parent.
|
||||
if(parentEntityId != ENTITY_ID_INVALID) {
|
||||
entityposition_t *parent = componentGetData(
|
||||
mgr, parentEntityId, parentComponentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
if(parent->childCount < ENTITY_POSITION_CHILDREN_MAX) {
|
||||
parent->childEntityIds[parent->childCount] = entityId;
|
||||
parent->childComponentIds[parent->childCount] = componentId;
|
||||
parent->childCount++;
|
||||
}
|
||||
}
|
||||
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
entityid_t entityPositionGetParent(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entityposition_t *pos = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
return pos->parentEntityId;
|
||||
}
|
||||
|
||||
entityposition_t *entityPositionGet(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
return componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_POSITION
|
||||
);
|
||||
}
|
||||
|
||||
void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos) {
|
||||
pos->flags = (
|
||||
pos->flags |
|
||||
ENTITY_POSITION_FLAG_ROTATION_DIRTY |
|
||||
ENTITY_POSITION_FLAG_POSITION_DIRTY
|
||||
) & ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
entityPositionMarkDirty(mgr, pos);
|
||||
}
|
||||
|
||||
void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos) {
|
||||
if(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY) return;
|
||||
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
|
||||
for(uint8_t i = 0; i < pos->childCount; i++) {
|
||||
entityposition_t *child = componentGetData(
|
||||
mgr, pos->childEntityIds[i], pos->childComponentIds[i],
|
||||
COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionMarkDirty(mgr, child);
|
||||
}
|
||||
}
|
||||
|
||||
void entityPositionDisposeDeep(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entityposition_t *pos = entityPositionGet(mgr, entityId, componentId);
|
||||
|
||||
// Detach from parent so the parent's child list stays consistent.
|
||||
if(pos->parentEntityId != ENTITY_ID_INVALID) {
|
||||
entityPositionSetParent(
|
||||
mgr, entityId, componentId, ENTITY_ID_INVALID, COMPONENT_ID_INVALID
|
||||
);
|
||||
}
|
||||
|
||||
// Copy the child list before disposing self (entityDispose invalidates
|
||||
// pos).
|
||||
uint8_t childCount = pos->childCount;
|
||||
entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX];
|
||||
componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX];
|
||||
for(uint8_t i = 0; i < childCount; i++) {
|
||||
childEntityIds[i] = pos->childEntityIds[i];
|
||||
childComponentIds[i] = pos->childComponentIds[i];
|
||||
// Sever child's parent link so it won't try to modify our disposed
|
||||
// data.
|
||||
entityposition_t *child = entityPositionGet(
|
||||
mgr, childEntityIds[i], childComponentIds[i]
|
||||
);
|
||||
child->parentEntityId = ENTITY_ID_INVALID;
|
||||
child->parentComponentId = COMPONENT_ID_INVALID;
|
||||
}
|
||||
|
||||
entityDispose(mgr, entityId);
|
||||
|
||||
for(uint8_t i = 0; i < childCount; i++) {
|
||||
entityPositionDisposeDeep(mgr, childEntityIds[i], childComponentIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void entityPositionDecompose(entityposition_t *pos) {
|
||||
// Translation: column 3
|
||||
pos->position[0] = pos->localTransform[3][0];
|
||||
pos->position[1] = pos->localTransform[3][1];
|
||||
pos->position[2] = pos->localTransform[3][2];
|
||||
|
||||
// Scale: length of each basis column (xyz only)
|
||||
pos->scale[0] = sqrtf(
|
||||
pos->localTransform[0][0] * pos->localTransform[0][0] +
|
||||
pos->localTransform[0][1] * pos->localTransform[0][1] +
|
||||
pos->localTransform[0][2] * pos->localTransform[0][2]
|
||||
);
|
||||
pos->scale[1] = sqrtf(
|
||||
pos->localTransform[1][0] * pos->localTransform[1][0] +
|
||||
pos->localTransform[1][1] * pos->localTransform[1][1] +
|
||||
pos->localTransform[1][2] * pos->localTransform[1][2]
|
||||
);
|
||||
pos->scale[2] = sqrtf(
|
||||
pos->localTransform[2][0] * pos->localTransform[2][0] +
|
||||
pos->localTransform[2][1] * pos->localTransform[2][1] +
|
||||
pos->localTransform[2][2] * pos->localTransform[2][2]
|
||||
);
|
||||
|
||||
// Normalize columns to isolate the rotation matrix (no mat4 needed).
|
||||
const float_t invS0 = pos->scale[0] > 0.0f ? 1.0f / pos->scale[0] : 0.0f;
|
||||
const float_t invS1 = pos->scale[1] > 0.0f ? 1.0f / pos->scale[1] : 0.0f;
|
||||
const float_t invS2 = pos->scale[2] > 0.0f ? 1.0f / pos->scale[2] : 0.0f;
|
||||
|
||||
const float_t r00 = pos->localTransform[0][0] * invS0;
|
||||
const float_t r01 = pos->localTransform[0][1] * invS0;
|
||||
const float_t r02 = pos->localTransform[0][2] * invS0;
|
||||
const float_t r10 = pos->localTransform[1][0] * invS1;
|
||||
const float_t r11 = pos->localTransform[1][1] * invS1;
|
||||
const float_t r20 = pos->localTransform[2][0] * invS2;
|
||||
const float_t r21 = pos->localTransform[2][1] * invS2;
|
||||
const float_t r22 = pos->localTransform[2][2] * invS2;
|
||||
|
||||
// Extract XYZ euler angles (R = Rx * Ry * Rz, column-major)
|
||||
const float_t sinBeta = glm_clamp(r20, -1.0f, 1.0f);
|
||||
pos->rotation[1] = asinf(sinBeta);
|
||||
const float_t cosBeta = cosf(pos->rotation[1]);
|
||||
|
||||
if(fabsf(cosBeta) > 1e-6f) {
|
||||
pos->rotation[0] = atan2f(-r21, r22);
|
||||
pos->rotation[2] = atan2f(-r10, r00);
|
||||
} else {
|
||||
// Gimbal lock: pin Z to 0, recover X.
|
||||
pos->rotation[2] = 0.0f;
|
||||
pos->rotation[0] = (sinBeta > 0.0f)
|
||||
? atan2f(r01, r11)
|
||||
: -atan2f(r01, r11);
|
||||
}
|
||||
}
|
||||
|
||||
void entityPositionEnsurePRS(entityposition_t *pos) {
|
||||
if(!(pos->flags & ENTITY_POSITION_FLAG_PRS_DIRTY)) return;
|
||||
entityPositionDecompose(pos);
|
||||
pos->flags &= ~ENTITY_POSITION_FLAG_PRS_DIRTY;
|
||||
}
|
||||
|
||||
void entityPositionEnsureLocal(entityposition_t *pos) {
|
||||
const uint8_t dirty = pos->flags & (
|
||||
ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY
|
||||
);
|
||||
if(!dirty) return;
|
||||
|
||||
if(dirty & ENTITY_POSITION_FLAG_ROTATION_DIRTY) {
|
||||
// Rotation or scale changed: rebuild cols 0-2 analytically (XYZ euler).
|
||||
const float_t c0 = cosf(pos->rotation[0]), s0 = sinf(pos->rotation[0]);
|
||||
const float_t c1 = cosf(pos->rotation[1]), s1 = sinf(pos->rotation[1]);
|
||||
const float_t c2 = cosf(pos->rotation[2]), s2 = sinf(pos->rotation[2]);
|
||||
const float_t s0s1 = s0 * s1;
|
||||
const float_t c0s1 = c0 * s1;
|
||||
|
||||
pos->localTransform[0][0] = c1 * c2 * pos->scale[0];
|
||||
pos->localTransform[0][1] = (c0 * s2 + s0s1 * c2) * pos->scale[0];
|
||||
pos->localTransform[0][2] = (s0 * s2 - c0s1 * c2) * pos->scale[0];
|
||||
pos->localTransform[0][3] = 0.0f;
|
||||
|
||||
pos->localTransform[1][0] = -c1 * s2 * pos->scale[1];
|
||||
pos->localTransform[1][1] = (c0 * c2 - s0s1 * s2) * pos->scale[1];
|
||||
pos->localTransform[1][2] = (s0 * c2 + c0s1 * s2) * pos->scale[1];
|
||||
pos->localTransform[1][3] = 0.0f;
|
||||
|
||||
pos->localTransform[2][0] = s1 * pos->scale[2];
|
||||
pos->localTransform[2][1] = -s0 * c1 * pos->scale[2];
|
||||
pos->localTransform[2][2] = c0 * c1 * pos->scale[2];
|
||||
pos->localTransform[2][3] = 0.0f;
|
||||
}
|
||||
|
||||
if(dirty & ENTITY_POSITION_FLAG_POSITION_DIRTY) {
|
||||
// Only position changed: update column 3 only (no trig needed).
|
||||
pos->localTransform[3][0] = pos->position[0];
|
||||
pos->localTransform[3][1] = pos->position[1];
|
||||
pos->localTransform[3][2] = pos->position[2];
|
||||
pos->localTransform[3][3] = 1.0f;
|
||||
}
|
||||
|
||||
pos->flags &= ~(
|
||||
ENTITY_POSITION_FLAG_ROTATION_DIRTY | ENTITY_POSITION_FLAG_POSITION_DIRTY
|
||||
);
|
||||
}
|
||||
|
||||
void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos) {
|
||||
if(!(pos->flags & ENTITY_POSITION_FLAG_WORLD_DIRTY)) return;
|
||||
entityPositionEnsureLocal(pos);
|
||||
|
||||
if(pos->parentEntityId != ENTITY_ID_INVALID) {
|
||||
// Parented: world = parent.world x local. worldTransform must be
|
||||
// written because children (and this node's getters) read it.
|
||||
entityposition_t *parent = componentGetData(
|
||||
mgr, pos->parentEntityId, pos->parentComponentId,
|
||||
COMPONENT_TYPE_POSITION
|
||||
);
|
||||
entityPositionEnsureWorld(mgr, parent);
|
||||
glm_mat4_mul(
|
||||
parent->worldTransform, pos->localTransform, pos->worldTransform
|
||||
);
|
||||
} else if(pos->childCount > 0) {
|
||||
// Parentless root with children: children need a valid worldTransform
|
||||
// to multiply against, but world == local, so just copy.
|
||||
glm_mat4_copy(pos->localTransform, pos->worldTransform);
|
||||
}
|
||||
// Parentless leaf: world == local. Getters read localTransform directly;
|
||||
// no copy needed.
|
||||
|
||||
pos->flags &= ~ENTITY_POSITION_FLAG_WORLD_DIRTY;
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "entity/entitybase.h"
|
||||
#include "error/error.h"
|
||||
|
||||
/** Maximum number of child position components this node can track. */
|
||||
#define ENTITY_POSITION_CHILDREN_MAX 8
|
||||
|
||||
/**
|
||||
* PRS cache is stale. localTransform was written directly (e.g. lookAt) and
|
||||
* position/rotation/scale need to be decomposed before they can be read.
|
||||
*/
|
||||
#define ENTITY_POSITION_FLAG_PRS_DIRTY (1 << 0)
|
||||
|
||||
/**
|
||||
* Columns 0-2 of localTransform are stale. Rotation or scale changed; the
|
||||
* basis vectors need to be rebuilt analytically before the matrix can be used.
|
||||
* Does not imply column 3 (translation) is stale.
|
||||
*/
|
||||
#define ENTITY_POSITION_FLAG_ROTATION_DIRTY (1 << 1)
|
||||
|
||||
/**
|
||||
* Column 3 of localTransform is stale. Position changed; only the
|
||||
* translation column needs to be written. Does not imply columns 0-2 are
|
||||
* stale.
|
||||
*/
|
||||
#define ENTITY_POSITION_FLAG_POSITION_DIRTY (1 << 2)
|
||||
|
||||
/**
|
||||
* worldTransform is stale. Either the local matrix changed or an ancestor
|
||||
* moved; worldTransform must be recomputed before world data can be read.
|
||||
*/
|
||||
#define ENTITY_POSITION_FLAG_WORLD_DIRTY (1 << 3)
|
||||
|
||||
typedef struct {
|
||||
/*
|
||||
* Hot fields - flag checks, parent/child traversal (markDirty, ensureWorld)
|
||||
* only touch these. Kept at the front so they share the first cache line.
|
||||
*/
|
||||
|
||||
/** ENTITY_POSITION_FLAG_* bitmask; describes which caches are stale. */
|
||||
uint8_t flags;
|
||||
/** Entity ID of the parent node, or ENTITY_ID_INVALID if none. */
|
||||
entityid_t parentEntityId;
|
||||
/** Component ID of the parent position, or COMPONENT_ID_INVALID if none. */
|
||||
componentid_t parentComponentId;
|
||||
/** Number of currently registered children. */
|
||||
uint8_t childCount;
|
||||
/** Entity IDs of child nodes. */
|
||||
entityid_t childEntityIds[ENTITY_POSITION_CHILDREN_MAX];
|
||||
/** Component IDs of child position components. */
|
||||
componentid_t childComponentIds[ENTITY_POSITION_CHILDREN_MAX];
|
||||
|
||||
/*
|
||||
* Warm fields - read/written by PRS getters/setters.
|
||||
* Accessed more often than the matrices but less often than flags.
|
||||
*/
|
||||
|
||||
/** Cached local position (XYZ). Stale when PRS_DIRTY is set. */
|
||||
vec3 position;
|
||||
/** Cached local rotation (XYZ euler, radians). Stale when PRS_DIRTY. */
|
||||
vec3 rotation;
|
||||
/** Cached local scale (XYZ). Stale when PRS_DIRTY is set. */
|
||||
vec3 scale;
|
||||
|
||||
/*
|
||||
* Cold fields - only touched when actually rebuilding transforms.
|
||||
*/
|
||||
|
||||
/** Local transform matrix, rebuilt lazily from position/rotation/scale. */
|
||||
mat4 localTransform;
|
||||
/** World transform matrix, recomputed lazily from the parent chain. */
|
||||
mat4 worldTransform;
|
||||
} entityposition_t;
|
||||
|
||||
/**
|
||||
* Initializes the entity position component, setting identity transforms and
|
||||
* zeroing all parent/child state.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
*/
|
||||
void entityPositionInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Positions and orients the entity at eye, facing target. Stores this as
|
||||
* the entity's normal world-space placement (consistent with every other
|
||||
* setter), not as a view matrix -- invert entityPositionGetTransform()'s
|
||||
* result to get a view matrix for rendering.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param eye The eye/camera position.
|
||||
* @param target The target point to look at.
|
||||
* @param up The up vector.
|
||||
*/
|
||||
void entityPositionLookAt(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 eye,
|
||||
vec3 target,
|
||||
vec3 up
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the world-space transform matrix, recomputing it lazily if dirty.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination matrix.
|
||||
*/
|
||||
void entityPositionGetTransform(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the local transform matrix (does not include parent transforms).
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination matrix.
|
||||
*/
|
||||
void entityPositionGetLocalTransform(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
mat4 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the cached local position (XYZ). Decomposes localTransform into PRS
|
||||
* first if ENTITY_POSITION_FLAG_PRS_DIRTY is set; never triggers a matrix
|
||||
* rebuild or world-transform update.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetLocalPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the world-space position. For parentless entities this is the same as
|
||||
* the local position.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetWorldPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the world-space position. For parentless entities this is equivalent
|
||||
* to entityPositionSetLocalPosition. For parented entities the position is
|
||||
* converted to local space via the inverted parent world transform.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param position The desired world-space position.
|
||||
*/
|
||||
void entityPositionSetWorldPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 position
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the local position, marks localTransform and worldTransform (self +
|
||||
* descendants) dirty.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param position The new local position.
|
||||
*/
|
||||
void entityPositionSetLocalPosition(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 position
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the cached local euler rotation (XYZ, radians). Decomposes
|
||||
* localTransform first if ENTITY_POSITION_FLAG_PRS_DIRTY is set.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetLocalRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the world-space euler rotation (XYZ, radians) by decomposing the
|
||||
* world transform. For parentless entities this is the same as local
|
||||
* rotation.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetWorldRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the local euler rotation (XYZ, radians) and marks transforms dirty.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param rotation The new local rotation.
|
||||
*/
|
||||
void entityPositionSetLocalRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 rotation
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the world-space euler rotation (XYZ, radians). For parentless
|
||||
* entities this is equivalent to entityPositionSetLocalRotation. For
|
||||
* parented entities the rotation is converted to local space by removing
|
||||
* the parent world rotation.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param rotation The desired world-space euler rotation.
|
||||
*/
|
||||
void entityPositionSetWorldRotation(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 rotation
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the cached local scale. Decomposes localTransform first if
|
||||
* ENTITY_POSITION_FLAG_PRS_DIRTY is set.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetLocalScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the world-space scale by extracting column lengths from the world
|
||||
* transform. For parentless entities this is the same as local scale.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param dest Destination vector.
|
||||
*/
|
||||
void entityPositionGetWorldScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 dest
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the local scale and marks transforms dirty.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param scale The new local scale.
|
||||
*/
|
||||
void entityPositionSetLocalScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 scale
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the world-space scale. For parentless entities this is equivalent to
|
||||
* entityPositionSetLocalScale. For parented entities the scale is converted
|
||||
* to local space by dividing by the parent world scale (assumes no shear).
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @param scale The desired world-space scale.
|
||||
*/
|
||||
void entityPositionSetWorldScale(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
vec3 scale
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the parent of this entity's position component.
|
||||
* Pass ENTITY_ID_INVALID / COMPONENT_ID_INVALID to detach from any parent.
|
||||
*
|
||||
* @param mgr The entity manager that owns both entities.
|
||||
* @param entityId The child entity ID.
|
||||
* @param componentId The child component ID.
|
||||
* @param parentEntityId The parent entity ID.
|
||||
* @param parentComponentId The parent component ID.
|
||||
*/
|
||||
void entityPositionSetParent(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityid_t parentEntityId,
|
||||
const componentid_t parentComponentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets the entity ID of this position component's parent.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @return The parent entity ID, or ENTITY_ID_INVALID if unparented.
|
||||
*/
|
||||
entityid_t entityPositionGetParent(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a direct pointer to the entity position component data.
|
||||
* After modifying localTransform directly, call entityPositionMarkDirty() to
|
||||
* set ENTITY_POSITION_FLAG_WORLD_DIRTY on self and descendants. After
|
||||
* modifying PRS directly, call entityPositionRebuild() instead.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity ID.
|
||||
* @param componentId The component ID.
|
||||
* @return Pointer to the component data.
|
||||
*/
|
||||
entityposition_t *entityPositionGet(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Signals that the PRS cache was modified externally. Sets both
|
||||
* ENTITY_POSITION_FLAG_ROTATION_DIRTY and ENTITY_POSITION_FLAG_POSITION_DIRTY
|
||||
* so all of localTransform is rebuilt lazily on the next read, clears
|
||||
* ENTITY_POSITION_FLAG_PRS_DIRTY, propagates ENTITY_POSITION_FLAG_WORLD_DIRTY
|
||||
* to self and all descendants.
|
||||
*
|
||||
* @param mgr The entity manager that owns pos and its descendants.
|
||||
* @param pos The position component whose PRS was modified.
|
||||
*/
|
||||
void entityPositionRebuild(entitymanager_t *mgr, entityposition_t *pos);
|
||||
|
||||
/**
|
||||
* Sets ENTITY_POSITION_FLAG_WORLD_DIRTY on this node and all descendants,
|
||||
* indicating that worldTransform must be recomputed before it is read.
|
||||
* Call this after modifying localTransform directly.
|
||||
*
|
||||
* @param mgr The entity manager that owns pos and its descendants.
|
||||
* @param pos The position component to mark dirty.
|
||||
*/
|
||||
void entityPositionMarkDirty(entitymanager_t *mgr, entityposition_t *pos);
|
||||
|
||||
/**
|
||||
* Disposes this entity and all of its position-component descendants
|
||||
* recursively. Detaches from any parent before destroying.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The root entity ID.
|
||||
* @param componentId The root position component ID.
|
||||
*/
|
||||
void entityPositionDisposeDeep(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Decomposes the local transform matrix back into the position, rotation
|
||||
* (XYZ euler, radians), and scale cache fields.
|
||||
*
|
||||
* @param pos The position component to decompose.
|
||||
*/
|
||||
void entityPositionDecompose(entityposition_t *pos);
|
||||
|
||||
/**
|
||||
* Internal. Decomposes localTransform into the PRS cache if
|
||||
* ENTITY_POSITION_FLAG_PRS_DIRTY is set.
|
||||
*
|
||||
* @param pos The position component to update.
|
||||
*/
|
||||
void entityPositionEnsurePRS(entityposition_t *pos);
|
||||
|
||||
/**
|
||||
* Internal. Rebuilds localTransform from the PRS cache, touching only the
|
||||
* columns flagged as stale (ROTATION_DIRTY and/or POSITION_DIRTY).
|
||||
*
|
||||
* @param pos The position component to update.
|
||||
*/
|
||||
void entityPositionEnsureLocal(entityposition_t *pos);
|
||||
|
||||
/**
|
||||
* Internal. Recomputes worldTransform from the parent chain if
|
||||
* ENTITY_POSITION_FLAG_WORLD_DIRTY is set.
|
||||
*
|
||||
* @param mgr The entity manager that owns pos and its ancestors.
|
||||
* @param pos The position component to update.
|
||||
*/
|
||||
void entityPositionEnsureWorld(entitymanager_t *mgr, entityposition_t *pos);
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "entityrenderable.h"
|
||||
#include "entity/entitymanager.h"
|
||||
#include "display/shader/shadermaterial.h"
|
||||
#include "display/shader/shaderunlit.h"
|
||||
#include "display/display.h"
|
||||
#include "display/mesh/cube.h"
|
||||
#include "util/memory.h"
|
||||
#include "assert/assert.h"
|
||||
|
||||
void entityRenderableInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
memoryZero(r, sizeof(entityrenderable_t));
|
||||
r->type = ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL;
|
||||
r->data.material.shaderType = SHADER_LIST_SHADER_UNLIT;
|
||||
r->data.material.material.unlit.color = COLOR_WHITE;
|
||||
r->data.material.meshes[0] = &CUBE_MESH_SIMPLE;
|
||||
r->data.material.meshOffsets[0] = 0;
|
||||
r->data.material.meshCounts[0] = -1;
|
||||
r->data.material.meshCount = 1;
|
||||
r->data.material.state.flags = DISPLAY_STATE_FLAG_DEPTH_TEST;
|
||||
}
|
||||
|
||||
void entityRenderableDispose(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
}
|
||||
|
||||
void entityRenderableSetType(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityrenderabletype_t type
|
||||
) {
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
r->type = type;
|
||||
}
|
||||
|
||||
void entityRenderableSetPriority(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const int8_t priority
|
||||
) {
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
r->priority = priority;
|
||||
}
|
||||
|
||||
void entityRenderableSetColor(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const color_t color
|
||||
) {
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
assertTrue(
|
||||
r->type == ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
|
||||
"Renderable must be ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL to set color"
|
||||
);
|
||||
r->data.material.material.unlit.color = color;
|
||||
}
|
||||
|
||||
void entityRenderableSetMesh(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const uint8_t slot,
|
||||
mesh_t *mesh
|
||||
) {
|
||||
assertNotNull(mesh, "Mesh cannot be null");
|
||||
assertTrue(slot < ENTITY_RENDERABLE_MESHES_MAX, "Mesh slot out of bounds");
|
||||
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
assertTrue(
|
||||
r->type == ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
|
||||
"Renderable must be ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL to set a mesh"
|
||||
);
|
||||
|
||||
r->data.material.meshes[slot] = mesh;
|
||||
r->data.material.meshOffsets[slot] = 0;
|
||||
r->data.material.meshCounts[slot] = -1;
|
||||
if(slot >= r->data.material.meshCount) {
|
||||
r->data.material.meshCount = slot + 1;
|
||||
}
|
||||
}
|
||||
|
||||
void entityRenderableSetDraw(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
errorret_t (*draw)(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
void *user
|
||||
),
|
||||
void *user
|
||||
) {
|
||||
assertNotNull(draw, "Draw callback cannot be null");
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
r->type = ENTITY_RENDERABLE_TYPE_CUSTOM;
|
||||
r->data.custom.draw = draw;
|
||||
r->data.custom.drawUser = user;
|
||||
}
|
||||
|
||||
errorret_t entityRenderableDraw(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
) {
|
||||
entityrenderable_t *r = componentGetData(
|
||||
mgr, entityId, componentId, COMPONENT_TYPE_RENDERABLE
|
||||
);
|
||||
switch(r->type) {
|
||||
case ENTITY_RENDERABLE_TYPE_SPRITEBATCH:
|
||||
return entityRenderableDrawSpritebatch(&r->data.spritebatch);
|
||||
case ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL:
|
||||
return entityRenderableDrawMaterial(&r->data.material);
|
||||
case ENTITY_RENDERABLE_TYPE_CUSTOM:
|
||||
return entityRenderableDrawCustom(
|
||||
mgr, entityId, componentId, &r->data.custom
|
||||
);
|
||||
default:
|
||||
assertUnreachable("Invalid renderable type");
|
||||
}
|
||||
}
|
||||
|
||||
errorret_t entityRenderableDrawSpritebatch(
|
||||
const entityrenderablespritebatch_t *sb
|
||||
) {
|
||||
if(sb->spriteCount == 0) errorOk();
|
||||
|
||||
errorChain(displaySetState((displaystate_t){
|
||||
.flags = DISPLAY_STATE_FLAG_BLEND
|
||||
}));
|
||||
|
||||
spriteBatchClear();
|
||||
shadermaterial_t mat;
|
||||
memoryZero(&mat, sizeof(shadermaterial_t));
|
||||
mat.unlit.texture = sb->texture;
|
||||
mat.unlit.color = COLOR_WHITE;
|
||||
errorChain(spriteBatchBuffer(
|
||||
sb->sprites, sb->spriteCount,
|
||||
SHADER_LIST_DEFS[SHADER_LIST_SHADER_UNLIT].shader, mat
|
||||
));
|
||||
return spriteBatchFlush();
|
||||
}
|
||||
|
||||
errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m) {
|
||||
errorChain(displaySetState(m->state));
|
||||
shader_t *shader = SHADER_LIST_DEFS[m->shaderType].shader;
|
||||
assertNotNull(shader, "Shader cannot be null for material type");
|
||||
errorChain(shaderBind(shader));
|
||||
errorChain(shaderSetMaterial(shader, &m->material));
|
||||
for(uint8_t i = 0; i < m->meshCount; i++) {
|
||||
errorChain(meshDraw(m->meshes[i], m->meshOffsets[i], m->meshCounts[i]));
|
||||
}
|
||||
errorOk();
|
||||
}
|
||||
|
||||
errorret_t entityRenderableDrawCustom(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityrenderablecustom_t *custom
|
||||
) {
|
||||
return custom->draw(mgr, entityId, componentId, custom->drawUser);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include "entity/entitybase.h"
|
||||
#include "display/mesh/mesh.h"
|
||||
#include "display/shader/shadermaterial.h"
|
||||
#include "display/spritebatch/spritebatch.h"
|
||||
#include "display/displaystate.h"
|
||||
#include "error/error.h"
|
||||
|
||||
#define ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX 64
|
||||
#define ENTITY_RENDERABLE_MESHES_MAX 8
|
||||
|
||||
typedef enum {
|
||||
ENTITY_RENDERABLE_TYPE_CUSTOM = 0,
|
||||
ENTITY_RENDERABLE_TYPE_SPRITEBATCH,
|
||||
ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL,
|
||||
} entityrenderabletype_t;
|
||||
|
||||
typedef struct {
|
||||
spritebatchsprite_t sprites[ENTITY_RENDERABLE_SPRITEBATCH_SPRITES_MAX];
|
||||
uint32_t spriteCount;
|
||||
texture_t *texture;
|
||||
} entityrenderablespritebatch_t;
|
||||
|
||||
typedef struct {
|
||||
mesh_t *meshes[ENTITY_RENDERABLE_MESHES_MAX];
|
||||
int32_t meshOffsets[ENTITY_RENDERABLE_MESHES_MAX];
|
||||
int32_t meshCounts[ENTITY_RENDERABLE_MESHES_MAX];
|
||||
uint8_t meshCount;
|
||||
shaderlistshader_t shaderType;
|
||||
shadermaterial_t material;
|
||||
displaystate_t state;
|
||||
} entityrenderablematerial_t;
|
||||
|
||||
typedef struct {
|
||||
errorret_t (*draw)(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
void *user
|
||||
);
|
||||
void *drawUser;
|
||||
} entityrenderablecustom_t;
|
||||
|
||||
typedef union entityrenderabledata_u {
|
||||
entityrenderablespritebatch_t spritebatch;
|
||||
entityrenderablematerial_t material;
|
||||
entityrenderablecustom_t custom;
|
||||
} entityrenderabledata_t;
|
||||
|
||||
typedef struct {
|
||||
entityrenderabletype_t type;
|
||||
entityrenderabledata_t data;
|
||||
|
||||
/**
|
||||
* Render priority. 0 = auto (derived from type/flags). Higher values
|
||||
* render later (on top of lower values). Range: [-128..127] with 0 auto.
|
||||
*/
|
||||
int8_t priority;
|
||||
} entityrenderable_t;
|
||||
|
||||
/**
|
||||
* Initializes the entity renderable component. Defaults to
|
||||
* ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL with the unlit shader, a white
|
||||
* cube, and depth-test enabled.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to initialize the component for.
|
||||
* @param componentId The renderable component of the entity.
|
||||
*/
|
||||
void entityRenderableInit(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Disposes the entity renderable component.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to dispose the component for.
|
||||
* @param componentId The renderable component of the entity.
|
||||
*/
|
||||
void entityRenderableDispose(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the rendering type for the renderable component. Resets
|
||||
* type-specific data to zero.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to configure.
|
||||
* @param componentId The renderable component.
|
||||
* @param type The rendering type to use.
|
||||
*/
|
||||
void entityRenderableSetType(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityrenderabletype_t type
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the render priority. 0 = auto (derived from type/flags). Higher
|
||||
* values render later (on top). Use non-zero to force ordering.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to configure.
|
||||
* @param componentId The renderable component.
|
||||
* @param priority The priority value, or 0 for auto.
|
||||
*/
|
||||
void entityRenderableSetPriority(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const int8_t priority
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the unlit material color. Only meaningful when the renderable is
|
||||
* (or defaults to) ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL -- asserts
|
||||
* otherwise.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to configure.
|
||||
* @param componentId The renderable component.
|
||||
* @param color The color to tint the material with.
|
||||
*/
|
||||
void entityRenderableSetColor(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const color_t color
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets one of the material renderable's meshes, drawn in full (no offset/
|
||||
* count override). Only meaningful when the renderable is (or defaults
|
||||
* to) ENTITY_RENDERABLE_TYPE_SHADER_MATERIAL -- asserts otherwise.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to configure.
|
||||
* @param componentId The renderable component.
|
||||
* @param slot Index into the material's meshes array (0 to
|
||||
* ENTITY_RENDERABLE_MESHES_MAX - 1).
|
||||
* @param mesh The mesh to draw in that slot.
|
||||
*/
|
||||
void entityRenderableSetMesh(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const uint8_t slot,
|
||||
mesh_t *mesh
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the draw callback, switching the type to
|
||||
* ENTITY_RENDERABLE_TYPE_CUSTOM.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to configure.
|
||||
* @param componentId The renderable component of the entity.
|
||||
* @param draw The draw callback to assign.
|
||||
* @param user Userdata passed to the callback.
|
||||
*/
|
||||
void entityRenderableSetDraw(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
errorret_t (*draw)(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
void *user
|
||||
),
|
||||
void *user
|
||||
);
|
||||
|
||||
/**
|
||||
* Draws the entity using its renderable component data.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity to draw.
|
||||
* @param componentId The renderable component of the entity.
|
||||
* @return Any error state that happened.
|
||||
*/
|
||||
errorret_t entityRenderableDraw(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId
|
||||
);
|
||||
|
||||
/**
|
||||
* Internal. Draws a spritebatch-type renderable.
|
||||
*
|
||||
* @param sb The spritebatch data to draw.
|
||||
* @return Error state.
|
||||
*/
|
||||
errorret_t entityRenderableDrawSpritebatch(
|
||||
const entityrenderablespritebatch_t *sb
|
||||
);
|
||||
|
||||
/**
|
||||
* Internal. Draws a shader-material-type renderable.
|
||||
*
|
||||
* @param m The material data to draw.
|
||||
* @return Error state.
|
||||
*/
|
||||
errorret_t entityRenderableDrawMaterial(const entityrenderablematerial_t *m);
|
||||
|
||||
/**
|
||||
* Internal. Invokes a custom-type renderable's draw callback.
|
||||
*
|
||||
* @param mgr The entity manager that owns the entity.
|
||||
* @param entityId The entity being drawn.
|
||||
* @param componentId The renderable component of the entity.
|
||||
* @param custom The custom draw data.
|
||||
* @return Error state.
|
||||
*/
|
||||
errorret_t entityRenderableDrawCustom(
|
||||
entitymanager_t *mgr,
|
||||
const entityid_t entityId,
|
||||
const componentid_t componentId,
|
||||
const entityrenderablecustom_t *custom
|
||||
);
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
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
|
||||
);
|
||||
+2
-1
@@ -6,5 +6,6 @@
|
||||
# Sources
|
||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||
PUBLIC
|
||||
assetchunkloader.c
|
||||
entitytrigger.c
|
||||
entitytriggerevents.c
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user