Compare commits
5 Commits
clay-ui
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
462fb12bd5
|
|||
|
cb878cfaa5
|
|||
|
71de95822c
|
|||
|
f4728bf19d
|
|||
|
42453c2eb6
|
@@ -1,16 +0,0 @@
|
|||||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
|
||||||
;;; See AGENTS.md -> "Coding Style" for the rationale.
|
|
||||||
;;;
|
|
||||||
;;; The canonical style is cc-mode "stroustrup" with tabs enabled: 4 columns per
|
|
||||||
;;; level, tabs 8 columns wide, so depth 1 is four spaces, depth 2 is one tab,
|
|
||||||
;;; depth 3 is a tab plus four spaces. A correctly formatted file is a fixed
|
|
||||||
;;; point of `indent-region' under these settings.
|
|
||||||
;;;
|
|
||||||
;;; Reindent a whole file with C-x h C-M-\, or the tree with
|
|
||||||
;;; `scripts/reindent.sh'.
|
|
||||||
|
|
||||||
((c-mode . ((c-file-style . "stroustrup")
|
|
||||||
(indent-tabs-mode . t)
|
|
||||||
(tab-width . 8)
|
|
||||||
(fill-column . 100)
|
|
||||||
(require-final-newline . t))))
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
name: libakgl CI Build
|
|
||||||
run-name: ${{ gitea.actor }} libakgl test
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cmake_build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y \
|
|
||||||
cmake doxygen gcc gcovr graphviz pkg-config \
|
|
||||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
|
||||||
libpng-dev libtiff-dev libwebp-dev \
|
|
||||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
|
||||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
|
||||||
libxss-dev libxtst-dev
|
|
||||||
- name: Configure and build
|
|
||||||
run: |
|
|
||||||
cmake -S . -B build \
|
|
||||||
-DCMAKE_BUILD_TYPE=Debug \
|
|
||||||
-DAKGL_COVERAGE=ON \
|
|
||||||
-DAKGL_WERROR=ON
|
|
||||||
cmake --build build --parallel
|
|
||||||
- name: Build API documentation
|
|
||||||
run: doxygen Doxyfile
|
|
||||||
# The perf suites run here too -- they are ordinary CTest tests -- but this
|
|
||||||
# is a Debug build with coverage instrumentation, where a timing is a
|
|
||||||
# measurement of gcov. AKGL_BENCH_SCALE cuts their iteration counts so
|
|
||||||
# they contribute path coverage without spending ten minutes proving
|
|
||||||
# nothing; benchutil.h already refuses to enforce budgets in an
|
|
||||||
# unoptimized build. The performance job below is where the timings are
|
|
||||||
# taken and the budgets are checked.
|
|
||||||
- name: Test (JUnit)
|
|
||||||
env:
|
|
||||||
AKGL_BENCH_SCALE: '0.02'
|
|
||||||
run: |
|
|
||||||
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
|
|
||||||
ctest \
|
|
||||||
--test-dir build \
|
|
||||||
--output-on-failure \
|
|
||||||
--output-junit "$(pwd)/ctest-junit.xml"
|
|
||||||
- name: Publish test results
|
|
||||||
if: always()
|
|
||||||
uses: mikepenz/action-junit-report@v4
|
|
||||||
with:
|
|
||||||
report_paths: 'ctest-junit.xml'
|
|
||||||
annotate_only: true
|
|
||||||
detailed_summary: true
|
|
||||||
include_passed: true
|
|
||||||
fail_on_failure: 'true'
|
|
||||||
- name: Upload coverage reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: code-coverage
|
|
||||||
path: build/coverage/
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
# The benchmarks, in the only build where their numbers mean anything.
|
|
||||||
# tests/benchutil.h enforces a budget per measurement only when it is compiled
|
|
||||||
# optimized and running at full scale, which is exactly this job and no other:
|
|
||||||
# the coverage job above is Debug, and the memory job below is under valgrind.
|
|
||||||
# A budget is roughly ten times the baseline recorded in PERFORMANCE.md, which
|
|
||||||
# is what makes a shared, slower runner viable -- it catches an algorithmic
|
|
||||||
# regression and ignores a busy machine. If a budget still proves flaky here,
|
|
||||||
# raise that one budget with a measurement behind it and re-record the
|
|
||||||
# baseline; do not drop the gate.
|
|
||||||
performance:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y \
|
|
||||||
cmake gcc pkg-config \
|
|
||||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
|
||||||
libpng-dev libtiff-dev libwebp-dev \
|
|
||||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
|
||||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
|
||||||
libxss-dev libxtst-dev
|
|
||||||
- name: Configure and build
|
|
||||||
run: |
|
|
||||||
cmake -S . -B build \
|
|
||||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
|
||||||
-DAKGL_WERROR=ON
|
|
||||||
cmake --build build --parallel
|
|
||||||
# --verbose rather than --output-on-failure: a passing benchmark's output
|
|
||||||
# is the entire point of running it, and --output-on-failure prints
|
|
||||||
# nothing at all for a green run. pipefail because the step ends in a
|
|
||||||
# pipe, whose status would otherwise be tee's -- always 0 -- and a blown
|
|
||||||
# budget would be reported as a passing step.
|
|
||||||
- name: Benchmarks (JUnit)
|
|
||||||
run: |
|
|
||||||
set -o pipefail
|
|
||||||
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
|
|
||||||
ctest \
|
|
||||||
--test-dir build \
|
|
||||||
-L perf \
|
|
||||||
--verbose \
|
|
||||||
--output-junit "$(pwd)/perf-junit.xml" \
|
|
||||||
2>&1 | tee "$(pwd)/perf-baseline.txt"
|
|
||||||
- name: Publish benchmark results
|
|
||||||
if: always()
|
|
||||||
uses: mikepenz/action-junit-report@v4
|
|
||||||
with:
|
|
||||||
report_paths: 'perf-junit.xml'
|
|
||||||
annotate_only: true
|
|
||||||
detailed_summary: true
|
|
||||||
include_passed: true
|
|
||||||
fail_on_failure: 'true'
|
|
||||||
# Kept whether the job passed or failed: the tables are the point. A run
|
|
||||||
# that went red says which measurement moved, and a run that went green is
|
|
||||||
# the next baseline if somebody re-records PERFORMANCE.md.
|
|
||||||
- name: Upload benchmark tables
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: performance-baseline
|
|
||||||
path: perf-baseline.txt
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
# Every suite under valgrind. No memory-check programs of their own: the perf
|
|
||||||
# suites scale themselves down when they detect valgrind, which turns them
|
|
||||||
# into the broadest path coverage in the tree at a cost this job can afford.
|
|
||||||
#
|
|
||||||
# This job gates. A definite leak, a read past the end of an allocation, or a
|
|
||||||
# branch on uninitialised memory fails the build on the push that introduced
|
|
||||||
# it -- not on the day somebody gets around to caring. The six findings this
|
|
||||||
# job had on its first run were fixed to make that possible rather than
|
|
||||||
# excused into a warning.
|
|
||||||
memory_check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
- name: Install memory-check dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y \
|
|
||||||
cmake gcc pkg-config valgrind \
|
|
||||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
|
||||||
libpng-dev libtiff-dev libwebp-dev \
|
|
||||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
|
||||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
|
||||||
libxss-dev libxtst-dev
|
|
||||||
# RelWithDebInfo rather than Debug: valgrind needs the symbols, and -O0
|
|
||||||
# would leave every inlined frame in the stacks it prints.
|
|
||||||
- name: Configure and build
|
|
||||||
run: |
|
|
||||||
cmake -S . -B build \
|
|
||||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
||||||
cmake --build build --parallel
|
|
||||||
# Every suite, character included. It was excluded from both this job
|
|
||||||
# and the coverage job on the grounds that it failed deliberately -- it
|
|
||||||
# did not. It was reporting success while running one of its four tests;
|
|
||||||
# see TODO.md, "Test suites that could not fail".
|
|
||||||
- name: Memory check
|
|
||||||
run: scripts/memcheck.sh
|
|
||||||
- name: Upload valgrind logs
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: valgrind-logs
|
|
||||||
path: build/Testing/Temporary/MemoryChecker.*.log
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
mutation_test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
- name: Install mutation-test dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y \
|
|
||||||
cmake gcc pkg-config python3 \
|
|
||||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
|
||||||
libpng-dev libtiff-dev libwebp-dev \
|
|
||||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
|
||||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
|
||||||
libxss-dev libxtst-dev
|
|
||||||
# Keep CI bounded to a focused source file. Run the default target locally
|
|
||||||
# for the complete libakgl-owned src/ tree.
|
|
||||||
- name: Mutation testing
|
|
||||||
run: |
|
|
||||||
python3 scripts/mutation_test.py \
|
|
||||||
--target src/staticstring.c \
|
|
||||||
--junit mutation-junit.xml \
|
|
||||||
--threshold 50
|
|
||||||
- name: Publish mutation results
|
|
||||||
if: always()
|
|
||||||
uses: mikepenz/action-junit-report@v4
|
|
||||||
with:
|
|
||||||
report_paths: 'mutation-junit.xml'
|
|
||||||
annotate_only: true
|
|
||||||
detailed_summary: true
|
|
||||||
include_passed: true
|
|
||||||
fail_on_failure: 'false'
|
|
||||||
15
.gitignore
vendored
@@ -1,16 +1,3 @@
|
|||||||
# A leading ./ is not a valid gitignore pattern, so "./build/*" matched nothing
|
./build/*
|
||||||
# and every out-of-tree build tree showed up as untracked. This also covers the
|
|
||||||
# instrumented trees scripts/coverage.py and the AKGL_COVERAGE option create.
|
|
||||||
build*/
|
|
||||||
.aider*
|
.aider*
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# rebuild.sh captures its cmake output here. A build artifact, not a record --
|
|
||||||
# it sat untracked in `git status` long enough to become invisible, which is the
|
|
||||||
# state a genuinely unexpected untracked file needs to stand out from.
|
|
||||||
rebuild.log
|
|
||||||
|
|
||||||
# Generated by configure_file into the build tree. include/ precedes the build
|
|
||||||
# tree on the include path, so a stray copy here would silently shadow the real
|
|
||||||
# one and pin every consumer to whatever version it was generated at.
|
|
||||||
include/akgl/version.h
|
|
||||||
|
|||||||
26
.gitmodules
vendored
@@ -1,34 +1,24 @@
|
|||||||
[submodule "deps/semver"]
|
[submodule "deps/semver"]
|
||||||
path = deps/semver
|
path = deps/semver
|
||||||
url = https://github.com/h2non/semver.c.git
|
url = git@github.com:h2non/semver.c.git
|
||||||
[submodule "deps/SDL"]
|
[submodule "deps/SDL"]
|
||||||
path = deps/SDL
|
path = deps/SDL
|
||||||
url = https://github.com/libsdl-org/SDL.git
|
url = git@github.com:libsdl-org/SDL.git
|
||||||
[submodule "deps/SDL_image"]
|
[submodule "deps/SDL_image"]
|
||||||
path = deps/SDL_image
|
path = deps/SDL_image
|
||||||
url = https://github.com/libsdl-org/SDL_image.git
|
url = git@github.com:libsdl-org/SDL_image.git
|
||||||
[submodule "deps/SDL_mixer"]
|
[submodule "deps/SDL_mixer"]
|
||||||
path = deps/SDL_mixer
|
path = deps/SDL_mixer
|
||||||
url = https://github.com/libsdl-org/SDL_mixer.git
|
url = git@github.com:libsdl-org/SDL_mixer.git
|
||||||
[submodule "deps/SDL_ttf"]
|
[submodule "deps/SDL_ttf"]
|
||||||
path = deps/SDL_ttf
|
path = deps/SDL_ttf
|
||||||
url = https://github.com/libsdl-org/SDL_ttf.git
|
url = git@github.com:libsdl-org/SDL_ttf.git
|
||||||
[submodule "deps/libsdlerror"]
|
[submodule "deps/libsdlerror"]
|
||||||
path = deps/libakerror
|
path = deps/libakerror
|
||||||
url = https://source.starfort.tech/andrew/libakerror.git
|
url = https://source.home.aklabs.net/andrew/libsdlerror.git
|
||||||
[submodule "deps/libakstdlib"]
|
[submodule "deps/libakstdlib"]
|
||||||
path = deps/libakstdlib
|
path = deps/libakstdlib
|
||||||
url = https://source.starfort.tech/andrew/libakstdlib.git
|
url = https://source.home.aklabs.net/andrew/libakstdlib.git
|
||||||
[submodule "deps/jansson"]
|
[submodule "deps/jansson"]
|
||||||
path = deps/jansson
|
path = deps/jansson
|
||||||
url = https://github.com/akheron/jansson.git
|
url = git@github.com:akheron/jansson.git
|
||||||
[submodule "deps/libccd"]
|
|
||||||
path = deps/libccd
|
|
||||||
url = https://github.com/danfis/libccd.git
|
|
||||||
[submodule "deps/tg"]
|
|
||||||
path = deps/tg
|
|
||||||
url = https://github.com/tidwall/tg.git
|
|
||||||
[submodule "clay"]
|
|
||||||
path = deps/clay
|
|
||||||
url = https://github.com/nicbarker/clay.git
|
|
||||||
branch = v0.14
|
|
||||||
|
|||||||
782
AGENTS.md
@@ -1,782 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
|
|
||||||
This is a C library intended to support the development of video games. Public C headers live in `include/akgl/`; keep declarations there aligned with their implementations in `src/`. Tests are standalone C programs under `tests/`, with JSON, image, and map fixtures in `tests/assets/`. The `util/` directory contains the `charviewer` utility and its sample assets. Third-party and companion libraries are vendored in `deps/`. Treat `build/` and generated `akgl.pc` files as build outputs rather than hand-maintained source; `include/akgl/SDL_GameControllerDB.h` is generated too, but is tracked on purpose — see below.
|
|
||||||
|
|
||||||
## Generated and Vendored Sources
|
|
||||||
|
|
||||||
### `include/akgl/SDL_GameControllerDB.h` is generated, and is tracked deliberately
|
|
||||||
|
|
||||||
`mkcontrollermappings.sh` regenerates this header by fetching the community
|
|
||||||
controller database from `raw.githubusercontent.com`. **It is committed to the
|
|
||||||
repository on purpose**: it is the offline fallback that keeps the library
|
|
||||||
buildable if upstream is renamed, rate-limited, taken down, or simply
|
|
||||||
unreachable from the build machine. Do not delete it, do not add it to
|
|
||||||
`.gitignore`, and do not "clean up" the fact that a generated file is tracked.
|
|
||||||
|
|
||||||
Working rules:
|
|
||||||
|
|
||||||
- **Never hand-edit it.** It is machine-written; changes belong in
|
|
||||||
`mkcontrollermappings.sh`.
|
|
||||||
- **An ordinary build does not touch it.** Regeneration is
|
|
||||||
`cmake --build build --target controllerdb`, and nothing else runs the script.
|
|
||||||
Until 0.5.0 an `add_custom_command` re-ran it on every build, which made every
|
|
||||||
build need network access and left the file modified with a fresh `$(date)`
|
|
||||||
stamp and nothing of substance changed.
|
|
||||||
- **Update it as a deliberate, standalone commit** when you actually want newer
|
|
||||||
mappings, so the diff is reviewable and bisectable.
|
|
||||||
- **A failed fetch cannot reach this file.** The script assembles the header in
|
|
||||||
a temporary directory and moves it into place only after checking curl's exit
|
|
||||||
status, a plausible minimum mapping count, and that no mapping contains a
|
|
||||||
character that would break a C string literal. Any of those failing leaves the
|
|
||||||
tracked copy untouched and exits non-zero.
|
|
||||||
- Formatting tooling ignores it: `scripts/reindent.sh` and the pre-commit hook
|
|
||||||
both skip it.
|
|
||||||
|
|
||||||
> **Fixed in 0.5.0.** The safety net used to be self-defeating:
|
|
||||||
> `mkcontrollermappings.sh` had no `set -e` and never checked `curl`, so a
|
|
||||||
> failed fetch wrote `AKGL_SDL_GAMECONTROLLER_DB_LEN 0` and an empty array
|
|
||||||
> **over the good tracked copy, and exited 0** — and because CMake re-ran the
|
|
||||||
> generator on every build, an offline build silently destroyed the very
|
|
||||||
> fallback the file exists to provide. Both halves are closed: the script
|
|
||||||
> refuses rather than overwrites, and a build does not run it.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
|
|
||||||
Configure an out-of-tree development build:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
||||||
```
|
|
||||||
|
|
||||||
Build all library, utility, and test targets:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cmake --build build --parallel
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the complete test suite with failure details:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ctest --test-dir build --output-on-failure
|
|
||||||
```
|
|
||||||
|
|
||||||
Run one test while iterating, for example `ctest --test-dir build -R sprite --output-on-failure`. The `rebuild.sh` script also installs into a developer-specific `/home/andrew/local` prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended.
|
|
||||||
|
|
||||||
Run only the performance suites with `ctest --test-dir build -L perf --output-on-failure`, or leave them out of an ordinary run with `-LE perf`. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. `AKGL_BENCH_SCALE` scales every iteration count — `AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf` for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in `PERFORMANCE.md`.
|
|
||||||
|
|
||||||
Check for memory defects with `cmake --build build --target memcheck`, or
|
|
||||||
`scripts/memcheck.sh` directly — it takes ctest's selection flags, so
|
|
||||||
`scripts/memcheck.sh -R tilemap` and `scripts/memcheck.sh -LE perf` both work.
|
|
||||||
It runs the suites that already exist under valgrind (`ctest -T memcheck`) with
|
|
||||||
the headless drivers forced, and exits non-zero when valgrind finds a definite
|
|
||||||
leak, an invalid access, or a read of uninitialised memory — which `ctest -T
|
|
||||||
memcheck` on its own will not do. The whole run takes about thirty seconds
|
|
||||||
because the perf suites scale themselves down under valgrind. Suppressions for
|
|
||||||
third-party findings live in `scripts/valgrind.supp`; add one only when the
|
|
||||||
finding genuinely cannot be fixed from this repository.
|
|
||||||
|
|
||||||
Run mutation testing with `cmake --build build --target mutation`. For a quick smoke run, use `scripts/mutation_test.py --target src/tilemap.c --max-mutants 10`; the harness mutates only a scratch copy and runs every suite.
|
|
||||||
|
|
||||||
Generate HTML and Cobertura coverage reports with `cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug`, then build and run CTest. Reports are written to `build-coverage/coverage/`; this mode requires `gcovr` and GCC or Clang.
|
|
||||||
|
|
||||||
Both `gcovr` invocations take the build tree as an explicit positional search
|
|
||||||
path. Given none, gcovr searches `--root` -- the source directory, where
|
|
||||||
developers keep their build trees -- and `--object-directory` does not narrow
|
|
||||||
that. A second instrumented tree left in the source directory therefore used to
|
|
||||||
be folded into the report, and two trees describing different line numbers for
|
|
||||||
the same function failed `coverage_reset` before any test ran. Leave the
|
|
||||||
positional path alone.
|
|
||||||
|
|
||||||
A different trap survives: rebuilding an existing coverage tree after editing a
|
|
||||||
source leaves `.gcda` files describing the old object layout, and
|
|
||||||
`coverage_reset` -- whose whole job is to delete them -- fails with `GCOV
|
|
||||||
returncode was 5` first. `find build-coverage -name '*.gcda' -delete` clears it.
|
|
||||||
|
|
||||||
### What `ctest` runs
|
|
||||||
|
|
||||||
Beyond the C suites, three registered tests are not C programs and are worth
|
|
||||||
knowing about when one of them fails:
|
|
||||||
|
|
||||||
| Test | What it checks | Script |
|
|
||||||
|---|---|---|
|
|
||||||
| `api_surface` | Every exported `akgl_*` symbol is declared in a header | `scripts/check_api_surface.sh` |
|
|
||||||
| `error_protocol` | No `*_RETURN` inside `ATTEMPT`, no `return` out of `HANDLE` | `scripts/check_error_protocol.py` |
|
|
||||||
| `headers` | Every public header compiles as the first include of a translation unit | generated from `AKGL_PUBLIC_HEADERS` |
|
|
||||||
|
|
||||||
`api_surface` needs `nm` and the built library; it exits 2 and is reported as
|
|
||||||
skipped when it cannot run, rather than passing quietly.
|
|
||||||
|
|
||||||
## Continuous integration
|
|
||||||
|
|
||||||
`.gitea/workflows/ci.yaml` runs four jobs on every push, and each one exists
|
|
||||||
because the others cannot do its work:
|
|
||||||
|
|
||||||
| Job | Build | What it is for |
|
|
||||||
|---|---|---|
|
|
||||||
| `cmake_build` | Debug + `AKGL_COVERAGE=ON` | The unit suites and the coverage report. The perf suites run here too, at `AKGL_BENCH_SCALE=0.02`, for path coverage only — a timing taken under gcov is a measurement of gcov. |
|
|
||||||
| `performance` | RelWithDebInfo | `ctest -L perf`. The only job where budgets are enforced, because `tests/benchutil.h` enforces them only when compiled optimized at full scale. Keeps the tables as an artifact. |
|
|
||||||
| `memory_check` | RelWithDebInfo | `scripts/memcheck.sh`, every suite under valgrind. Gates: a definite leak, an invalid access or a branch on uninitialised memory fails the build. Keeps the valgrind logs as an artifact. |
|
|
||||||
| `mutation_test` | Debug | One focused source file, so CI stays bounded. |
|
|
||||||
|
|
||||||
Every suite runs in every job. The `character` suite used to be excluded from
|
|
||||||
the coverage and memory-check jobs on the grounds that it failed deliberately.
|
|
||||||
It did not: it was reporting success while running one of its four tests, for
|
|
||||||
two independent reasons, and both are fixed. See TODO.md, "Test suites that
|
|
||||||
could not fail".
|
|
||||||
|
|
||||||
## Coding Style
|
|
||||||
|
|
||||||
The canonical style is Emacs `cc-mode` **`stroustrup`**, with tabs enabled. This
|
|
||||||
is not advisory — new and edited code must match what `cc-mode` produces, so that
|
|
||||||
reindenting a region never shows up as a diff.
|
|
||||||
|
|
||||||
### Emacs setup
|
|
||||||
|
|
||||||
`.dir-locals.el` in the repository root already applies the style to every
|
|
||||||
`c-mode` buffer, so nothing needs configuring by hand:
|
|
||||||
|
|
||||||
```elisp
|
|
||||||
((c-mode . ((c-file-style . "stroustrup")
|
|
||||||
(indent-tabs-mode . t)
|
|
||||||
(tab-width . 8)
|
|
||||||
(fill-column . 100)
|
|
||||||
(require-final-newline . t))))
|
|
||||||
```
|
|
||||||
|
|
||||||
Interactively: `C-x h C-M-\` (`mark-whole-buffer` + `indent-region`) reindents
|
|
||||||
the buffer. A correctly formatted file is a fixed point of that operation —
|
|
||||||
reindenting must produce no change.
|
|
||||||
|
|
||||||
### Reindenting from the command line
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/reindent.sh # reindent every tracked C source in place
|
|
||||||
scripts/reindent.sh src/tilemap.c # reindent only the named files
|
|
||||||
scripts/reindent.sh --check # list non-conforming files, change nothing
|
|
||||||
```
|
|
||||||
|
|
||||||
`--check` exits 1 when something needs reindenting and 2 if Emacs is missing, so
|
|
||||||
it is usable from CI. The script drives Emacs in batch mode through
|
|
||||||
`scripts/reindent.el`, which reindents, normalises leading whitespace to the
|
|
||||||
canonical tab/space mix, strips trailing whitespace, and ensures a final
|
|
||||||
newline. `include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
|
|
||||||
|
|
||||||
Note that `reindent.el` deliberately does **not** use Emacs' `tabify`: that
|
|
||||||
function's `tabify-regexp` is `" [ \t]+"`, which is not anchored to the start of
|
|
||||||
a line, so it rewrites runs of spaces anywhere and destroys the hand-aligned
|
|
||||||
value columns in the bit-flag tables in `actor.h` and `iterator.h`. Only leading
|
|
||||||
whitespace is ever rewritten.
|
|
||||||
|
|
||||||
### The pre-commit hook
|
|
||||||
|
|
||||||
`scripts/hooks/pre-commit` reindents staged C sources before the commit is
|
|
||||||
written. Enable it once per clone:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git config core.hooksPath scripts/hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
It inspects the *staged* content rather than the working tree, so what lands in
|
|
||||||
the commit is what was checked. When a file needs reindenting it is fixed in the
|
|
||||||
working tree and re-staged — but only if the index and working tree agree for
|
|
||||||
that file. If they differ, re-staging would sweep unstaged work into the commit,
|
|
||||||
so the hook stops and tells you to reindent and stage it yourself. It steps
|
|
||||||
aside during a merge, and warns rather than blocking if Emacs is unavailable.
|
|
||||||
`git commit --no-verify` bypasses it.
|
|
||||||
|
|
||||||
For reference, `cc-mode` defines the style as:
|
|
||||||
|
|
||||||
```elisp
|
|
||||||
("stroustrup"
|
|
||||||
(c-basic-offset . 4)
|
|
||||||
(c-comment-only-line-offset . 0)
|
|
||||||
(c-offsets-alist . ((statement-block-intro . +)
|
|
||||||
(substatement-open . 0)
|
|
||||||
(substatement-label . 0)
|
|
||||||
(label . 0)
|
|
||||||
(statement-cont . +))))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Indentation and whitespace
|
|
||||||
|
|
||||||
- **4 columns per level** (`c-basic-offset` 4).
|
|
||||||
- **Tabs are 8 columns wide and are used for indentation** (`indent-tabs-mode`
|
|
||||||
`t`, `tab-width` 8). Emacs emits the largest possible run of tabs and pads the
|
|
||||||
remainder with spaces, which produces this ladder. Editors that expand tabs, or
|
|
||||||
that assume a 4-column tab, will silently corrupt it:
|
|
||||||
|
|
||||||
| Depth | Columns | Bytes |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 4 | 4 spaces |
|
|
||||||
| 2 | 8 | `TAB` |
|
|
||||||
| 3 | 12 | `TAB` + 4 spaces |
|
|
||||||
| 4 | 16 | `TAB` `TAB` |
|
|
||||||
| 5 | 20 | `TAB` `TAB` + 4 spaces |
|
|
||||||
|
|
||||||
- No trailing whitespace. Files end with a single newline.
|
|
||||||
- `case` labels sit at the same column as their `switch` (`label . 0`).
|
|
||||||
- Continuation lines of a wrapped expression indent one level (`statement-cont . +`).
|
|
||||||
|
|
||||||
The whole tree conforms: `scripts/reindent.sh --check` is clean, and the
|
|
||||||
pre-commit hook keeps it that way. Convert a file to the canonical style in its
|
|
||||||
own commit, not mixed into a behavioral change.
|
|
||||||
|
|
||||||
### Braces
|
|
||||||
|
|
||||||
- **Function bodies open on their own line, in column 0.**
|
|
||||||
- **Control statements keep the brace on the same line**, one space before it.
|
|
||||||
- **`else`, `else if`, and `while` of a `do`/`while` stay on the closing-brace
|
|
||||||
line**: `} else {`, not a bare `else` on the next line. Note that this is a
|
|
||||||
house convention rather than something `cc-mode` enforces — the `stroustrup`
|
|
||||||
style governs indentation only and will not move a brace or an `else` for you.
|
|
||||||
- **Always brace, even single-statement bodies.**
|
|
||||||
|
|
||||||
```c
|
|
||||||
akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
|
|
||||||
{
|
|
||||||
if ( obj->curSpriteFrameId == 0 ) {
|
|
||||||
obj->curSpriteReversing = false;
|
|
||||||
} else if ( obj->parent != NULL ) {
|
|
||||||
obj->curSpriteFrameId -= 1;
|
|
||||||
} else {
|
|
||||||
obj->curSpriteFrameId += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
|
|
||||||
- **Spaces inside control-flow parentheses**: `if ( x == y ) {`, `while ( done == false ) {`,
|
|
||||||
`for ( i = 0; i < len; i++ ) {`. This is the dominant existing convention
|
|
||||||
(140 sites to 7) and `cc-mode` will not add or remove it.
|
|
||||||
- No space between a function name and its argument list, at both call and
|
|
||||||
definition sites, and no padding inside call parentheses: `SDL_Log("x %d", n)`.
|
|
||||||
- Binary operators and assignment are surrounded by single spaces; unary
|
|
||||||
operators are not separated from their operand.
|
|
||||||
- **The pointer `*` binds to the identifier**, not the type: `char *name`,
|
|
||||||
`akgl_Actor **dest`. Never `char* name`.
|
|
||||||
- When a call is too long for one line, put each argument on its own line
|
|
||||||
indented one level past the callee, with the closing `)` on its own line. This
|
|
||||||
is the established shape for the `FAIL_*` and `CATCH` macros:
|
|
||||||
|
|
||||||
```c
|
|
||||||
FAIL_ZERO_RETURN(
|
|
||||||
errctx,
|
|
||||||
SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj),
|
|
||||||
AKERR_KEY,
|
|
||||||
"Unable to add actor to registry"
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compiler warnings
|
|
||||||
|
|
||||||
**`-Wall` is on for every target this project owns** -- the library, the test
|
|
||||||
suites and `charviewer` -- and the tree builds clean under it at every
|
|
||||||
optimization level.
|
|
||||||
|
|
||||||
**`-Werror` is not on by default.** It is `option(AKGL_WERROR ... OFF)`, turned
|
|
||||||
on only by the `cmake_build` and `performance` CI jobs. Two reasons, and both
|
|
||||||
are measured rather than assumed:
|
|
||||||
|
|
||||||
- libakgl is consumed with `add_subdirectory()`; `akbasic` does exactly that. A
|
|
||||||
target-level `-Werror` makes every diagnostic a newer compiler invents into a
|
|
||||||
broken build for somebody else's project.
|
|
||||||
- The warning set is not stable across build types. An `-O2` build reports ten
|
|
||||||
`-Wstringop-truncation` warnings that `-O0` and `-fsyntax-only` do not,
|
|
||||||
because they need the middle end. The two CI jobs that set `AKGL_WERROR` are
|
|
||||||
deliberately one of each: coverage at `-O0`, performance at `-O2`.
|
|
||||||
|
|
||||||
If you are checking this by hand, **compile for real at the optimization level
|
|
||||||
being shipped.** `-fsyntax-only` reported 6 of the 16 findings in `src/`.
|
|
||||||
|
|
||||||
**`-Wextra` is deliberately not adopted.** It adds 22 findings, 17 of which are
|
|
||||||
inherent to the design rather than defects: 13 `-Wunused-parameter`, because a
|
|
||||||
backend vtable entry or an SDL callback must match a signature whether it reads
|
|
||||||
every argument or not, and 4 `-Wimplicit-fallthrough` from libakerror's own
|
|
||||||
`PROCESS`/`HANDLE`/`HANDLE_GROUP`, which fall through between `case` labels by
|
|
||||||
design. Adopting it today would mean disabling both permanently to gain 5
|
|
||||||
`-Wsign-compare`. The fallthrough half is filed upstream as
|
|
||||||
`deps/libakerror/TODO.md` item 8; revisit when that lands.
|
|
||||||
|
|
||||||
**Vendored code is exempt, not fixed.** `deps/semver/semver.c` is listed
|
|
||||||
directly in `add_library(akgl ...)`, so the target's `PRIVATE` options reach it;
|
|
||||||
`set_source_files_properties(... COMPILE_OPTIONS "-w")` keeps a future `semver`
|
|
||||||
update from failing this build. The other vendored projects are separate targets
|
|
||||||
and are unaffected either way.
|
|
||||||
|
|
||||||
**Never silence a warning with a cast.** Three `-Wpointer-sign` findings in
|
|
||||||
`src/sprite.c` were real signedness mismatches -- `uint32_t *` passed where an
|
|
||||||
`int *` was expected -- and a cast would have hidden every one. That is the
|
|
||||||
whole argument of `TODO.md` item 37. Read into a correctly typed local, check
|
|
||||||
the range, and assign.
|
|
||||||
|
|
||||||
### No repository-wide formatter
|
|
||||||
|
|
||||||
There is no `clang-format` or linter wired into the build, and `cc-mode`'s
|
|
||||||
indentation engine is not exactly reproducible by `clang-format`. Do not
|
|
||||||
introduce one without discussion, and do not reformat code you are not otherwise
|
|
||||||
changing — unrelated whitespace churn makes review harder and is the reason the
|
|
||||||
style drifted in the first place.
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
- **Public functions**: `akgl_<subsystem>_<verb>`, all lower snake_case —
|
|
||||||
`akgl_actor_set_character`, `akgl_heap_next_string`. No camelCase, and never
|
|
||||||
embed a type name (`akgl_Actor_cmhf_left_on` is wrong; it should be
|
|
||||||
`akgl_actor_cmhf_left_on`).
|
|
||||||
- **Public types**: `akgl_TypeName` — `akgl_Actor`, `akgl_SpriteSheet`,
|
|
||||||
`akgl_PhysicsBackend`. Every type exported from a header takes the prefix.
|
|
||||||
`point` and `RectanglePoints` shipped bare until 0.5.0; they are
|
|
||||||
`akgl_Point` and `akgl_RectanglePoints` now, and neither is precedent.
|
|
||||||
- **Constants and macros**: `AKGL_UPPER_SNAKE_CASE`. A constant belongs to the
|
|
||||||
subsystem it describes: a character limit is `AKGL_CHARACTER_MAX_*`, not
|
|
||||||
`AKGL_SPRITE_MAX_CHARACTER_*`. Name a constant for what its value *is* — a
|
|
||||||
nanoseconds-per-millisecond scale factor is `AKGL_TIME_ONEMS_NS`.
|
|
||||||
- **Exported globals** take the `akgl_` prefix like any other public symbol. Do
|
|
||||||
not add bare names (`renderer`, `camera`, `window`), leading-underscore names
|
|
||||||
(reserved at file scope), or SCREAMING_SNAKE names for mutable objects —
|
|
||||||
`AKGL_UPPER_SNAKE_CASE` is for constants.
|
|
||||||
|
|
||||||
This one is not cosmetic and there is a scar to prove it. Until 0.5.0 the
|
|
||||||
library exported `renderer`, and `tests/character.c` defined a
|
|
||||||
`SDL_Renderer *renderer` of its own. Both had external linkage and the same
|
|
||||||
spelling, so the executable's definition preempted the shared library's: the
|
|
||||||
library read a `SDL_Renderer *` through an `akgl_RenderBackend *`, every
|
|
||||||
texture load in that suite failed, and the suite reported success anyway. A
|
|
||||||
consuming game with a variable called `renderer` would have hit the same
|
|
||||||
thing with no test to notice.
|
|
||||||
- **`static` helpers drop the `akgl_` prefix**, which exists only to avoid
|
|
||||||
external collisions.
|
|
||||||
- **Include guards**: `_AKGL_<FILE>_H_`, matching the file name. Guard names
|
|
||||||
without the project prefix risk colliding with system headers.
|
|
||||||
- **Parameter names must match between the declaration and the definition** —
|
|
||||||
Doxygen publishes the header spelling. Conventional names: `dest` for an
|
|
||||||
output parameter (not `dst`), `self` for a backend receiver, `obj` for the
|
|
||||||
instance being initialized or inspected, `e` for an incoming error context to
|
|
||||||
be inspected.
|
|
||||||
- **The local error context is named `errctx`** (92 sites to 45). New code uses
|
|
||||||
`errctx`; convert `e` when you touch a function for other reasons.
|
|
||||||
- **Header/source pairs are named by feature**: `include/akgl/sprite.h` and
|
|
||||||
`src/sprite.c`.
|
|
||||||
|
|
||||||
## Error-Handling Protocol
|
|
||||||
|
|
||||||
The `akerror` control-flow macros have a shape that must be followed exactly,
|
|
||||||
because the failure mode is silent.
|
|
||||||
|
|
||||||
- Inside an `ATTEMPT` block use the **`_BREAK`** variants (`FAIL_ZERO_BREAK`,
|
|
||||||
`FAIL_NONZERO_BREAK`, `FAIL_BREAK`) and `CATCH`. Outside it use the
|
|
||||||
**`_RETURN`** variants.
|
|
||||||
- **Never use a `*_RETURN` macro inside an `ATTEMPT` block.** It returns past the
|
|
||||||
`CLEANUP` block, so every release, `fclose`, and free in `CLEANUP` is skipped.
|
|
||||||
This has already caused a heap-string leak on the success path of
|
|
||||||
`akgl_get_json_tilemap_property`.
|
|
||||||
- `CLEANUP` must precede `PROCESS`. Transposing them moves the cleanup body into
|
|
||||||
the `PROCESS` switch, where it runs only when an error context exists.
|
|
||||||
- `CATCH` reports failure by `break`ing, which binds to the innermost enclosing
|
|
||||||
loop or `switch`. A `CATCH` written directly inside a `while` exits the loop
|
|
||||||
rather than the function — put the `ATTEMPT` block inside the loop.
|
|
||||||
- **Never `return` from inside a `HANDLE` block either.** `FINISH` ends with
|
|
||||||
`RELEASE_ERROR`, so leaving before it means the handled context is never given
|
|
||||||
back to `AKERR_ARRAY_ERROR` — one leaked slot per call, and the 129th call
|
|
||||||
aborts the process with "Unable to pull an error context from the array!".
|
|
||||||
`SUCCEED_RETURN` is safe because it releases first; a bare `return`, or a
|
|
||||||
`return f(...)` that tail-calls the fallback, is not. Set a flag in the
|
|
||||||
`HANDLE` block and act on it after `FINISH`. This has already cost a
|
|
||||||
process-killing leak in `akgl_path_relative`; see TODO.md, "Performance".
|
|
||||||
- Validate every pointer parameter before dereferencing it, including the ones a
|
|
||||||
sibling function happens not to check.
|
|
||||||
|
|
||||||
The first two rules are enforced by `scripts/check_error_protocol.py`, running
|
|
||||||
as the `error_protocol` test: it flags a `*_RETURN` inside an `ATTEMPT` block
|
|
||||||
and a `return` out of a `HANDLE` block. Both have shipped before. Neither
|
|
||||||
produces a compiler diagnostic, and neither fails a test run until the pool it
|
|
||||||
drains is empty, which is why a linter is the only thing that catches them.
|
|
||||||
|
|
||||||
It cannot see the third hazard -- a `CATCH` inside a loop nested in an `ATTEMPT`
|
|
||||||
block, where `break` binds to the loop. When a loop is the *entire* body of an
|
|
||||||
`ATTEMPT` that is harmless, because `CLEANUP`, `PROCESS` and `FINISH` still run;
|
|
||||||
anything after the loop has to account for it. `akgl_tilemap_load_layer_objects`
|
|
||||||
carries a comment saying exactly that, and `akgl_draw_background` uses a flag
|
|
||||||
instead for the same reason.
|
|
||||||
|
|
||||||
## API Surface
|
|
||||||
|
|
||||||
Every function with external linkage must be declared in a header, and every
|
|
||||||
declaration must have a definition. A non-`static` function that appears in no
|
|
||||||
header is still in the ABI but is unreachable by callers; a declaration with no
|
|
||||||
definition is a link error for anyone compiling against the header alone. If a
|
|
||||||
function is exposed only so tests can reach it, declare it under the existing
|
|
||||||
"part of the internal API" comment block in the relevant header.
|
|
||||||
|
|
||||||
Headers must be self-contained: include what you use, so that a translation unit
|
|
||||||
including a single `akgl` header compiles without a particular include order.
|
|
||||||
Within the project use the angled form, `#include <akgl/sibling.h>`.
|
|
||||||
|
|
||||||
This is enforced. `AKGL_PUBLIC_HEADERS` in `CMakeLists.txt` lists every header
|
|
||||||
installed into `include/akgl/`, and drives two things from that one list: what
|
|
||||||
`install()` ships, and a generated translation unit per header — each including
|
|
||||||
exactly that header and nothing before it — linked into the `headers` suite. A
|
|
||||||
new header is covered by adding it to that list. One header per translation
|
|
||||||
unit is the only shape that proves anything: a second `#include` after the
|
|
||||||
first proves nothing about the second, because by then the first has dragged
|
|
||||||
its dependencies in.
|
|
||||||
|
|
||||||
The declaration half is enforced too, by `scripts/check_api_surface.sh` running
|
|
||||||
as the `api_surface` test. It reads the built library's dynamic symbol table,
|
|
||||||
strips comments out of every public header, and fails on an exported `akgl_*`
|
|
||||||
symbol that is declared nowhere. **Stripping the comments is the point**: four
|
|
||||||
of the nineteen symbols this found were *mentioned* in `controller.h` prose,
|
|
||||||
which is not the same as being declared, and is how they stayed missing.
|
|
||||||
|
|
||||||
Declare no-argument functions as `(void)`, not `()`.
|
|
||||||
|
|
||||||
## Copying Into Fixed-Width Fields
|
|
||||||
|
|
||||||
**Never `strncpy` or `memcpy` into one of this library's fixed-width fields.
|
|
||||||
Use `aksl_strncpy()` from `akstdlib.h`.**
|
|
||||||
|
|
||||||
```c
|
|
||||||
PASS(errctx, aksl_strncpy(obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1));
|
|
||||||
```
|
|
||||||
|
|
||||||
`strncpy(dest, src, sizeof(dest))` leaves `dest` **unterminated** whenever the
|
|
||||||
source fills it, and `memcpy(dest, src, sizeof(dest))` reads past the end of any
|
|
||||||
source shorter than the field. Every name in this library is a registry key,
|
|
||||||
handed to `strcmp`, `realpath` and SDL property calls, none of which stop at the
|
|
||||||
field. Ten sites had the first problem and one had the second; that whole class
|
|
||||||
is the same one as the savegame overread and the `akgl_get_property` overread
|
|
||||||
before it.
|
|
||||||
|
|
||||||
`aksl_strncpy` always terminates and never NUL-pads. Two details matter:
|
|
||||||
|
|
||||||
- **Bound `n` at `sizeof(dest) - 1`.** It raises `AKERR_OUTOFBOUNDS` when the
|
|
||||||
bytes do not fit, and capping `n` is what preserves the "truncated, not
|
|
||||||
rejected" contract those headers document. Drop the cap only when you mean to
|
|
||||||
change that contract.
|
|
||||||
- **It does not pad.** Where the padding is load-bearing -- the savegame name
|
|
||||||
tables write a fixed-width field and the reader steps that many bytes --
|
|
||||||
`strncpy` into a pre-zeroed buffer is still correct. `write_name_field()` in
|
|
||||||
`src/game.c` is the one place that applies, and it says so.
|
|
||||||
|
|
||||||
`-Wstringop-truncation` catches the first form at `-O2`. Nothing catches the
|
|
||||||
`memcpy` form; that one was found by reading its neighbours.
|
|
||||||
|
|
||||||
## Reaching For libakstdlib
|
|
||||||
|
|
||||||
`deps/libakstdlib` wraps most of libc so a failure arrives as an
|
|
||||||
`akerr_ErrorContext *` with context instead of a return value the caller has to
|
|
||||||
remember to check. Use the wrapper **wherever the underlying call can actually
|
|
||||||
fail**, which in practice means anything touching a file, a format string, or a
|
|
||||||
fixed-width destination:
|
|
||||||
|
|
||||||
| Instead of | Use | Because |
|
|
||||||
|---|---|---|
|
|
||||||
| `fclose` | `aksl_fclose` | The flush happens here, so a full disk or an exceeded quota is reported *only* here |
|
|
||||||
| `fgetc` | `aksl_fgetc` | `fgetc(3)` returns `EOF` for both end-of-file and a read error; this raises `AKERR_EOF` and `AKERR_IO` separately |
|
|
||||||
| `snprintf` | `aksl_snprintf` | Truncation becomes `AKERR_OUTOFBOUNDS` naming both lengths, instead of a silently short string |
|
|
||||||
| `strncpy` into a fixed field | `aksl_strncpy` | See **Copying Into Fixed-Width Fields** |
|
|
||||||
| `fopen`/`fread`/`fwrite`/`realpath`/`atof`/`atoi` | the `aksl_` form | Already the convention in this tree |
|
|
||||||
|
|
||||||
**`fclose(3)` disassociates the stream whether or not it succeeds.** Drop the
|
|
||||||
`FILE *` before you look at the status, or `CLEANUP` closes it a second time:
|
|
||||||
|
|
||||||
```c
|
|
||||||
tmpfp = fp;
|
|
||||||
fp = NULL;
|
|
||||||
CATCH(errctx, aksl_fclose(tmpfp));
|
|
||||||
```
|
|
||||||
|
|
||||||
Written the other way round this aborts in glibc with `double free detected in
|
|
||||||
tcache`, and only on the path where a close actually fails -- which is to say,
|
|
||||||
never during development. `/dev/full` is how `tests/game.c` reaches it: writes
|
|
||||||
to that device are accepted and the `ENOSPC` surfaces at the flush.
|
|
||||||
|
|
||||||
**Do not convert the pure-arithmetic calls.** `memset`, `memcpy`, `strlen`,
|
|
||||||
`strcmp` and friends can only fail on a NULL argument, and the tree is
|
|
||||||
inconsistent about them already; see `TODO.md`, "libakstdlib wrappers not yet
|
|
||||||
adopted", before starting a sweep.
|
|
||||||
|
|
||||||
**Never silence `-Wformat-truncation`.** `include/akgl/error.h` still exports
|
|
||||||
`DISABLE_GCC_WARNING_FORMAT_TRUNCATION` and nothing uses it. Both sites that
|
|
||||||
did were genuinely truncating, and both reported the length problem as
|
|
||||||
something else -- one as a missing texture file, with SDL naming a path the
|
|
||||||
caller never wrote. `aksl_snprintf` is the answer.
|
|
||||||
|
|
||||||
## Fixing Defects
|
|
||||||
|
|
||||||
**Read what the code does before deciding what it should do.** Every rule below
|
|
||||||
is here because it was broken in this repository, and every one of them was
|
|
||||||
cheap to check and expensive to assume.
|
|
||||||
|
|
||||||
- **Check the surrounding code before designing around a hazard.** The savegame
|
|
||||||
name-width fix first grew four `AKGL_GAME_SAVE_*_NAME_WIDTH` aliases, so the
|
|
||||||
on-disk format's field widths could diverge from the object model's. They were
|
|
||||||
deleted: `akgl_game_load` compares the save's `libversion` against
|
|
||||||
`AKGL_VERSION` and refuses a mismatch **in the same function, sixteen lines
|
|
||||||
above the tables being edited**, so the divergence they defended against
|
|
||||||
cannot happen quietly. An
|
|
||||||
abstraction defending an impossible case is worse than none, because the next
|
|
||||||
reader has to work out what it is for.
|
|
||||||
|
|
||||||
- **Abstract when the second consumer is real, not before.** A `#define` with
|
|
||||||
one use, a wrapper with one caller, a parameter only ever passed one value:
|
|
||||||
delete it and inline the thing. Those four aliases had exactly one use per
|
|
||||||
side and each expanded to exactly one existing constant.
|
|
||||||
|
|
||||||
- **A test that has not failed has not been tested.** Break the fix, watch the
|
|
||||||
new test go red, put the fix back. Two tests in the 0.5.0 defect work passed
|
|
||||||
against the unfixed library on the first attempt:
|
|
||||||
|
|
||||||
- the savegame roundtrip asserted the load *succeeded*, and a reader stepping
|
|
||||||
the wrong field width does not fail — it finds a run of zeros mid-entry,
|
|
||||||
stops early, and reports success with silently wrong maps;
|
|
||||||
- the `akgl_get_json_with_default` test used `TEST_EXPECT_OK`, which releases
|
|
||||||
whatever context the statement returns — and that function hands *back* the
|
|
||||||
context it was given when it does not handle the status, so the `CLEANUP`
|
|
||||||
block released it a second time and the double release corrupted the
|
|
||||||
failure instead of reporting it.
|
|
||||||
|
|
||||||
Neither was discovered by reasoning. Both were discovered by reverting the fix
|
|
||||||
and being surprised.
|
|
||||||
|
|
||||||
- **Pick the test case that distinguishes the two behaviours, not the obvious
|
|
||||||
one.** `!AKGL_BITMASK_HAS(mask, bit)` misparses to `!(mask & bit) == bit`
|
|
||||||
without the parentheses — but for a bit that *is* set that is `0 == bit`,
|
|
||||||
false, which is the same answer the correct parse gives. It only diverges for
|
|
||||||
a bit that is **not** set whose value is **not** 1. The obvious test passes
|
|
||||||
either way.
|
|
||||||
|
|
||||||
- **Do not trust a comment, a TODO entry, or a CI exclusion that states a
|
|
||||||
premise.** Verify it. `tests/character.c` was excluded from two CI jobs and
|
|
||||||
from the mutation harness because it "fails deliberately". It did not: it was
|
|
||||||
exiting 0 while running one of its four tests. `TODO.md` carried eleven
|
|
||||||
entries describing code that had already changed. A premise nobody has
|
|
||||||
re-checked is where the next defect is hiding.
|
|
||||||
|
|
||||||
- **When a measurement moves, say what you measured.** Absolute benchmark
|
|
||||||
numbers drift with the machine. The `akgl_game_update` fix is recorded as the
|
|
||||||
*gap* between two rows of the same run — 92 µs before, noise after — because
|
|
||||||
a later run read every row 15% high, including rows nothing had touched. Do
|
|
||||||
not re-baseline a whole table to record one change.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Add yourself (agent program name, model name and version) as a co-author on every commit message
|
|
||||||
- Avoid dynamic memory allocation. Use the `akgl_heap_*` methods to retrieve necessary objects at runtime, and to release them when done. If the akgl heap facilities don't provide the kind of object needed, create a new heap layer to support that type of object.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
|
|
||||||
|
|
||||||
**Know who owns the error context an assertion consumes.** `TEST_EXPECT_OK`,
|
|
||||||
`TEST_EXPECT_STATUS` and `TEST_EXPECT_ANY_ERROR` all release whatever context
|
|
||||||
the statement returns. Some functions return *the context they were given* --
|
|
||||||
`akgl_get_json_with_default` hands its argument straight back when it does not
|
|
||||||
handle the status -- so a `CLEANUP` block that also releases that context
|
|
||||||
releases it twice, and a double-released context corrupts the failure instead of
|
|
||||||
reporting it. A test written that way **passes against the broken code**, which
|
|
||||||
is exactly what happened to the first draft of the `AKERR_OUTOFBOUNDS` test in
|
|
||||||
`tests/json_helpers.c`. When the call may hand the context back, take the result
|
|
||||||
into a local, `NULL` your own pointer, and release it yourself:
|
|
||||||
|
|
||||||
```c
|
|
||||||
defaulted = akgl_get_json_with_default(keyerr, &defval, &dest, sizeof(int));
|
|
||||||
keyerr = NULL; /* ownership passed either way */
|
|
||||||
if ( defaulted != NULL ) {
|
|
||||||
defaulted->handled = true;
|
|
||||||
defaulted = akerr_release_error(defaulted);
|
|
||||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "...");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Never `exit(3)` on an akerr status — call `akerr_exit()`.** An exit status is
|
|
||||||
one byte wide and libakgl's status band starts at 256, so `exit(AKGL_ERR_SDL)`
|
|
||||||
is a wait status of 0 and a shell sees a clean run. Every suite in this
|
|
||||||
directory once reported success on the most common failure a library built on
|
|
||||||
SDL can have: `tests/character.c` aborted at its second of four tests on a bad
|
|
||||||
renderer and was green until 0.5.0. `akerr_exit()` (libakerror 2.0.1) maps 0 to
|
|
||||||
0, 1–255 to themselves, and anything else to
|
|
||||||
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). The default unhandled-error handler
|
|
||||||
calls it, so an ordinary suite needs no trap of its own — `tests/testutil.h`
|
|
||||||
carried one until 0.6.0 and it is gone. A suite that installs its *own* handler
|
|
||||||
still has to call `akerr_exit()` from it; `tests/actor.c` does. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
|
|
||||||
|
|
||||||
### Physics simulations
|
|
||||||
|
|
||||||
`tests/physics_sim.c` is not a unit suite. `tests/physics.c` checks that
|
|
||||||
`akgl_physics_simulate` does the arithmetic it says it does; this one runs the
|
|
||||||
arcade backend for a second or two the way a game does and asks whether the
|
|
||||||
result is what a player would expect. It carries a Mario-esque jump and fall,
|
|
||||||
Zelda-style top-down walking, and a run reversed at full speed, because those
|
|
||||||
are the three shapes most 2D games are made of.
|
|
||||||
|
|
||||||
It found three defects that every existing unit test passed straight over, so
|
|
||||||
the distinction is worth keeping:
|
|
||||||
|
|
||||||
- **The first step was however long the level took to load.** `gravity_time`
|
|
||||||
was never initialized and `dt` was unbounded, so a 250 ms load produced a
|
|
||||||
250 ms step -- 100 px of fall where a 60 Hz frame is 0.44 px. Both
|
|
||||||
initializers seed the clock now, and `akgl_physics_simulate` bounds `dt` to
|
|
||||||
`max_timestep` (`physics.max_timestep`, default 0.05 s).
|
|
||||||
- **Releasing a vertical key cancelled gravity.** The `_off` handlers zeroed
|
|
||||||
`ay`, `ey`, `ty` and `vy` together, and `ey` is where the arcade backend
|
|
||||||
accumulates gravity, so tapping down mid-jump stopped the character in the
|
|
||||||
air. The handlers clear `ax`/`tx` (or `ay`/`ty`) and nothing else. Velocity
|
|
||||||
was never theirs to clear either -- `simulate` recomputes `v` as `e + t`
|
|
||||||
every step.
|
|
||||||
- **Diagonal movement was 41% too fast.** Thrust was capped per axis, so an
|
|
||||||
actor holding two directions got both caps at once and travelled their
|
|
||||||
diagonal. It is capped as a vector against the `sx`/`sy`/`sz` ellipse now.
|
|
||||||
|
|
||||||
Two rules for working on this suite:
|
|
||||||
|
|
||||||
- **Drive the clock, do not sleep on it.** `sim_step()` sets `gravity_time` to
|
|
||||||
`now - dt` and calls `simulate` once, so a simulated second is 60 steps and
|
|
||||||
takes no wall-clock time. Only `test_sim_first_step_is_not_a_leap` uses a real
|
|
||||||
`SDL_Delay`, because a real stall is the thing it is measuring.
|
|
||||||
- **`main` must `SDL_Init`.** Without it, SDL sets its clock epoch on first use,
|
|
||||||
`SDL_GetTicksNS()` returns something around 130 ns, and every dt-dependent
|
|
||||||
assertion passes for a reason that has nothing to do with the code. The
|
|
||||||
first-step test passed exactly that way before the `SDL_Init` call was added,
|
|
||||||
and only started failing -- correctly -- once it was there.
|
|
||||||
|
|
||||||
Each simulation prints what it measured whether it passes or fails, and `main`
|
|
||||||
exits with the number that failed. Read the numbers when changing the backend;
|
|
||||||
a change that keeps every assertion green while moving the apex of a jump by
|
|
||||||
30 px is a change worth noticing.
|
|
||||||
|
|
||||||
### Performance suites
|
|
||||||
|
|
||||||
`tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that
|
|
||||||
does) are benchmarks, built and registered like any other suite but listed in
|
|
||||||
`AKGL_PERF_SUITES` so they carry the `perf` label and a longer timeout. The
|
|
||||||
harness is `tests/benchutil.h`. Three rules keep the numbers honest, and all
|
|
||||||
three were learned by getting them wrong first:
|
|
||||||
|
|
||||||
- **Nothing that checks an error goes inside the clock.** `PASS` and `CATCH`
|
|
||||||
call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work
|
|
||||||
than several of the calls being measured. Use `BENCH_LOOP`, which stashes the
|
|
||||||
context and stops at the first failure, and hand it to `PASS` afterwards.
|
|
||||||
- **Flush the renderer before stopping the clock.** SDL batches: a `draw_*` call
|
|
||||||
queues a command and returns. `BENCH_FLUSH_STOP` in `tests/perf_render.c` is
|
|
||||||
there because the first version of that suite measured queueing, reported a
|
|
||||||
tilemap frame 250 times faster than it is, and paid the real cost at teardown
|
|
||||||
inside `SDL_DestroyTexture`.
|
|
||||||
- **A drawing benchmark needs a raw-SDL control that does the same pixel work.**
|
|
||||||
Without one there is no way to separate what libakgl costs from what the
|
|
||||||
rasterizer costs, and the answer is not the one you would guess — see
|
|
||||||
`PERFORMANCE.md`.
|
|
||||||
|
|
||||||
Budgets are per-operation ceilings at roughly ten times the recorded baseline;
|
|
||||||
they are enforced only in an optimized build at full scale. When a change moves
|
|
||||||
a number for a good reason, re-record the baseline in `PERFORMANCE.md` in the
|
|
||||||
same commit and say why it moved.
|
|
||||||
|
|
||||||
These two suites are also the memory-check vehicle. `tests/benchutil.h` detects
|
|
||||||
valgrind from `LD_PRELOAD` and drops the scale to
|
|
||||||
`AKGL_BENCH_VALGRIND_SCALE`, so the same binaries walk every path once instead
|
|
||||||
of a hundred thousand times, and the timings a checked run prints are labelled
|
|
||||||
as meaningless. **Do not add memory-check suites**: a new path worth checking
|
|
||||||
belongs in a benchmark, where it gets both.
|
|
||||||
|
|
||||||
## Testing a Tutorial
|
|
||||||
|
|
||||||
`ctest -R docs_examples` proves every listing in `docs/` still compiles and
|
|
||||||
still matches the file it was quoted from. It cannot prove the chapter *teaches*
|
|
||||||
anything: a document can be composed entirely of verified excerpts and still be
|
|
||||||
unfollowable, because what a reader needs is the glue between them.
|
|
||||||
|
|
||||||
**So test a tutorial by making somebody follow it.** Hand it to a subagent on a
|
|
||||||
weaker model with no context beyond the chapter, and have them build the thing it
|
|
||||||
describes. The weaker model is the point -- it will not paper over a gap with
|
|
||||||
knowledge the document did not give it, which is exactly the failure mode a
|
|
||||||
capable reviewer has.
|
|
||||||
|
|
||||||
### The rules that make the result mean something
|
|
||||||
|
|
||||||
- **Give them what a real reader has, and nothing more.** The chapter, the
|
|
||||||
library source, its public headers, and the art. **Not the finished program.**
|
|
||||||
Copy the tree with `examples/` and `docs/` removed rather than telling them not
|
|
||||||
to look -- a rule they can break is not a control.
|
|
||||||
- **Headers are fair game.** The manual defers signatures to Doxygen and a real
|
|
||||||
user has `include/akgl/*.h` in front of them. Forbidding those tests a reader
|
|
||||||
who does not exist.
|
|
||||||
- **They must compile it and run it.** This is the whole thing. A design nobody
|
|
||||||
built proves nothing, and the defects that matter most do not fail to compile.
|
|
||||||
- **Verify their work yourself.** Run the binary they produced. Look at the
|
|
||||||
screenshot they took. Do not take a report's word for what it built --
|
|
||||||
see "Reading the report" below.
|
|
||||||
- **Do not make them author assets by hand.** A real reader draws a map in Tiled;
|
|
||||||
hand-writing a `.tmj` tests nothing about the chapter and eats the whole
|
|
||||||
session. Give them `docs/tutorials/assets/`. The asset *formats* are already
|
|
||||||
covered by `docs_examples`, which runs the chapter's own JSON blocks through
|
|
||||||
`akgl_sprite_load_json` and `akgl_character_load_json`.
|
|
||||||
- **Give them a screenshot helper**, marked as scaffolding and not part of the
|
|
||||||
tutorial, so there is a picture to check. Screen output is evidence; an exit
|
|
||||||
status of 0 is not.
|
|
||||||
|
|
||||||
### Setting the sandbox up
|
|
||||||
|
|
||||||
One directory per reader, because two of them building at once in the same tree
|
|
||||||
collide:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rsync -a --exclude='.git' --exclude='build' --exclude='examples' --exclude='docs' \
|
|
||||||
. "$SB/reader/libakgl/"
|
|
||||||
cp docs/20-tutorial-sidescroller.md "$SB/reader/TUTORIAL.md"
|
|
||||||
cp -r docs/tutorials/assets/sidescroller "$SB/reader/art"
|
|
||||||
mkdir -p "$SB/reader/game"
|
|
||||||
```
|
|
||||||
|
|
||||||
A consumer using the CMake the chapter itself teaches --
|
|
||||||
`add_subdirectory(../libakgl libakgl)` plus the documented
|
|
||||||
`target_link_libraries` line -- configures and builds in about a minute from
|
|
||||||
cold, and seconds after that. Tell them to use
|
|
||||||
`cmake --build build --target <theirs> --parallel`, or they will also build
|
|
||||||
every test suite in the tree.
|
|
||||||
|
|
||||||
**Prove the path works before you hand it over.** Write a throwaway consumer
|
|
||||||
that opens a window and takes a screenshot, build it, run it, and delete it. A
|
|
||||||
reader who cannot build is a reader who finds nothing, and the fault will be
|
|
||||||
yours -- the screenshot helper's first draft included `akgl/renderer.h` for
|
|
||||||
`akgl_renderer`, which is declared in `akgl/game.h`, and would have cost them
|
|
||||||
the session.
|
|
||||||
|
|
||||||
### Reading the report
|
|
||||||
|
|
||||||
**Every finding is a hypothesis until you check it.** A weaker model reports its
|
|
||||||
own mistakes as documentation defects with total confidence, and both kinds are
|
|
||||||
worth having -- but only one is worth acting on.
|
|
||||||
|
|
||||||
- Reproduce the failure before believing it. One reader reported the dialogue
|
|
||||||
freeze broken; it had drawn its own map with the NPC out of reach and never
|
|
||||||
used the one it was given. Running the reference showed the player moving zero
|
|
||||||
pixels through the whole freeze window.
|
|
||||||
- A rejected finding usually still leaves something. That same map failure was
|
|
||||||
not a code defect, but it did show the chapter stated `TALK_RANGE` without
|
|
||||||
saying what it means when you are placing NPCs -- and that a too-distant NPC
|
|
||||||
produces *success*, not an error.
|
|
||||||
- Fix the document, not the reader. If they guessed a header name, the chapter
|
|
||||||
never named it.
|
|
||||||
|
|
||||||
### What this catches that review does not
|
|
||||||
|
|
||||||
Four read-only passes over the two tutorials in `docs/` found real gaps and
|
|
||||||
missed all three of these, which the first build-and-run found immediately:
|
|
||||||
|
|
||||||
| Defect | Why reading missed it |
|
|
||||||
|---|---|
|
|
||||||
| libakstdlib's header was never named -- `aksl_*` functions, `<akstdlib.h>` file | Every reviewer already knew, or did not have to write the `#include` |
|
|
||||||
| The asset directory defaulted to `"."` instead of the compiled-in macro | Prose said "falls back", and nobody had to run it from another directory |
|
|
||||||
| A published example output the chapter could not produce | The capture happened after teardown released the pool. Nothing fails to compile |
|
|
||||||
|
|
||||||
The third is the shape to remember: **a tutorial that prints an expected result
|
|
||||||
is making a claim, and a claim nobody executed is a claim that is probably
|
|
||||||
wrong.**
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
|
|
||||||
Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes.
|
|
||||||
945
CMakeLists.txt
602
PERFORMANCE.md
@@ -1,602 +0,0 @@
|
|||||||
# libakgl performance baseline
|
|
||||||
|
|
||||||
This is where the library actually spends its time, measured rather than guessed.
|
|
||||||
The numbers below are the first recorded baseline: libakgl 0.3.0, at commit
|
|
||||||
`f35443e` plus the perf suites themselves. Everything here is reproducible with
|
|
||||||
two commands, and the suites that produced it are checked in as
|
|
||||||
`tests/perf.c` and `tests/perf_render.c`.
|
|
||||||
|
|
||||||
Read it in this order if you only want the short version: **the frame budget** is
|
|
||||||
the part that matters and **what the numbers say** is the argument. The six
|
|
||||||
defects the suites turned up on the way — two of them process-killing — are in
|
|
||||||
`TODO.md` under **Performance**, along with the targets these numbers are
|
|
||||||
measured against.
|
|
||||||
|
|
||||||
## Reproducing it
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
||||||
cmake --build build --parallel
|
|
||||||
ctest --test-dir build -L perf --output-on-failure
|
|
||||||
```
|
|
||||||
|
|
||||||
Both suites print a table and exit non-zero if any measurement blew its budget.
|
|
||||||
They are ordinary CTest tests, so `ctest --test-dir build` runs them along with
|
|
||||||
everything else; `ctest --test-dir build -LE perf` leaves them out when you only
|
|
||||||
want the unit suites. `AKGL_BENCH_SCALE` scales every iteration count —
|
|
||||||
`AKGL_BENCH_SCALE=0.1` for a quick look, `10` for a long one. Below 1.0 the
|
|
||||||
budgets are reported but not enforced, because a short run is a noisy one.
|
|
||||||
|
|
||||||
The whole thing takes about 30 seconds: 4 s for `perf`, 25 s for `perf_render`.
|
|
||||||
|
|
||||||
## The machine
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| CPU | AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max |
|
|
||||||
| Memory | 62 GiB |
|
|
||||||
| OS | Linux 6.8.0-136-generic |
|
|
||||||
| Compiler | gcc 13.3.0, `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -g`) |
|
|
||||||
| SDL | vendored SDL 3.4.8, `dummy` video driver, `software` renderer |
|
|
||||||
| Target | 640x480, offscreen |
|
|
||||||
|
|
||||||
One machine, one build type, one afternoon. Treat the absolute numbers as this
|
|
||||||
laptop's and the *ratios* as the library's.
|
|
||||||
|
|
||||||
## How it is measured
|
|
||||||
|
|
||||||
- **Best of five.** Each benchmark runs five times and the harness keeps the
|
|
||||||
fastest. The mean is the wrong statistic on a shared machine: every source of
|
|
||||||
noise makes a run slower and none makes it faster.
|
|
||||||
- **No error checking inside the clock.** `PASS` and `CATCH` call
|
|
||||||
`akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than
|
|
||||||
several of the calls being measured. The timed loop stashes the context and
|
|
||||||
checks it after the clock stops.
|
|
||||||
- **The renderer is flushed inside the measurement.** SDL batches: a `draw_*`
|
|
||||||
call queues a command and returns. The first version of this suite measured
|
|
||||||
*queueing* and reported a tilemap frame at 65 µs; the deferred work then took
|
|
||||||
114 seconds to come out at teardown, inside `SDL_DestroyTexture`. Every
|
|
||||||
drawing benchmark now flushes before it stops the clock, and the numbers below
|
|
||||||
are 250x larger and true.
|
|
||||||
- **SDL's log output goes to a sink that discards it.** The library logs on
|
|
||||||
paths this suite calls hundreds of thousands of times; timing a write to a
|
|
||||||
terminal measures the terminal. What the *formatting* costs is measured
|
|
||||||
deliberately, by the pair of actor-spawn benchmarks.
|
|
||||||
- **Budgets are set at roughly 10x the measured baseline.** Loose enough that a
|
|
||||||
busy machine does not turn CI red, tight enough that a linear scan becoming
|
|
||||||
quadratic cannot hide. They are enforced only in an optimized build at full
|
|
||||||
scale — a coverage build measures gcov, not libakgl.
|
|
||||||
|
|
||||||
**The renderer caveat, stated once and loudly.** These draw benchmarks run
|
|
||||||
against SDL's *software* renderer. No shipped game does that. What a software
|
|
||||||
renderer buys is that all the work stays in this process where it can be timed,
|
|
||||||
and that the per-blit cost is at least honest about how much pixel traffic was
|
|
||||||
asked for. Read every drawing number as *a count of work libakgl asked for*, not
|
|
||||||
as a frame rate anyone would ship. That is exactly why the raw-SDL control rows
|
|
||||||
exist: they do the same pixel work with none of the library in the path, so the
|
|
||||||
difference between the two is libakgl's share, and that part *does* carry over
|
|
||||||
to a GPU backend.
|
|
||||||
|
|
||||||
## Static footprint
|
|
||||||
|
|
||||||
libakgl does not call `malloc`. Every one of these arrays exists from process
|
|
||||||
start whether the game uses one slot or all of them.
|
|
||||||
|
|
||||||
| Pool | Slots | Bytes each | Total |
|
|
||||||
|---|---:|---:|---:|
|
|
||||||
| `akgl_heap_actors` | 64 | 400 | 25,600 |
|
|
||||||
| `HEAP_SPRITE` | 1024 | 176 | 180,224 |
|
|
||||||
| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 |
|
|
||||||
| `HEAP_CHARACTER` | 256 | 184 | 47,104 |
|
|
||||||
| `akgl_heap_strings` | 256 | 4,100 | 1,049,600 |
|
|
||||||
| **pools, total** | | | **1,851,392** |
|
|
||||||
| `akgl_Tilemap` (one, as `_akgl_gamemap`) | 1 | 26,388,008 | 26,388,008 |
|
|
||||||
| — of which layers | 16 | 1,120,296 | 17,924,736 |
|
|
||||||
| — of which tilesets | 16 | 528,944 | 8,463,104 |
|
|
||||||
|
|
||||||
**28 MB of BSS before `main` runs, and 94% of it is one tilemap.** A layer is
|
|
||||||
512x512 `int` cells whether the map is 512x512 or 2x2, and a tileset carries a
|
|
||||||
65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is
|
|
||||||
not a hypothetical cost: zeroing that struct is 1.37 ms, and `akgl_tilemap_load`
|
|
||||||
pays it before it has read a byte of the map file.
|
|
||||||
|
|
||||||
## Results: everything that does not draw
|
|
||||||
|
|
||||||
`tests/perf.c`, full scale. `ns/op` is the best of five runs; `ops/sec` is its
|
|
||||||
reciprocal.
|
|
||||||
|
|
||||||
| Benchmark | Unit | ns/op | ops/sec |
|
|
||||||
|---|---|---:|---:|
|
|
||||||
| `heap_next_actor`, empty pool | call | 4.0 | 248,392,158 |
|
|
||||||
| `heap_next_actor`, one slot left | call | 36.9 | 27,077,696 |
|
|
||||||
| heap string claim + release cycle | cycle | 49.8 | 20,084,967 |
|
|
||||||
| `heap_next_string`, empty pool | call | 3.9 | 255,059,748 |
|
|
||||||
| `heap_next_string`, one slot left | call | 250.9 | 3,986,007 |
|
|
||||||
| `heap_release_string`, 4 KiB wipe | call | 47.2 | 21,191,918 |
|
|
||||||
| `heap_init`, all five pools | call | 45,141 | 22,153 |
|
|
||||||
| actor spawn + release, library logging on | actor | 202.3 | 4,942,862 |
|
|
||||||
| actor spawn + release, logging suppressed | actor | 162.5 | 6,153,324 |
|
|
||||||
| `akgl_actor_set_character`, registry lookup | call | 39.3 | 25,438,092 |
|
|
||||||
| `akgl_set_property` | call | 96.9 | 10,316,516 |
|
|
||||||
| `akgl_get_property`, 4 KiB copy | call | 85.3 | 11,722,134 |
|
|
||||||
| `akgl_character_sprite_get`, state to sprite | call | 37.4 | 26,734,605 |
|
|
||||||
| `akgl_actor_update`, animation advancing | actor | 68.4 | 14,609,892 |
|
|
||||||
| `akgl_actor_update`, no sprite for state | actor | 616.5 | 1,621,937 |
|
|
||||||
| `akgl_physics_simulate`, 64 live actors | frame | 1,216.8 | 821,841 |
|
|
||||||
| `akgl_physics_simulate`, empty pool | frame | 63.9 | 15,650,101 |
|
|
||||||
| logic frame: 64 updates + simulate | frame | 5,763.1 | 173,517 |
|
|
||||||
| `akgl_collide_rectangles`, overlapping | call | 6.1 | 164,345,776 |
|
|
||||||
| `akgl_collide_rectangles`, disjoint | call | 6.1 | 164,423,708 |
|
|
||||||
| control: all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 11,769.1 | 84,968 |
|
|
||||||
| `akgl_string_initialize` | call | 32.1 | 31,197,432 |
|
|
||||||
| `akgl_string_copy`, full length | call | 32.2 | 31,075,004 |
|
|
||||||
| `json_load_file`, small document | load | 11,338.8 | 88,193 |
|
|
||||||
| `akgl_get_json_string_value` | call | 40.7 | 24,552,958 |
|
|
||||||
| `akgl_get_json_integer_value` | call | 13.8 | 72,704,562 |
|
|
||||||
| `akgl_path_relative`, `realpath` on an existing file | call | 3,481.5 | 287,232 |
|
|
||||||
|
|
||||||
## Results: everything that draws
|
|
||||||
|
|
||||||
`tests/perf_render.c`, full scale, 640x480 software renderer. The two **control**
|
|
||||||
rows are raw `SDL_RenderTexture` loops with no libakgl in the path.
|
|
||||||
|
|
||||||
| Benchmark | Unit | ns/op | ops/sec |
|
|
||||||
|---|---|---:|---:|
|
|
||||||
| `frame_start` + `frame_end` (clear + present) | frame | 23,161.7 | 43,175 |
|
|
||||||
| `akgl_draw_point` | call | 132.5 | 7,547,141 |
|
|
||||||
| `akgl_draw_line`, screen diagonal | call | 605.9 | 1,650,375 |
|
|
||||||
| `akgl_draw_rect`, 200x150 outline | call | 520.8 | 1,920,055 |
|
|
||||||
| `akgl_draw_filled_rect`, 200x150 | call | 43,695.1 | 22,886 |
|
|
||||||
| `akgl_draw_circle`, radius 64 | call | 2,516.2 | 397,423 |
|
|
||||||
| `akgl_draw_copy_region`, 64x64 readback | call | 954.7 | 1,047,471 |
|
|
||||||
| `akgl_draw_paste_region`, 64x64 upload | call | 5,352.1 | 186,841 |
|
|
||||||
| `akgl_draw_flood_fill`, full 640x480 target | call | 1,967,295.6 | 508 |
|
|
||||||
| `akgl_text_measure`, 15 characters | call | 37.3 | 26,804,657 |
|
|
||||||
| `akgl_text_rendertextat`, 15 characters | call | 12,601.7 | 79,354 |
|
|
||||||
| `akgl_sprite_load_json`, sheet already loaded | load | 17,011.3 | 58,784 |
|
|
||||||
| `akgl_character_load_json`, two mappings | load | 14,506.8 | 68,933 |
|
|
||||||
| `akgl_tilemap_load` + release, fixture map | load | 11,881,190.8 | 84 |
|
|
||||||
| zeroing one `akgl_Tilemap` | call | 1,372,396.7 | 729 |
|
|
||||||
| `akgl_tilemap_compute_tileset_offsets`, 1728 tiles | call | 2,240.8 | 446,264 |
|
|
||||||
| `akgl_tilemap_draw`, 40x30 tiles, 1 tileset | frame | 16,260,566.6 | 61 |
|
|
||||||
| `akgl_tilemap_draw`, 40x30 tiles, 8 tilesets | frame | 16,395,182.4 | 61 |
|
|
||||||
| **control**: raw SDL blits, one source tile | frame | 471,676.6 | 2,120 |
|
|
||||||
| **control**: raw SDL blits, map order | frame | 16,229,501.0 | 62 |
|
|
||||||
| `akgl_actor_render`, on camera | actor | 2,992.0 | 334,220 |
|
|
||||||
| `draw_world`, 1200 tiles + 64 actors | frame | 16,484,493.6 | 61 |
|
|
||||||
| `akgl_game_update`, full frame | frame | 16,576,902.2 | 60 |
|
|
||||||
|
|
||||||
The `akgl_game_update` row is from before the sixteen-times-per-actor defect was
|
|
||||||
fixed in 0.5.0; see below. It sat about 92 µs above `draw_world` in the same
|
|
||||||
run, and now sits level with it. The absolute figures in this table have not
|
|
||||||
been re-taken -- a later run on this machine read every row about 15% high,
|
|
||||||
including rows nothing has touched, so the table is a record of one machine
|
|
||||||
state and the honest measurement of that fix is the *gap* between two rows of
|
|
||||||
the same run, not a new absolute.
|
|
||||||
|
|
||||||
## The frame budget
|
|
||||||
|
|
||||||
At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a
|
|
||||||
full screen of 16-pixel tiles and 64 actors:
|
|
||||||
|
|
||||||
| Part of the frame | Cost | Share of 16.67 ms |
|
|
||||||
|---|---:|---:|
|
|
||||||
| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% |
|
|
||||||
| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% |
|
|
||||||
| Clear + present | 0.023 ms | 0.1% |
|
|
||||||
| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% |
|
|
||||||
| 1200 tile blits | 16.26 ms | 97.6% |
|
|
||||||
| Six lines of HUD text | 0.076 ms | 0.5% |
|
|
||||||
|
|
||||||
**Everything libakgl decides is free. The pixels are the whole frame.** Every
|
|
||||||
piece of bookkeeping this library does — pool scans, registry lookups, state-to-
|
|
||||||
sprite mapping, physics, the error-context machinery — adds up to well under 1%
|
|
||||||
of a frame that is 97% software rasterization. On a GPU backend those blits get
|
|
||||||
cheap and libakgl's own share rises, which is exactly why the per-operation
|
|
||||||
numbers above matter more than the frame totals.
|
|
||||||
|
|
||||||
## What the numbers say
|
|
||||||
|
|
||||||
### The tilemap draw is SDL, not libakgl — and I can prove it
|
|
||||||
|
|
||||||
`akgl_tilemap_draw` takes 16.26 ms for a 1200-tile screen. A raw
|
|
||||||
`SDL_RenderTexture` loop issuing the *same 1200 blits from the same scattered
|
|
||||||
source tiles* takes 16.23 ms. The library's own per-tile work — the bounds
|
|
||||||
arithmetic, the tileset scan, the offset-table lookup, the backend indirection,
|
|
||||||
the error macros — is **0.03 ms per frame, under 0.2%**.
|
|
||||||
|
|
||||||
That took three attempts to measure honestly. A control that walked the sheet
|
|
||||||
sequentially said libakgl cost 67%; a control that blitted one source tile over
|
|
||||||
and over said it cost 3400%. Both were wrong, and both were wrong the same way:
|
|
||||||
they changed the *memory access pattern of the source texture* rather than
|
|
||||||
isolating the library. A 16x16 tile read from a random place in a 768x576 sheet
|
|
||||||
costs about 13 µs on this software rasterizer; the same tile read from cache
|
|
||||||
costs 0.4 µs. That factor of thirty is the whole story, and none of it is
|
|
||||||
libakgl's.
|
|
||||||
|
|
||||||
Related: the `FIXME` in `src/tilemap.c` worrying that the per-tile tileset scan
|
|
||||||
"is probably not very efficient" is, at eight tilesets, worth **0.8% of the
|
|
||||||
frame** (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it
|
|
||||||
should still be fixed, but it is not where the time is, and nobody should
|
|
||||||
reorganise the loader for it.
|
|
||||||
|
|
||||||
### The pools are linear scans, and only the string pool cares
|
|
||||||
|
|
||||||
Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is
|
|
||||||
36.9 ns — nine times the cost, and still nothing.
|
|
||||||
|
|
||||||
The string pool is the exception, and it is instructive. Claiming from an empty
|
|
||||||
string pool is 3.9 ns; claiming the *last free slot* is 250.9 ns, **64 times**
|
|
||||||
slower. Same algorithm, same 256-ish entries. The difference is that each
|
|
||||||
`akgl_String` is `PATH_MAX` + 4 bytes, so the scan touches one reference count
|
|
||||||
every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is
|
|
||||||
a megabyte, and walking it is walking a megabyte.
|
|
||||||
|
|
||||||
`akgl_heap_release_string` costs 47.2 ns because it `memset`s all 4,100 bytes
|
|
||||||
whether the string held a path or one character. Same for `akgl_string_copy`
|
|
||||||
(32.2 ns) and `akgl_get_property` (85.3 ns), which move `AKGL_MAX_STRING_LENGTH`
|
|
||||||
bytes unconditionally. None of these is expensive in isolation; all of them are
|
|
||||||
the same avoidable habit of paying for `PATH_MAX` when you used eleven bytes.
|
|
||||||
|
|
||||||
### Errors cost about ten times what success costs
|
|
||||||
|
|
||||||
`akgl_actor_update` on an actor whose character has a sprite for its state:
|
|
||||||
68.4 ns. The same call on an actor whose character does *not*: **616.5 ns**.
|
|
||||||
|
|
||||||
That path is not an error in any meaningful sense — the library handles
|
|
||||||
`AKERR_KEY` and carries on, and `akgl_actor_render` logs it and draws nothing.
|
|
||||||
It is a normal condition on a partly authored character. But raising it means
|
|
||||||
claiming a context out of `AKERR_ARRAY_ERROR`, formatting a message with
|
|
||||||
`vsnprintf`, appending a stack-trace frame, walking the `PROCESS` switch, and
|
|
||||||
releasing it again. Nine times the cost of the update it replaced.
|
|
||||||
|
|
||||||
The design conclusion is not "make errors cheaper". It is that a *routine*
|
|
||||||
condition should not be reported as an error. A character that has no sprite for
|
|
||||||
a state should answer that question with a boolean.
|
|
||||||
|
|
||||||
### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0
|
|
||||||
|
|
||||||
`akgl_game_update` looped over `AKGL_TILEMAP_MAX_LAYERS` with the actor sweep
|
|
||||||
nested inside it, and never compared an actor's `layer` to the layer it was on.
|
|
||||||
Every live actor's `updatefunc` ran **16 times per frame**: at 68.4 ns per
|
|
||||||
update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the
|
|
||||||
point, every piece of a game's own per-frame actor logic running sixteen times.
|
|
||||||
|
|
||||||
The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer
|
|
||||||
operation; drawing is, and `akgl_render_2d_draw_world` walks the layers itself.
|
|
||||||
`AKGL_ITERATOR_OP_LAYERMASK` is honoured now rather than ignored, so a caller
|
|
||||||
that genuinely wants one layer can still ask for it and gets each of those
|
|
||||||
actors once.
|
|
||||||
|
|
||||||
**How it was measured.** `tests/game.c` counts calls into a stub `updatefunc`
|
|
||||||
and asserts exactly one per live actor per `akgl_game_update`; against the old
|
|
||||||
code it reports 16. The timing side is the gap between the `akgl_game_update`
|
|
||||||
and `draw_world` rows of the *same* benchmark run: 92 µs before, and across
|
|
||||||
three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.
|
|
||||||
|
|
||||||
It was invisible in the frame total because the tilemap blits are three orders
|
|
||||||
of magnitude larger, which is exactly why it needed a counting test rather than
|
|
||||||
a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the
|
|
||||||
frame doing nothing.
|
|
||||||
|
|
||||||
### Text has no cache at all
|
|
||||||
|
|
||||||
`akgl_text_rendertextat` is 12.6 µs for fifteen characters: it rasterizes the
|
|
||||||
string, uploads it as a texture, blits it, and destroys the texture — every
|
|
||||||
call, every frame, for a score that changes once a second. Measuring the string
|
|
||||||
first with `akgl_text_measure` is 37.3 ns, i.e. free, which tells you the whole
|
|
||||||
cost is the rasterize-and-upload.
|
|
||||||
|
|
||||||
Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it
|
|
||||||
is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour)
|
|
||||||
would take it to nothing, and it is the single clearest optimisation in the
|
|
||||||
library.
|
|
||||||
|
|
||||||
### Loading is dominated by things that are not the file
|
|
||||||
|
|
||||||
A 2x2 fixture map with one tileset takes **11.9 ms** to load and release. Of
|
|
||||||
that, 1.37 ms — 11.5% — is `memset`ing the 26 MB `akgl_Tilemap` before anything
|
|
||||||
is read. Most of the rest is decoding the tileset PNG. The JSON is noise:
|
|
||||||
parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.
|
|
||||||
|
|
||||||
`akgl_path_relative` is 3.5 µs, because it is a `realpath(3)` syscall. A map
|
|
||||||
naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall
|
|
||||||
and not a string operation.
|
|
||||||
|
|
||||||
### Collision, measured
|
|
||||||
|
|
||||||
Added in 0.8.0. Every row below is from one run, on the machine described above.
|
|
||||||
|
|
||||||
| Operation | Unit | ns per unit | per second |
|
|
||||||
|---|---|---:|---:|
|
|
||||||
| narrowphase box/box, overlapping | call | 20.6 | 48,485,173 |
|
|
||||||
| narrowphase box/box, disjoint | call | 9.0 | 111,102,445 |
|
|
||||||
| narrowphase circle/circle, overlapping | call | 67.8 | 14,752,779 |
|
|
||||||
| grid `move`, proxy has not left its cells | actor | 11.5 | 86,944,486 |
|
|
||||||
| grid query, 64 actors | query | 43.2 | 23,155,384 |
|
|
||||||
| bsp query, 64 actors | query | 113.6 | 8,802,193 |
|
|
||||||
| tile query, actor standing on a floor | query | 15.6 | 64,142,653 |
|
|
||||||
| **a whole physics step with collision, 64 actors** | frame | 15,420.1 | 64,851 |
|
|
||||||
|
|
||||||
Three of those are worth reading rather than skimming.
|
|
||||||
|
|
||||||
**The box fast path is two to seven times cheaper than the general solver**, and
|
|
||||||
the disjoint case is cheaper still because the proxies' bounds reject it before
|
|
||||||
any shape arithmetic runs. A tile game is almost entirely box against box, so
|
|
||||||
9-20 ns describes a real frame and 67.8 is what a circle costs when a game asks
|
|
||||||
for one.
|
|
||||||
|
|
||||||
**The grid beats the tree by 2.6x on the same population.** That is the argument
|
|
||||||
for which one is the default, measured here rather than cited from somebody
|
|
||||||
else's engine. The tree also rebuilds whenever a proxy moves and that cost is not
|
|
||||||
in its row, so the gap in a scene that is actually moving is wider than 2.6x.
|
|
||||||
|
|
||||||
**A `move` that changes nothing costs 11.5 ns.** That is the incremental claim in
|
|
||||||
one number: an actor walking across a tile pays that on most frames and pays the
|
|
||||||
re-cell only when it crosses a boundary.
|
|
||||||
|
|
||||||
#### The whole step, which is the number that matters
|
|
||||||
|
|
||||||
**15.4 µs for 64 actors, of which 1.6 µs is the step without collision.** So
|
|
||||||
collision costs about 13.8 µs a frame at the default pool size — the proxy sync
|
|
||||||
pass, the broad phase, the narrowphase over whatever it returns, and the response
|
|
||||||
— which is **0.08% of a 16.67 ms frame**. The all-pairs control over the same
|
|
||||||
population is 12.1 µs, and it answers a strictly smaller question: it tests pairs
|
|
||||||
and does not sync, sub-step or resolve anything.
|
|
||||||
|
|
||||||
At `-DAKGL_MAX_HEAP_ACTOR=256`, a separate build measured on the same machine:
|
|
||||||
|
|
||||||
| Operation | Unit | ns per unit |
|
|
||||||
|---|---|---:|
|
|
||||||
| a whole physics step with collision, 256 actors | frame | 54,073.3 |
|
|
||||||
| all-pairs control over the same 256 actors (32,640 pairs) | sweep | 188,530.6 |
|
|
||||||
|
|
||||||
**54.1 µs against Target 12's 2 ms budget**, with the all-pairs control 3.5x more
|
|
||||||
expensive while still doing less. Population went up 4x and the step went up 3.5x,
|
|
||||||
which is the grid doing its job: the all-pairs sweep went up 15.6x over the same
|
|
||||||
range, as `n²` says it must. That measurement is from a `AKGL_BENCH_SCALE=0.3`
|
|
||||||
run of a purpose-built configuration and is not part of the CI baseline; the 64
|
|
||||||
row above is.
|
|
||||||
|
|
||||||
### Two rows moved, and not because collision is running
|
|
||||||
|
|
||||||
`akgl_physics_simulate` over 64 live actors went from 1,216.8 ns to 1,588.5, and
|
|
||||||
the logic frame moved with it -- **on a backend with no collision world
|
|
||||||
attached**, so no collision code runs in either. Two things account for it and
|
|
||||||
neither is the feature doing its job:
|
|
||||||
|
|
||||||
- **`akgl_Actor` grew from 415 to 464 bytes**, gaining a 40-byte collision shape,
|
|
||||||
an override flag and a proxy pointer. The step sweeps the whole pool, so that
|
|
||||||
is about 3 KB more working set streamed every frame. The empty-pool row is
|
|
||||||
unchanged at 58.7 ns, which is what says this is per *live* actor rather than
|
|
||||||
per slot.
|
|
||||||
- **Reaching the collision check through `CATCH` cost more than the check.**
|
|
||||||
`PASS` and `CATCH` call `akerr_valid_error_address`, which walks
|
|
||||||
`AKERR_ARRAY_ERROR`, so a function that early-returns is not free when it is
|
|
||||||
reached through one. Routing the no-collision path around the machinery
|
|
||||||
entirely took this row from 2,018.7 ns back to 1,588.5 -- the same lesson
|
|
||||||
`AGENTS.md` records for writing benchmarks, arriving in production code.
|
|
||||||
|
|
||||||
What is left is the struct, and it is paid whether or not a game uses collision.
|
|
||||||
That is worth knowing rather than smoothing over.
|
|
||||||
|
|
||||||
### The overlap test is fine; the all-pairs loop around it was the problem
|
|
||||||
|
|
||||||
`akgl_collide_rectangles` is 6.1 ns whether the rectangles overlap or not. It was
|
|
||||||
24.9 ns overlapping and 57.9 ns disjoint until 0.8.0, when it stopped being eight
|
|
||||||
corner-containment tests and became four span comparisons -- a change made to fix
|
|
||||||
the cross case it could not see, with the speed as a side effect rather than the
|
|
||||||
goal. The disjoint case improved most because it was the one that ran all eight
|
|
||||||
tests before answering.
|
|
||||||
|
|
||||||
**Read that 6.1 as a floor, not a measurement.** It is within this run's
|
|
||||||
resolution of a function that does almost nothing, and the rows above are not
|
|
||||||
re-baselined for it; only the rows the change actually moved are.
|
|
||||||
|
|
||||||
The cost was never the test. It was the loop a caller had to write around it,
|
|
||||||
because there was no broad phase: 2016 pairs for 64 actors, **12 µs** a frame,
|
|
||||||
0.07% of a 60 fps budget. Affordable, and still O(n²) -- at
|
|
||||||
`AKGL_MAX_HEAP_ACTOR=256` the same loop is 32,640 pairs and **188.5 µs measured**,
|
|
||||||
1.1% of the frame for a game that has done nothing yet.
|
|
||||||
|
|
||||||
**That loop is what 0.8.0 removed the need for.** A whole physics step with a
|
|
||||||
collision world attached is 54.1 µs at 256 actors, doing strictly more -- see
|
|
||||||
"Collision, measured" below. The all-pairs row stays as the `control:` it is
|
|
||||||
labelled, which is what it is measuring now.
|
|
||||||
|
|
||||||
### Spawning is cheap, and a third of it is a log line
|
|
||||||
|
|
||||||
An actor spawn — pool claim, `memset`, registry insert, release — is 202.3 ns
|
|
||||||
with the library's logging on and 162.5 ns with SDL's log priority raised so the
|
|
||||||
message is never formatted. **20% of a spawn is formatting a log line nobody
|
|
||||||
reads**, and that is with output going to a sink that throws it away; write it to
|
|
||||||
a terminal and it is far worse. `akgl_actor_initialize` and
|
|
||||||
`akgl_character_sprite_add` both log unconditionally at `INFO`.
|
|
||||||
|
|
||||||
## How other engines spend the same frame
|
|
||||||
|
|
||||||
The natural question after a baseline like this is how it compares to the
|
|
||||||
engines people actually ship 2D games on. The comparison below is against
|
|
||||||
Construct (2 and 3) and Phaser (3 and 4), because both publish real
|
|
||||||
engineering detail: Ashley Gullen's Construct tech blogs and the Phaser docs
|
|
||||||
and source say what they built, what they rejected, and — most usefully — what
|
|
||||||
they measured. Godot and GameMaker publish similar batching guidance and add
|
|
||||||
nothing the other two don't. Sources are at the end of the section; the
|
|
||||||
construct.net posts are Cloudflare-walled to non-browsers and were verified
|
|
||||||
through Wayback captures.
|
|
||||||
|
|
||||||
One caveat governs the whole comparison, so it goes first. **Every number
|
|
||||||
these engines publish assumes the pixels are on a GPU.** Construct's ~300 CPU
|
|
||||||
cycles per sprite and Phaser's 16,384-quad batch ceiling are budgets for
|
|
||||||
*building command lists*, not for touching pixels. Two things follow:
|
|
||||||
|
|
||||||
- Ashley's argument that a frame costs max(logic, render) — because the CPU
|
|
||||||
builds commands while the GPU rasterizes the previous batch — inverts under
|
|
||||||
a software renderer. Here the frame really is logic *plus* render, which is
|
|
||||||
why 97.6% of ours is blits.
|
|
||||||
- What transfers from their playbooks is the **culling and the caching**:
|
|
||||||
camera culls, dirty-flagged text textures, collision cells. What does not
|
|
||||||
transfer is the **batching**, and the control rows above already proved it:
|
|
||||||
libakgl's per-tile submission overhead is 0.2% of the frame. Batching is a
|
|
||||||
solution to a problem this library has measured itself not to have — and
|
|
||||||
SDL's renderer batches internally anyway.
|
|
||||||
|
|
||||||
### Tilemaps: they optimize draw calls; ours cost 0.2%
|
|
||||||
|
|
||||||
Construct 2 merged runs of identical tiles into rectangles and drew each
|
|
||||||
rectangle in one call — 120 draw calls down to 22 in their worked example —
|
|
||||||
and the Construct 3 runtime renders an entire tilemap layer with zero texture
|
|
||||||
switches, which they measured at 40x over C2 when every visible tile differs.
|
|
||||||
Phaser culls tiles to the camera every frame (one-tile padding by default),
|
|
||||||
and Phaser 4 added an opt-in `TilemapGPULayer` that draws a whole layer as a
|
|
||||||
single shader quad at a fixed cost per screen pixel, capped at 4096x4096
|
|
||||||
tiles.
|
|
||||||
|
|
||||||
All of that machinery exists to cut draw-call submission cost. libakgl's
|
|
||||||
equivalent cost is the 0.03 ms gap between `akgl_tilemap_draw` and the raw-SDL
|
|
||||||
control row. The 16.26 ms is rasterization, which none of those techniques
|
|
||||||
touch — a merged rectangle still fills the same pixels in software. The one
|
|
||||||
tilemap idea worth keeping in the back pocket is the one Construct *rejected*
|
|
||||||
in 2013 on simplicity grounds and Phaser shipped in 2026: push the whole layer
|
|
||||||
to the GPU. That is a backend decision, not a loop optimization, and the
|
|
||||||
per-tile bookkeeping measured above (~25 ns) is what would remain of the frame
|
|
||||||
when it happens.
|
|
||||||
|
|
||||||
### Sprites: their per-sprite budget and ours are the same order
|
|
||||||
|
|
||||||
Construct measured its C3 runtime at roughly 300 CPU cycles to render one
|
|
||||||
sprite — about 65 ns on this machine's clock — and got there by replacing
|
|
||||||
polymorphic dispatch with monomorphic code, which they measured as 70-88%
|
|
||||||
faster on their quad benchmark. Phaser batches 16,384 quads at a time and
|
|
||||||
multi-texture batching turned 210 sprites across 15 textures from 212 draw
|
|
||||||
operations into 2. libakgl's actor render bookkeeping is ~250 ns by
|
|
||||||
subtraction (target 3), through one `draw_texture` function pointer per actor
|
|
||||||
with an error scope per call. Same order of magnitude, no batch layer, and at
|
|
||||||
64 actors the whole sweep is 1.1% of the frame. Their lesson that dispatch
|
|
||||||
indirection is where per-sprite CPU goes is worth remembering if the actor
|
|
||||||
ceiling ever rises by an order of magnitude; at 64 it is noise.
|
|
||||||
|
|
||||||
### Text: everyone else has the cache
|
|
||||||
|
|
||||||
Phaser's `Text` object re-rasterizes its canvas and re-uploads the texture
|
|
||||||
*only when the content or style changes*; its `BitmapText` never pays even
|
|
||||||
that — their docs state "you don't incur any speed penalty when updating their
|
|
||||||
content because the underlying texture doesn't change." Construct's Text
|
|
||||||
plugin documents the same failure mode this suite measured: automatic-
|
|
||||||
resolution mode "can cause the text to constantly re-render when being
|
|
||||||
smoothly scaled," and fixed-resolution mode is the documented escape hatch.
|
|
||||||
Both engines treat re-rasterizing an unchanged string as a defect. libakgl
|
|
||||||
does it unconditionally, every call, every frame: 12.6 µs a string. The
|
|
||||||
dirty-flag cache in targets 8 and 9 is not novel engineering; it is the
|
|
||||||
industry floor.
|
|
||||||
|
|
||||||
### Collision: Construct already ran this experiment
|
|
||||||
|
|
||||||
Construct's brute-force collision at 1000-vs-1000 objects was about a million
|
|
||||||
checks per tick and ran at 10 fps on a desktop; their viewport-sized uniform
|
|
||||||
collision cells cut that to ~40,000 checks — a 96% reduction, six times
|
|
||||||
faster — with incremental insert/remove as objects move and zero cost for
|
|
||||||
static objects. They evaluated quadtrees and rejected them as "more
|
|
||||||
complicated and can be more expensive to update as objects move around."
|
|
||||||
Phaser went the other way: Arcade Physics keeps dynamic bodies in an RTree
|
|
||||||
that is *cleared and rebuilt every frame*, and their own docs say a
|
|
||||||
"conservative estimate of around 5,000 bodies should be considered the max"
|
|
||||||
before turning the tree off beats keeping it.
|
|
||||||
|
|
||||||
libakgl has no broad phase at all, and at the current 64-actor ceiling it
|
|
||||||
does not need one: the all-pairs sweep is 115 µs, 0.7% of the frame. The
|
|
||||||
comparison settles *which* broad phase fits when the ceiling rises — the
|
|
||||||
incremental uniform grid, which is allocation-free at steady state and simple
|
|
||||||
enough that its authors chose it over the fancier structure, not the
|
|
||||||
rebuild-every-frame tree. The design is on record in `TODO.md` under
|
|
||||||
**Performance -> The plan**.
|
|
||||||
|
|
||||||
### Memory: stricter than either, and paying for it wrong
|
|
||||||
|
|
||||||
Construct budgets memory layout-by-layout — only the current layout's images
|
|
||||||
are resident, every image is width x height x 4 bytes regardless of source
|
|
||||||
format, and peak memory is the worst single layout. Their create/destroy
|
|
||||||
churn is a first-class engine metric (5x faster in the C3 runtime), and their
|
|
||||||
anti-jank advice — place one instance in the layout so its texture is loaded,
|
|
||||||
then destroy it at start — is a warm-the-pool idiom. Phaser's pooling is
|
|
||||||
`Group` objects with `maxSize` as a hard cap, recycling members by toggling
|
|
||||||
`active`. Both engines converge on what libakgl starts from: fixed budgets
|
|
||||||
and recycled objects.
|
|
||||||
|
|
||||||
The difference is what the budget buys. Their costs scale with what a scene
|
|
||||||
*uses*; libakgl's scale with what the build *allows* — 28 MB of BSS with 94%
|
|
||||||
in one worst-case-sized tilemap, 4 KiB string wipes for eleven-byte strings,
|
|
||||||
pool scans that walk the ceiling rather than the population. The static-pool
|
|
||||||
discipline is right, and stricter than either engine. Targets 6, 7, 14 and 15
|
|
||||||
are all the same correction: keep the fixed ceiling, stop paying for it per
|
|
||||||
operation.
|
|
||||||
|
|
||||||
### The frame split: the same shape as 2012
|
|
||||||
|
|
||||||
Ashley wrote in 2012 that "it's not unusual for even a fairly complex game to
|
|
||||||
spend 10% of the time on the logic, and 90% of the time on rendering — even
|
|
||||||
when hardware accelerated!" That is a stated profiling experience from the
|
|
||||||
canvas era, with no published methodology, and it is cited here as exactly
|
|
||||||
that and nothing more; Phaser publishes no frame-composition profile at all,
|
|
||||||
and no official Phaser bunnymark number exists to borrow (third-party figures
|
|
||||||
float around; none are cited here). libakgl's measured 97.6% blits against
|
|
||||||
under 1% bookkeeping is the same shape, more extreme, because software
|
|
||||||
rasterization is slower than a GPU and the logic is C rather than JavaScript.
|
|
||||||
Both lead to the conclusion already in this document: the pixels are the
|
|
||||||
whole frame, and the per-operation bookkeeping numbers are what matter the
|
|
||||||
day a GPU backend makes the pixels cheap.
|
|
||||||
|
|
||||||
### Sources
|
|
||||||
|
|
||||||
- Ashley Gullen, [How the Construct 2 WebGL renderer works](https://www.construct.net/en/blogs/ashleys-blog-2/construct-webgl-renderer-works-917) (2014)
|
|
||||||
- Ashley Gullen, [Tech blog: Tilemap tidbits](https://www.construct.net/en/blogs/ashleys-blog-2/tech-blog-tilemap-tidbits-913) (2013)
|
|
||||||
- Ashley Gullen, [Collision cell optimisation in r155](https://www.construct.net/en/blogs/ashleys-blog-2/collision-cell-optimisation-914) (2013)
|
|
||||||
- Ashley Gullen, [How render cells work](https://www.construct.net/en/blogs/ashleys-blog-2/render-cells-work-921) (2014)
|
|
||||||
- Ashley Gullen, [Optimisation: don't waste your time](https://www.construct.net/en/blogs/construct-official-blog-1/optimisation-dont-waste-time-768) (2012)
|
|
||||||
- Scirra, [Announcing the Construct 3 runtime](https://www.construct.net/en/blogs/construct-official-blog-1/announcing-construct-runtime-904) (2018)
|
|
||||||
- Construct 3 manual: [Performance tips](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/performance-tips), [Memory usage](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/memory-usage), [Text](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/text), [Sprite Font](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/sprite-font)
|
|
||||||
- Phaser, [Phaser 4 rendering concepts](https://phaser.io/tutorials/phaser-4-rendering-concepts)
|
|
||||||
- Phaser, [How to render thousands of sprites in Phaser 4](https://phaser.io/news/2026/05/phaser4-spritegpulayer-performance)
|
|
||||||
- Phaser, [Advanced rendering tutorial, part 2: multi-texture batching](https://phaser.io/tutorials/advanced-rendering-tutorial/part2)
|
|
||||||
- Phaser docs: [Text](https://docs.phaser.io/phaser/concepts/gameobjects/text), [BitmapText](https://docs.phaser.io/phaser/concepts/gameobjects/bitmap-text), [Group](https://docs.phaser.io/phaser/concepts/gameobjects/group)
|
|
||||||
- Phaser source: [physics/arcade/World.js](https://github.com/phaserjs/phaser/blob/master/src/physics/arcade/World.js), [tilemaps/components/CullTiles.js](https://github.com/phaserjs/phaser/blob/master/src/tilemaps/components/CullTiles.js), [core/Config.js](https://github.com/phaserjs/phaser/blob/master/src/core/Config.js)
|
|
||||||
|
|
||||||
## Defects these tests found
|
|
||||||
|
|
||||||
Six, and they are filed where defects live: `TODO.md`, under **Performance ->
|
|
||||||
Defects the perf suites found**, items 28-33, each with its file, line,
|
|
||||||
functional consequence, and what fixing it would touch. They are not repeated
|
|
||||||
here.
|
|
||||||
|
|
||||||
Worth saying in this document, because it is the argument for having written the
|
|
||||||
suites at all: stress testing breaks things unit tests do not reach. Two of the
|
|
||||||
six are process-killing — an error-context leak that aborted on the 129th path
|
|
||||||
resolution (fixed, with a regression test), and a pooled-string leak that turns
|
|
||||||
into a segfault rather than an `AKGL_ERR_HEAP` around the 52nd map load. Neither
|
|
||||||
is reachable by a suite that calls each function a handful of times, and neither
|
|
||||||
had anything to do with speed. They came out of loops that ran the same call ten
|
|
||||||
thousand times in one process, which is what a running game does and what
|
|
||||||
nothing else in this tree did.
|
|
||||||
|
|
||||||
## What this report does not cover
|
|
||||||
|
|
||||||
- **One machine.** No ARM, no Raspberry Pi, no Windows, no macOS. The ratios
|
|
||||||
should travel; the absolute numbers will not.
|
|
||||||
- **No GPU backend.** Everything drawing-related is a software rasterizer under
|
|
||||||
the dummy video driver. The share of a frame that belongs to libakgl is a
|
|
||||||
floor, not an estimate.
|
|
||||||
- **No audio.** `src/audio.c` is not benchmarked at all; the mixer under the
|
|
||||||
dummy driver does not do the work a real device would.
|
|
||||||
- **No controller input.** The event path is driven by SDL and has no gamepad to
|
|
||||||
drive it under the dummy driver.
|
|
||||||
- **No heap profiling beyond the static footprint.** Nothing here measures peak
|
|
||||||
or steady-state heap usage. Correctness of the heap *is* covered now, by
|
|
||||||
`cmake --build build --target memcheck`, which runs these same binaries under
|
|
||||||
valgrind — `tests/benchutil.h` cuts the iteration counts by three orders of
|
|
||||||
magnitude when it finds itself there, so a benchmark becomes a path-coverage
|
|
||||||
program. That run found six more defects, including a leak of the parsed JSON
|
|
||||||
document in every asset loader; they are in `TODO.md` under **Memory
|
|
||||||
checking**. What is still missing is a profile: how much a running game
|
|
||||||
actually holds at once, and how that grows over an hour.
|
|
||||||
- **Single-threaded throughout.** The library is not thread-safe by design
|
|
||||||
(`akgl_game_state_lock` guards one field), and nothing here tests contention.
|
|
||||||
219
README.md
@@ -1,219 +0,0 @@
|
|||||||
# libakgl
|
|
||||||
|
|
||||||
A C library for building 2D games on SDL3. Not an engine: no editor, no scripting layer, no
|
|
||||||
inheritance, and no runtime `malloc`. Behaviour attaches to a struct as function pointers,
|
|
||||||
objects come from fixed pools, and every call reports failure through an error context the
|
|
||||||
caller cannot silently ignore.
|
|
||||||
|
|
||||||
## The manual
|
|
||||||
|
|
||||||
**[`docs/`](docs/README.md) is the manual** — twenty-one chapters covering every subsystem,
|
|
||||||
plus two complete tutorial games. Start at
|
|
||||||
[Chapter 3, Getting started](docs/03-getting-started.md) if you want a window on screen, or
|
|
||||||
[Chapter 2, Design philosophy](docs/02-design-philosophy.md) if you want to know why the
|
|
||||||
library is shaped the way it is.
|
|
||||||
|
|
||||||
The two tutorials build real, running programs that live under [`examples/`](examples):
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| [A 2D sidescroller](docs/20-tutorial-sidescroller.md) | Gravity, a jump, collision you write yourself, coins and hazards |
|
|
||||||
| [A top-down JRPG](docs/21-tutorial-jrpg.md) | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower |
|
|
||||||
|
|
||||||
For per-function reference, build the Doxygen output with `doxygen Doxyfile`; it is a CI
|
|
||||||
gate, so an undocumented symbol fails the build.
|
|
||||||
|
|
||||||
This README covers the development process only — hooks, mutation testing, benchmarks and
|
|
||||||
memory checking. Everything about *using* the library is in `docs/`.
|
|
||||||
|
|
||||||
> **The task-oriented FAQ that used to live here has moved into `docs/`, corrected.** It was
|
|
||||||
> not merely incomplete: its examples did not compile. Two unbalanced `PASS()` calls, a
|
|
||||||
> `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a
|
|
||||||
> bitmask expression, an `int screenwidth = NULL`, and — in the first snippet a reader ever
|
|
||||||
> saw — the exact `strncpy` call `AGENTS.md` forbids. The prose had drifted with it: its
|
|
||||||
> claim that the engine "ONLY supports TilED TMJ tilemaps with tileset external references"
|
|
||||||
> is backwards, and the loader accepts only *embedded* tilesets. Writing the manual turned
|
|
||||||
> up twenty-seven such claims across the headers. Every example in `docs/` is now compiled,
|
|
||||||
> linked, run or matched against the source tree by `ctest`, so that class of drift fails a
|
|
||||||
> build instead of reaching a reader.
|
|
||||||
|
|
||||||
## Documentation examples
|
|
||||||
|
|
||||||
Every fenced example in `docs/` carries an info string saying what it is, and the
|
|
||||||
`docs_examples` test acts on it: `c` blocks are compiled, `c run=` blocks are linked against
|
|
||||||
the library and executed headless with their output compared byte for byte, `c excerpt=`
|
|
||||||
blocks must still appear verbatim in the file they quote, `json` blocks are loaded through
|
|
||||||
the real `akgl_*_load_json`, and `c screenshot=` blocks generate the figures in
|
|
||||||
`docs/images/`. A block with no info string is a hard error, because the failure mode the
|
|
||||||
harness exists to prevent is passing while checking nothing.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ctest --test-dir build -R docs_examples --output-on-failure
|
|
||||||
ctest --test-dir build -R docs_screenshots --output-on-failure
|
|
||||||
cmake --build build --target docs_screenshots # regenerate the figures
|
|
||||||
```
|
|
||||||
|
|
||||||
While editing one chapter, run it directly rather than the whole suite:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./tests/docs_examples.sh --root . \
|
|
||||||
--cflags-file build/docs_cflags.txt \
|
|
||||||
--ldflags-file build/docs_ldflags.txt \
|
|
||||||
--checkjson build/akgl_docs_checkjson \
|
|
||||||
docs/14-physics.md
|
|
||||||
```
|
|
||||||
|
|
||||||
`docs/MAINTENANCE.md` is the reference for the info strings, the preludes and the fixtures.
|
|
||||||
There is deliberately no docs-path filter in CI: documentation goes stale because the *code*
|
|
||||||
moved, not because somebody edited a chapter.
|
|
||||||
|
|
||||||
## Git hooks
|
|
||||||
|
|
||||||
The repository ships a `pre-commit` hook that keeps committed C sources in the
|
|
||||||
project's canonical format (Emacs `cc-mode` "stroustrup"; see `AGENTS.md` for the
|
|
||||||
full style guide). The hook lives in `scripts/hooks/` and is version controlled,
|
|
||||||
but **Git configuration is not cloned**, so every clone has to be pointed at it
|
|
||||||
once:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git config core.hooksPath scripts/hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm it took effect:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git rev-parse --git-path hooks # should print: scripts/hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
That is the whole installation. The hook is already committed with its
|
|
||||||
executable bit set, so nothing needs `chmod`.
|
|
||||||
|
|
||||||
### What the hook does
|
|
||||||
|
|
||||||
On each commit it looks at the **staged** content of any added, copied, modified,
|
|
||||||
or renamed `.c`/`.h` file under `src/`, `include/`, `tests/`, or `util/`, and
|
|
||||||
reindents it if it does not already match the canonical style. Checking the
|
|
||||||
staged content rather than the working tree means what lands in the commit is
|
|
||||||
what was actually verified.
|
|
||||||
|
|
||||||
When a file needs reindenting, the hook fixes it in the working tree and
|
|
||||||
re-stages it — but only when the index and working tree agree for that file. If
|
|
||||||
they differ, you have staged part of a file with `git add -p`, and re-staging
|
|
||||||
would sweep your unstaged work into the commit. Rather than do that silently the
|
|
||||||
hook stops and prints the commands to run yourself:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/reindent.sh path/to/file.c
|
|
||||||
git add path/to/file.c
|
|
||||||
```
|
|
||||||
|
|
||||||
`include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
The hook drives Emacs in batch mode, because `cc-mode`'s indentation engine is
|
|
||||||
the definition of the style and `clang-format` cannot reproduce it exactly. If
|
|
||||||
`emacs` is not on `PATH` the hook prints a warning and allows the commit — a
|
|
||||||
hook that refuses to run without an optional tool only teaches people to reach
|
|
||||||
for `--no-verify`. Install Emacs to get the check; without it, formatting is on
|
|
||||||
you.
|
|
||||||
|
|
||||||
### Bypassing and uninstalling
|
|
||||||
|
|
||||||
Skip the hook for a single commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git commit --no-verify
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove it entirely:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git config --unset core.hooksPath
|
|
||||||
```
|
|
||||||
|
|
||||||
### If you already have local hooks
|
|
||||||
|
|
||||||
`core.hooksPath` **replaces** the hooks directory outright — once it is set, Git
|
|
||||||
stops reading `.git/hooks/` altogether, so any hooks you keep there will silently
|
|
||||||
stop firing. If that matters, leave `core.hooksPath` unset and symlink just this
|
|
||||||
one hook instead:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ln -s ../../scripts/hooks/pre-commit .git/hooks/pre-commit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Formatting without the hook
|
|
||||||
|
|
||||||
The hook is a convenience, not the source of truth. The same check is available
|
|
||||||
directly, and is what you would run in CI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/reindent.sh --check # list non-conforming files; exit 1 if any
|
|
||||||
scripts/reindent.sh # reindent every tracked C source in place
|
|
||||||
scripts/reindent.sh src/game.c # reindent specific files
|
|
||||||
```
|
|
||||||
|
|
||||||
`--check` exits `0` when everything conforms, `1` when a file needs reindenting,
|
|
||||||
and `2` if Emacs is missing or the script cannot run — so a CI job that treats
|
|
||||||
any non-zero status as failure will not mistake a broken toolchain for a clean
|
|
||||||
tree.
|
|
||||||
|
|
||||||
## Mutation testing
|
|
||||||
|
|
||||||
The mutation harness makes one deliberate source-code change at a time in a scratch copy, then rebuilds and runs the passing CTest suite to measure whether tests detect the change. The known-failing `character` test is excluded by default.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cmake --build build --target mutation
|
|
||||||
scripts/mutation_test.py --target src/tilemap.c --list
|
|
||||||
scripts/mutation_test.py --target src/tilemap.c --max-mutants 10
|
|
||||||
scripts/mutation_test.py --threshold 40 --junit mutation-junit.xml
|
|
||||||
```
|
|
||||||
|
|
||||||
The default run covers all libakgl-owned files under `src/`. Use repeated `--target` options to narrow the scope. A surviving mutant identifies behavior that the current tests do not verify; the script prints its file, line, operator, and exact edit. The real working tree is never mutated.
|
|
||||||
|
|
||||||
## Performance testing
|
|
||||||
|
|
||||||
`tests/perf.c` and `tests/perf_render.c` are benchmarks rather than unit tests: they drive the hot paths — pool acquire and release, the registry, the physics sweep, the per-actor update and render, the tilemap draw, text, and asset loading — and print what each one costs per operation.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ctest --test-dir build -L perf --output-on-failure # benchmarks only, about 30 seconds
|
|
||||||
ctest --test-dir build -LE perf # everything except the benchmarks
|
|
||||||
AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf # a tenth of the iterations
|
|
||||||
```
|
|
||||||
|
|
||||||
Each measurement is the best of five runs and is held to a budget set at roughly ten times the recorded baseline, so a suite that turns red means an algorithmic regression rather than a busy machine. Budgets are enforced only in an optimized build at full scale; below `AKGL_BENCH_SCALE=1.0`, and in a coverage build, they are reported without failing.
|
|
||||||
|
|
||||||
`PERFORMANCE.md` carries the recorded baseline, the frame budget it adds up to, and what the numbers say — including the raw-SDL control rows that separate what libakgl costs from what the rasterizer costs. The six defects the stress tests turned up, and the targets the numbers are measured against, are in `TODO.md` under **Performance**.
|
|
||||||
|
|
||||||
## Memory checking
|
|
||||||
|
|
||||||
`memcheck` runs the suites that already exist under valgrind rather than adding suites of its own. The perf binaries carry most of it: they are the only programs in the tree that load assets, draw a scene, and run a frame loop in one process, and `tests/benchutil.h` cuts their iteration counts by three orders of magnitude when it detects valgrind — a benchmark walks one path a hundred thousand times, a leak check wants every path walked once.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cmake --build build --target memcheck # everything, about 30 seconds
|
|
||||||
scripts/memcheck.sh -R tilemap # one suite; ctest selection flags pass through
|
|
||||||
scripts/memcheck.sh -LE perf # skip the benchmarks
|
|
||||||
```
|
|
||||||
|
|
||||||
The run forces `SDL_VIDEO_DRIVER=dummy`, `SDL_RENDER_DRIVER=software` and `SDL_AUDIO_DRIVER=dummy` so the vendor GPU stack is never loaded — that removes thousands of unfixable findings inside the driver without suppressing anything. Definite leaks, invalid accesses and uninitialised reads are counted and set the exit status; "still reachable" is not, because that is what SDL and FreeType keep for the process lifetime. Suppressions for genuine third-party findings are in `scripts/valgrind.supp`.
|
|
||||||
|
|
||||||
What it found the first time it ran is in `TODO.md` under **Memory checking**.
|
|
||||||
|
|
||||||
## What is compiled in, and the notice it carries
|
|
||||||
|
|
||||||
**[libccd](https://github.com/danfis/libccd) is compiled into `libakgl.so`.** It is the
|
|
||||||
collision narrowphase -- GJK, EPA and MPR, the same code vendored in ODE, FCL and Bullet --
|
|
||||||
and it is **BSD-3-Clause**. Its licence is `deps/libccd/BSD-LICENSE`, and `cmake --install`
|
|
||||||
puts a copy at `share/doc/akgl/BSD-LICENSE.libccd`. If you redistribute a binary built from
|
|
||||||
this tree, that notice has to travel with it.
|
|
||||||
|
|
||||||
It is the only dependency in that position. SDL3, SDL3_image, SDL3_mixer, SDL3_ttf, jansson,
|
|
||||||
libakerror and libakstdlib are all linked as separate shared objects and carry their own
|
|
||||||
notices; `deps/semver` is a single header, MIT, installed alongside ours; and `deps/tg` is
|
|
||||||
vendored but **compiled into nothing** -- see `TODO.md`, "tg is vendored and has no
|
|
||||||
consumer".
|
|
||||||
|
|
||||||
libccd is given a static arena rather than the allocator it ships with, so "libakgl does not
|
|
||||||
call `malloc` at runtime" stays literally true with it linked in. That is
|
|
||||||
`src/collision_arena.c`, and [Chapter 15](docs/15-collision.md) explains why.
|
|
||||||
1
deps/clay
vendored
2
deps/libakerror
vendored
2
deps/libakstdlib
vendored
1
deps/libccd
vendored
1
deps/tg
vendored
@@ -1,135 +0,0 @@
|
|||||||
# 01. Introduction
|
|
||||||
|
|
||||||
libakgl is a C library for building 2D games on SDL3. This is version **0.8.0**. It ships
|
|
||||||
193 functions declared across the twenty-three public headers in `include/akgl/`, plus
|
|
||||||
`akgl_version()` from the generated `version.h` — 194 exported `akgl_` symbols in
|
|
||||||
`libakgl.so.0.8`.
|
|
||||||
|
|
||||||
**0.8.0 is an ABI break.** The `collide` slot on `akgl_PhysicsBackend` changed signature,
|
|
||||||
four public structs grew, `akgl_physics_arcade_collide` stopped raising `AKERR_API`, and
|
|
||||||
four symbols were removed — `akgl_rectangle_points`, `akgl_collide_point_rectangle` and the
|
|
||||||
two types they used ([Chapter 19](19-utilities.md)). Collision is the reason for all of it;
|
|
||||||
it is [Chapter 15](15-collision.md).
|
|
||||||
|
|
||||||
It gives you object pools instead of `malloc`, name-based registries instead of pointer
|
|
||||||
plumbing, a per-frame tick that updates and draws every live actor, JSON asset formats for
|
|
||||||
sprites and characters, a Tiled map loader, arcade physics, a controller/keyboard input
|
|
||||||
layer, text, and a three-voice tone synthesizer.
|
|
||||||
|
|
||||||
## What it refuses to be
|
|
||||||
|
|
||||||
**It is not an engine.** There is no editor, no scene graph, no scripting layer, no asset
|
|
||||||
pipeline that runs before the compiler, and no runtime that owns your `main`. You write a C
|
|
||||||
program; libakgl is a library it calls.
|
|
||||||
|
|
||||||
The refusals are specific, and each one is a design decision documented in
|
|
||||||
[Chapter 2](02-design-philosophy.md):
|
|
||||||
|
|
||||||
| Not here | Instead |
|
|
||||||
|---|---|
|
|
||||||
| Inheritance, RTTI, dynamic dispatch on a type tag | A record of function pointers you populate — `akgl_RenderBackend`, `akgl_PhysicsBackend`, `akgl_Partitioner` |
|
|
||||||
| Runtime `malloc` | Eight statically sized pools with reference counts, plus a static arena for the narrowphase; `AKGL_ERR_HEAP` when a pool is full |
|
|
||||||
| An editor or a project format | Tiled TMJ maps and hand-writable JSON for sprites and characters |
|
|
||||||
| A scripting layer | C. Behaviour attaches as function pointers on `akgl_Actor` |
|
|
||||||
| Two worlds at once | Four swappable globals: `akgl_renderer`, `akgl_physics`, `akgl_camera`, `akgl_gamemap` |
|
|
||||||
| Collision you cannot switch off | A `collision` pointer on the backend. `NULL` is byte-identical to a build before collision existed |
|
|
||||||
|
|
||||||
The library also does not own your window. `akgl_render_2d_init` creates one for you, and
|
|
||||||
`akgl_render_2d_bind` is the same job with that half removed, for a host that already has an
|
|
||||||
`SDL_Renderer` — see [Chapter 8](08-rendering.md).
|
|
||||||
|
|
||||||
## What a frame costs
|
|
||||||
|
|
||||||
`PERFORMANCE.md` measures a 640x480 game with a full screen of 16-pixel tiles and 64 actors,
|
|
||||||
software-rasterized. At 60 fps a frame is 16.67 ms:
|
|
||||||
|
|
||||||
| Part of the frame | Cost | Share of 16.67 ms |
|
|
||||||
|---|---:|---:|
|
|
||||||
| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% |
|
|
||||||
| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% |
|
|
||||||
| Clear + present | 0.023 ms | 0.1% |
|
|
||||||
| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% |
|
|
||||||
| 1200 tile blits | 16.26 ms | 97.6% |
|
|
||||||
| Six lines of HUD text | 0.076 ms | 0.5% |
|
|
||||||
|
|
||||||
**libakgl's own per-tile overhead is about 0.03 ms per frame, under 0.2%.** That number is
|
|
||||||
the gap between `akgl_tilemap_draw` at 16.26 ms and a raw `SDL_RenderTexture` loop issuing
|
|
||||||
the same 1200 blits from the same scattered source tiles at 16.23 ms. It covers the bounds
|
|
||||||
arithmetic, the tileset scan, the offset-table lookup, the backend indirection and the error
|
|
||||||
macros.
|
|
||||||
|
|
||||||
It does not cover the pixels, and the pixels are the frame. It does not tell you what
|
|
||||||
libakgl costs on a GPU backend, where the blits get cheap and libakgl's share of a much
|
|
||||||
shorter frame rises — `PERFORMANCE.md` says so explicitly and is the reason the
|
|
||||||
per-operation numbers there matter more than these totals. And it is one laptop, one build
|
|
||||||
type, one afternoon: treat the absolute numbers as that machine's and the ratios as the
|
|
||||||
library's.
|
|
||||||
|
|
||||||
## What is not implemented
|
|
||||||
|
|
||||||
Named here rather than discovered later. Each gets a callout in the chapter where you would
|
|
||||||
hit it, and an entry in `TODO.md`.
|
|
||||||
|
|
||||||
| Gap | Behaviour today | Chapter |
|
|
||||||
|---|---|---|
|
|
||||||
| Terminal velocity | Gravity accumulates into `ey` unbounded. `physics.drag.y` is the only brake | [14](14-physics.md) |
|
|
||||||
| Friction / deceleration | Releasing a direction zeroes thrust and stops the actor dead. Right for Zelda, wrong for Mario | [14](14-physics.md) |
|
|
||||||
| Savegames | `akgl_game_save` writes the name tables but not the objects, so a file is not yet enough to restore a session | [07](07-the-game-and-the-frame.md) |
|
|
||||||
| Rotation | No actor carries an angle, and every collision shape is axis-aligned | [15](15-collision.md) |
|
|
||||||
| Continuous collision | Sub-stepping is capped, so something moving faster than about 1280 px/s on 16-pixel tiles can still pass through a wall | [15](15-collision.md) |
|
|
||||||
| Mesh drawing | `akgl_render_2d_draw_mesh` raises `AKERR_API`; the hook is reserved for a 3D backend | [08](08-rendering.md) |
|
|
||||||
| A text cache | Every `akgl_text_rendertextat` rasterizes, uploads, blits and destroys | [16](17-text-and-fonts.md) |
|
|
||||||
|
|
||||||
## Who owns which documentation
|
|
||||||
|
|
||||||
**This manual documents libakgl. It does not re-document its dependencies.** Every project
|
|
||||||
below is documented by the people who own its code, and a paraphrase here would be wrong the
|
|
||||||
day one of them changes without anything in this repository noticing. So each chapter
|
|
||||||
answers two questions — what does libakgl add or constrain here, and what does a libakgl
|
|
||||||
caller actually write — and links out for the rest.
|
|
||||||
|
|
||||||
| Topic | Owned by | What this manual owes you |
|
|
||||||
|---|---|---|
|
|
||||||
| `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH`, `PASS`, `CATCH`, `IGNORE` | libakerror (`deps/libakerror`) | Which statuses libakgl raises and what they mean here — [Chapter 4](04-errors.md) |
|
|
||||||
| `aksl_strncpy`, `aksl_fclose`, `aksl_fgetc`, `aksl_snprintf` | libakstdlib (`deps/libakstdlib`) | Which ones libakgl requires you to use, and why |
|
|
||||||
| `SDL_Renderer`, `SDL_Texture`, events, `SDL_PropertiesID` | [SDL3](https://wiki.libsdl.org/SDL3/) | The backend vtable, the frame contract, what libakgl does to the renderer's state |
|
|
||||||
| Image decoding | [SDL3_image](https://wiki.libsdl.org/SDL3_image/) | Which formats reach a spritesheet, and when they are decoded |
|
|
||||||
| Audio decoding, `MIX_Audio`, mixers and tracks | [SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) | `akgl_load_start_bgm` and the track table — [Chapter 18](18-audio.md) |
|
|
||||||
| TTF rasterizing and metrics | [SDL3_ttf](https://wiki.libsdl.org/SDL3_ttf/) | The font registry, the teardown ordering trap, the per-call cost — [Chapter 17](17-text-and-fonts.md) |
|
|
||||||
| `json_t`, `json_decref`, the parser | [jansson](https://jansson.readthedocs.io/) | `akgl_get_json_*` status semantics and the borrowed-reference rule — [Chapter 19](19-utilities.md) |
|
|
||||||
| The TMJ map format, layers, tilesets, custom properties | [Tiled](https://doc.mapeditor.org/en/stable/reference/json-map-format/) | libakgl's extensions and limits — [Chapter 13](13-tilemaps.md) |
|
|
||||||
|
|
||||||
The three-voice synthesizer in [Chapter 18](18-audio.md) is the one audio subsystem that is
|
|
||||||
libakgl's own, and it is documented here in full. It has nothing to do with SDL3_mixer.
|
|
||||||
|
|
||||||
## Where the per-function reference lives
|
|
||||||
|
|
||||||
This manual is narrative. It teaches a task and links to the generated Doxygen for
|
|
||||||
signatures, parameters and per-function `@throws` lists:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
doxygen Doxyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Every header already carries a substantial `@file` block explaining its subsystem's design
|
|
||||||
rationale, and `Doxyfile` sets `WARN_IF_UNDOCUMENTED = YES` with
|
|
||||||
`WARN_AS_ERROR = FAIL_ON_WARNINGS`, so an undocumented symbol fails CI. The gap these
|
|
||||||
chapters fill is navigation and worked examples, not reference text. **They deliberately do
|
|
||||||
not restate the 156 signatures** — a hand-copied signature table is exactly the artifact
|
|
||||||
that drifts, and it would compete with a reference that CI already keeps honest.
|
|
||||||
|
|
||||||
Where a chapter genuinely needs a declaration or a constant table in front of you, it uses
|
|
||||||
an `excerpt=` block whose contents are checked against the header on every test run. The
|
|
||||||
text you read *is* the header.
|
|
||||||
|
|
||||||
## Reading order
|
|
||||||
|
|
||||||
[Chapter 4](04-errors.md) comes before every subsystem chapter, because 181 of those 193
|
|
||||||
functions return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example
|
|
||||||
until you can read that return value. The dozen that do not are the `void` SDL enumeration
|
|
||||||
callbacks and the collision arena's accessors. After that, [Chapter 3](03-getting-started.md) gets a window on
|
|
||||||
the screen, and the subsystem chapters can be read in any order.
|
|
||||||
|
|
||||||
If you would rather start by building something, the two tutorials —
|
|
||||||
[Chapter 20](20-tutorial-sidescroller.md) and [Chapter 21](21-tutorial-jrpg.md) — are
|
|
||||||
complete programs under `examples/`, built by default and smoke-run in CI.
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
# 02. Design philosophy
|
|
||||||
|
|
||||||
Six decisions shape every API in this library. They are not preferences stated up front and
|
|
||||||
then quietly abandoned in the implementation; each one is visible in the code, and this
|
|
||||||
chapter shows the code that proves it.
|
|
||||||
|
|
||||||
None of this is an argument that you should build a game this way. It is an explanation of
|
|
||||||
what libakgl will and will not do for you, so you can decide whether the tradeoffs are ones
|
|
||||||
you want.
|
|
||||||
|
|
||||||
## Bounded, pre-declared resources — not dynamic ones
|
|
||||||
|
|
||||||
**libakgl does not call `malloc` at runtime.** Every runtime object comes out of a fixed,
|
|
||||||
statically allocated array declared in `heap.h`. There are five such arrays — actors,
|
|
||||||
sprites, spritesheets, characters and strings — and the ceilings are compile-time constants:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/heap.h
|
|
||||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
|
||||||
#define AKGL_MAX_HEAP_ACTOR 64
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
|
||||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
|
||||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
|
||||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_STRING
|
|
||||||
#define AKGL_MAX_HEAP_STRING 256
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
Allocation is a linear scan for a slot whose reference count is zero. That is the whole
|
|
||||||
algorithm:
|
|
||||||
|
|
||||||
```c excerpt=src/heap.c
|
|
||||||
akerr_ErrorContext *akgl_heap_next_actor(akgl_Actor **dest)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
for (int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
|
||||||
if ( akgl_heap_actors[i].refcount != 0 ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
*dest = &akgl_heap_actors[i];
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused actor on the heap");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two consequences follow, and both matter more than the absence of `malloc`.
|
|
||||||
|
|
||||||
**Exhaustion is a normal error, not a catastrophe.** `AKGL_ERR_HEAP` means "this pool is
|
|
||||||
full". It is not `ENOMEM`, the process is not in trouble, and the fix is almost never a
|
|
||||||
bigger pool — **it is usually a missing release.** Raising `AKGL_MAX_HEAP_*` before you have
|
|
||||||
checked for a leak converts a bug that fails on frame 300 into one that fails on frame 3000.
|
|
||||||
|
|
||||||
**The pools are compile-time sized, so the ceiling is an ABI constraint.** The arrays live
|
|
||||||
in the library, so libakgl and everything linking it must agree on the numbers. Overriding
|
|
||||||
one means rebuilding the whole tree, not just your game.
|
|
||||||
|
|
||||||
The idiom for a pool object is claim, use, release in `CLEANUP`, which runs on every path
|
|
||||||
out of the `ATTEMPT` block:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
#include <akgl/util.h>
|
|
||||||
|
|
||||||
/* Claim, use, release. The CLEANUP block runs on every path out of ATTEMPT. */
|
|
||||||
akerr_ErrorContext *scratch_path(char *root, char *name)
|
|
||||||
{
|
|
||||||
akgl_String *buf = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_heap_next_string(&buf));
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_string_initialize(buf, name));
|
|
||||||
CATCH(errctx, akgl_path_relative(root, buf->data, buf));
|
|
||||||
SDL_Log("resolved to %s", buf->data);
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(buf));
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**If the pool has no layer for your type, add one.** A new kind of runtime object gets a new
|
|
||||||
array, a `next`, and a `release`, in `heap.h` and `src/heap.c`. It does not get an
|
|
||||||
allocator. [Chapter 5](05-the-heap.md) covers the layers, the reference-count asymmetry
|
|
||||||
between `akgl_heap_next_string` and the other seven, and what the ceilings cost you.
|
|
||||||
|
|
||||||
## Variation lives in a backend, not in a branch
|
|
||||||
|
|
||||||
There are exactly three pluggable subsystems, and all have the same shape: **a struct of
|
|
||||||
function pointers, plus an initializer that populates it.**
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_RenderBackend akgl_PhysicsBackend akgl_Partitioner
|
|
||||||
+--------------------+ +--------------------+ +-----------------+
|
|
||||||
| sdl_renderer | | simulate | | reset |
|
|
||||||
| shutdown | | gravity | | insert |
|
|
||||||
| frame_start | | collide | | remove |
|
|
||||||
| frame_end | | move | | move |
|
|
||||||
| draw_texture | | drag_x/y/z | | query |
|
|
||||||
| draw_mesh | | gravity_x/y/z | | each_pair |
|
|
||||||
| draw_world | | max_timestep | | state |
|
|
||||||
+--------------------+ | collision | +-----------------+
|
|
||||||
^ +--------------------+ ^
|
|
||||||
| populated by ^ | populated by
|
|
||||||
akgl_render_2d_bind | populated by akgl_partitioner_init_grid
|
|
||||||
akgl_render_2d_init akgl_physics_init_null akgl_partitioner_init_bsp
|
|
||||||
akgl_physics_init_arcade (akgl_partitioner_factory)
|
|
||||||
(akgl_physics_factory)
|
|
||||||
```
|
|
||||||
|
|
||||||
The shipped physics initializers are five assignments and nothing else:
|
|
||||||
|
|
||||||
```c excerpt=src/physics.c
|
|
||||||
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
||||||
|
|
||||||
self->gravity = akgl_physics_null_gravity;
|
|
||||||
self->collide = akgl_physics_null_collide;
|
|
||||||
self->move = akgl_physics_null_move;
|
|
||||||
self->simulate = akgl_physics_simulate;
|
|
||||||
```
|
|
||||||
|
|
||||||
**To vary the behaviour, write an initializer and register it — do not add a conditional.**
|
|
||||||
Nothing in `akgl_physics_simulate` knows which backend it is stepping, and nothing in the
|
|
||||||
render path knows whether `draw_texture` came from the 2D backend or from yours. You can
|
|
||||||
also mix: borrow the entry points that already do what you want and replace only the one
|
|
||||||
that does not.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
|
|
||||||
/* One tick of "everything floats up at a constant rate", as a backend. */
|
|
||||||
static akerr_ErrorContext *floaty_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
||||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
|
||||||
actor->ey = -40.0f * dt;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
akerr_ErrorContext *floaty_init(akgl_PhysicsBackend *self)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
|
||||||
|
|
||||||
/* Borrow the parts that already do the right thing. */
|
|
||||||
self->simulate = akgl_physics_simulate;
|
|
||||||
self->move = akgl_physics_arcade_move;
|
|
||||||
self->collide = akgl_physics_null_collide;
|
|
||||||
self->gravity = floaty_gravity;
|
|
||||||
|
|
||||||
self->gravity_time = SDL_GetTicksNS();
|
|
||||||
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no inheritance, no RTTI and no dispatch on a type tag anywhere in the library.
|
|
||||||
Behaviour attaches as a function pointer on a struct — which is also how an actor's own six
|
|
||||||
behaviour hooks work, in [Chapter 12](12-actors.md).
|
|
||||||
|
|
||||||
## Bit flags are the default representation for state
|
|
||||||
|
|
||||||
Where another library would give you an enum and a setter per property, libakgl gives you a
|
|
||||||
32-bit mask and four macros. An actor's state is the clearest case: it is a *set*, not a
|
|
||||||
value, because an actor is alive and facing left and moving left all at once.
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/actor.h
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
|
||||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
|
||||||
```
|
|
||||||
|
|
||||||
The iterator flags in `iterator.h` are the same idea for "what should this sweep do":
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/iterator.h
|
|
||||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
|
||||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
|
||||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
|
||||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
|
||||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Both tables are hand-aligned, with the decimal value and the bit pattern in a comment per
|
|
||||||
row, and both are protected from the reindent script's `tabify` step precisely so that
|
|
||||||
alignment survives. The tables in the headers are the reference; these excerpts are checked
|
|
||||||
against them.
|
|
||||||
|
|
||||||
**Use the macros, not the operators.** `AKGL_BITMASK_HAS(x, y)` is "every bit of `y` is set
|
|
||||||
in `x`", not "any of them". It is fully parenthesized so that `!AKGL_BITMASK_HAS(a, b)`
|
|
||||||
means what it reads as — until 0.5.0 it did not, and the negation bound to the `&`. Nothing
|
|
||||||
in the tree negated it, which is the only reason that was latent rather than live.
|
|
||||||
|
|
||||||
## Errors carry context, and callers cannot ignore them
|
|
||||||
|
|
||||||
**181 of libakgl's 193 functions return `akerr_ErrorContext AKERR_NOIGNORE *`.**
|
|
||||||
Results come back through pointer parameters; the return value is always the error. The
|
|
||||||
`AKERR_NOIGNORE` attribute makes discarding it a compiler diagnostic.
|
|
||||||
|
|
||||||
That is a libakerror design and it is documented by libakerror.
|
|
||||||
[Chapter 4](04-errors.md) covers the part that is genuinely libakgl's: which statuses these
|
|
||||||
functions raise, what each one means *here*, and the traps libakgl's own structure creates.
|
|
||||||
Read it before any subsystem chapter.
|
|
||||||
|
|
||||||
The reasoning behind the choice is operational rather than aesthetic. A game that fails on
|
|
||||||
somebody else's machine leaves you a log file and nothing else, so an error has to say what
|
|
||||||
failed, where, and why, at the point it happened — not three frames later at a `NULL`
|
|
||||||
dereference with no history attached.
|
|
||||||
|
|
||||||
## Everything is referenced by name
|
|
||||||
|
|
||||||
Nothing in this library is passed around by pointer where a name will do. An actor names the
|
|
||||||
character it instantiates; a character names the sprites it draws for each state; a sprite
|
|
||||||
names the sheet it cuts frames from. All of it is resolved at load time through eight
|
|
||||||
registries:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/registry.h
|
|
||||||
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR;
|
|
||||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITE;
|
|
||||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITESHEET;
|
|
||||||
extern SDL_PropertiesID AKGL_REGISTRY_CHARACTER;
|
|
||||||
```
|
|
||||||
|
|
||||||
The lookup itself is unremarkable, and that is the point:
|
|
||||||
|
|
||||||
```c excerpt=src/actor.c
|
|
||||||
obj->basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, basecharname, NULL);
|
|
||||||
```
|
|
||||||
|
|
||||||
**This is what lets the whole asset graph be described in JSON files that reference each
|
|
||||||
other by name**, with no build step and no code generation. It also means a typo in an asset
|
|
||||||
file is a runtime failure rather than a compile error — which is the tradeoff, stated
|
|
||||||
plainly. The registries are SDL property sets rather than an akgl type, so you can enumerate
|
|
||||||
one with `SDL_EnumerateProperties`; that is exactly what `akgl_registry_iterate_actor` does.
|
|
||||||
|
|
||||||
Two sharp edges, both in [Chapter 6](06-the-registry.md): an uninitialized registry id is 0,
|
|
||||||
which SDL treats as "no such property set" and silently drops writes to; and
|
|
||||||
`AKGL_REGISTRY_SPRITESHEET` is keyed on the *resolved* image path, which is what makes two
|
|
||||||
sprites naming the same PNG share one texture.
|
|
||||||
|
|
||||||
## One world at a time
|
|
||||||
|
|
||||||
The library keeps exactly one of everything, reachable through four globals:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/game.h
|
|
||||||
extern akgl_Tilemap *akgl_gamemap;
|
|
||||||
extern akgl_RenderBackend *akgl_renderer;
|
|
||||||
extern akgl_PhysicsBackend *akgl_physics;
|
|
||||||
extern SDL_FRect *akgl_camera;
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_game_init` points each of them at default storage — `akgl_default_renderer`,
|
|
||||||
`akgl_default_physics`, `akgl_default_camera`, `akgl_default_gamemap`. **They are pointers
|
|
||||||
so that you can substitute your own instance by reassigning one**, and the rest of the
|
|
||||||
library goes on drawing and simulating through it without knowing.
|
|
||||||
|
|
||||||
That is the entire extent of the indirection. **There is no notion of two worlds at once.**
|
|
||||||
No context handle is threaded through the API, no `akgl_World *` is passed to every call,
|
|
||||||
and two simultaneous games in one process is not a thing this library supports. If you want
|
|
||||||
a paused world behind a menu, you save the state you care about and swap the pointers back.
|
|
||||||
|
|
||||||
The globals all carry the `akgl_` prefix, and that is not cosmetic. Until 0.5.0 the library
|
|
||||||
exported a bare `renderer`, `tests/character.c` defined an `SDL_Renderer *renderer` of its
|
|
||||||
own, both had external linkage with the same spelling, and the executable's definition
|
|
||||||
preempted the library's. Every texture load in that suite failed and the suite reported
|
|
||||||
success anyway. A consuming game with a variable called `renderer` would have hit exactly
|
|
||||||
the same thing, with no test to notice.
|
|
||||||
|
|
||||||
## What the six add up to
|
|
||||||
|
|
||||||
You get a library whose worst-case memory is knowable at compile time, whose failure modes
|
|
||||||
arrive as messages instead of crashes, whose behaviour you extend by writing a function
|
|
||||||
rather than by editing a switch, and whose assets are text files you can edit by hand.
|
|
||||||
|
|
||||||
You give up dynamic sizing, more than one world, and any ability to describe a game without
|
|
||||||
writing C. Those are the tradeoffs. [Chapter 3](03-getting-started.md) puts a window on the
|
|
||||||
screen.
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
# 03. Getting started
|
|
||||||
|
|
||||||
This chapter gets libakgl into your build and a window on your screen. It assumes you have
|
|
||||||
read [Chapter 4](04-errors.md), because the first program is written in the error protocol
|
|
||||||
and will not make sense otherwise.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Dependency | Floor | Why libakgl needs it |
|
|
||||||
|---|---|---|
|
|
||||||
| SDL3 | 3.4.x (3.4.8 is vendored) | Window, renderer, events, properties, timing, mutexes |
|
|
||||||
| SDL3_image | vendored | Decoding spritesheet and tileset images |
|
|
||||||
| SDL3_mixer | vendored | `akgl_load_start_bgm`, the mixer device and track table |
|
|
||||||
| SDL3_ttf | vendored | Font loading, rasterizing and metrics |
|
|
||||||
| jansson | vendored | Parsing every JSON asset format |
|
|
||||||
| libakerror | **>= 2.0.1, hard floor** | The error protocol. Part of libakgl's *public* interface |
|
|
||||||
| libakstdlib | >= 0.2 | Checked wrappers over libc — `aksl_strncpy`, `aksl_fclose`, `aksl_snprintf` |
|
|
||||||
| semver | vendored, in-tree | Savegame and version comparison |
|
|
||||||
|
|
||||||
All eight are git submodules under `deps/`, so a recursive clone gives you a build with no
|
|
||||||
system packages at all.
|
|
||||||
|
|
||||||
**The libakerror floor is enforced, not advisory.** Two `#error` feature tests in
|
|
||||||
`include/akgl/error.h` fire on a stale header:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/error.h
|
|
||||||
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
|
|
||||||
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
It is a hard floor because libakerror 2.0.0 made `akerr_next_error()` return a context that
|
|
||||||
already holds its reference and moved `__akerr_last_ignored` into thread-local storage —
|
|
||||||
and both of those expand at **your** call sites, through `IGNORE` and the `FAIL_*` macros,
|
|
||||||
because `akerror.h` is part of libakgl's public interface. A tree that mixes headers and
|
|
||||||
libraries across that line double-counts every reference and never returns a pool slot. The
|
|
||||||
soname moved to `libakerror.so.2` to stop the library half happening by accident; the
|
|
||||||
`#error` catches the header half in an install tree.
|
|
||||||
|
|
||||||
## Building libakgl itself
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
git clone --recursive https://source.starfort.tech/andrew/libakgl.git
|
|
||||||
cd libakgl
|
|
||||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
||||||
cmake --build build --parallel
|
|
||||||
ctest --test-dir build --output-on-failure
|
|
||||||
```
|
|
||||||
|
|
||||||
If a dependency is already installed system-wide, libakgl finds it and skips the vendored
|
|
||||||
copy. The rule is `if(NOT TARGET ...)` per dependency, so mixing is fine: a system SDL3 with
|
|
||||||
a vendored jansson works.
|
|
||||||
|
|
||||||
## Route 1 — `add_subdirectory()`
|
|
||||||
|
|
||||||
This is what `akbasic` does, and it is the route with the fewest ways to go wrong: one set
|
|
||||||
of compiler flags, one set of headers, nothing to install.
|
|
||||||
|
|
||||||
```cmake
|
|
||||||
add_subdirectory(third_party/libakgl)
|
|
||||||
|
|
||||||
add_executable(mygame src/main.c)
|
|
||||||
target_link_libraries(mygame PRIVATE akgl::akgl)
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl::akgl` carries its own usage requirements, so the SDL3, libakerror and jansson include
|
|
||||||
paths reach your target without you naming them.
|
|
||||||
|
|
||||||
**`AKGL_WERROR` is `OFF` by default, and leaving it off is the right thing for a consumer.**
|
|
||||||
It is turned on only by two CI jobs in this repository. The reason is exactly your build: a
|
|
||||||
target-level `-Werror` turns every diagnostic a newer compiler invents into a broken build
|
|
||||||
for somebody else's project, and the warning set is not even stable across optimization
|
|
||||||
levels — an `-O2` build reports ten `-Wstringop-truncation` warnings that `-O0` and
|
|
||||||
`-fsyntax-only` do not. `-Wall` is on for every target this project owns, and the tree builds
|
|
||||||
clean under it at every optimization level.
|
|
||||||
|
|
||||||
If you have already vendored SDL3 yourself, declare it before the `add_subdirectory` and
|
|
||||||
libakgl will use yours rather than adding a second copy:
|
|
||||||
|
|
||||||
```cmake
|
|
||||||
add_subdirectory(third_party/SDL) # declares SDL3::SDL3
|
|
||||||
add_subdirectory(third_party/libakgl) # sees it and does not add its own
|
|
||||||
```
|
|
||||||
|
|
||||||
## Route 2 — pkg-config
|
|
||||||
|
|
||||||
`make install` puts `akgl.pc` in `lib/pkgconfig/`. Here is what it contains, in full:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prefix=/usr/local
|
|
||||||
exec_prefix=${prefix}
|
|
||||||
libdir=${exec_prefix}/lib
|
|
||||||
includedir=${exec_prefix}/include
|
|
||||||
|
|
||||||
Name: akgl
|
|
||||||
Description: AKLabs Game library
|
|
||||||
Version: 0.7.0
|
|
||||||
Cflags: -I${includedir}/
|
|
||||||
Libs: -L${libdir} -lakgl
|
|
||||||
```
|
|
||||||
|
|
||||||
**`akgl.pc` names no dependencies at all.** There is no `Requires:` and no
|
|
||||||
`Requires.private:` line, so `pkg-config --cflags --libs akgl` tells you nothing about
|
|
||||||
libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer, SDL3_ttf or jansson. That has always
|
|
||||||
been wrong, and it is sharper than it looks: `akerror.h` is part of libakgl's *public*
|
|
||||||
interface, so a consumer that builds against whatever `akerror.h` is on its include path and
|
|
||||||
links whatever `libakerror.so` the loader finds can get exactly the mismatch pkg-config had
|
|
||||||
every opportunity to prevent.
|
|
||||||
|
|
||||||
Until that is fixed, name them yourself. These are the modules whose headers libakgl's own
|
|
||||||
public headers include:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansson
|
|
||||||
```
|
|
||||||
|
|
||||||
`sdl3-image` is needed at link time but appears in no public header, so it belongs in the
|
|
||||||
private half.
|
|
||||||
|
|
||||||
Recorded in `TODO.md` under "akgl.pc names no dependencies", along with why the fix wants
|
|
||||||
its own commit: adding a `Requires:` line changes what `pkg-config --libs akgl` emits for
|
|
||||||
every existing consumer.
|
|
||||||
|
|
||||||
**There is no CMake package config.** `install()` ships the library, the headers and
|
|
||||||
`akgl.pc`, and nothing generates an `akglConfig.cmake`, so `find_package(akgl)` does not
|
|
||||||
work against an install tree. Use `add_subdirectory()` or pkg-config.
|
|
||||||
|
|
||||||
## The startup order that actually works
|
|
||||||
|
|
||||||
Verified against `src/game.c`, not against the header comment:
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. fill in akgl_game.name, .version and .uri
|
|
||||||
| akgl_game_init refuses to run without all three, and there are no
|
|
||||||
| defaults -- the window title, SDL's app metadata and the savegame
|
|
||||||
| compatibility check are all built from them
|
|
||||||
v
|
|
||||||
2. akgl_game_init()
|
|
||||||
| error codes, version stamp, frame clock, state mutex, pools, the eight
|
|
||||||
| registries, SDL app metadata, control maps, SDL_Init(VIDEO|GAMEPAD|
|
|
||||||
| AUDIO), controller DB, gamepads, SDL_mixer, SDL_ttf, and finally the
|
|
||||||
| four globals pointed at their default storage.
|
|
||||||
|
|
|
||||||
| What it does NOT do: create the window, choose a physics backend, or
|
|
||||||
| load any configuration. Those read properties, which you have not set
|
|
||||||
| yet.
|
|
||||||
v
|
|
||||||
3. akgl_registry_load_properties() or akgl_set_property()
|
|
||||||
| screen size, physics constants, anything else
|
|
||||||
v
|
|
||||||
4. akgl_render_2d_init(akgl_renderer) <- creates the window; reads
|
|
||||||
| game.screenwidth/screenheight
|
|
||||||
| akgl_physics_factory(akgl_physics, name) <- "null" or "arcade"
|
|
||||||
v
|
|
||||||
5. load assets, then loop on akgl_game_update()
|
|
||||||
```
|
|
||||||
|
|
||||||
**`akgl_game_init` does not choose a physics backend for you.** `physics.h` says the
|
|
||||||
`physics.engine` property drives `akgl_physics_factory`; it does not. That string appears
|
|
||||||
nowhere in `src/`, and `akgl_game_init` never calls the factory. You call it yourself, with
|
|
||||||
an `akgl_String` holding `"null"` or `"arcade"`. The header comment is wrong and is left
|
|
||||||
alone here deliberately — this manual documents what the code does, and correcting header
|
|
||||||
prose is a separate commit.
|
|
||||||
|
|
||||||
## The first window
|
|
||||||
|
|
||||||
Sixty frames of an empty world, then out. It compiles under
|
|
||||||
`-std=gnu99 -Wall -Werror` and runs headless.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akstdlib.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
#include <akgl/registry.h>
|
|
||||||
#include <akgl/renderer.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
#include <akgl/text.h>
|
|
||||||
|
|
||||||
int main(void)
|
|
||||||
{
|
|
||||||
akgl_String *engine = NULL;
|
|
||||||
int frame = 0;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
/* 1. akgl_game_init refuses to run without all three of these. */
|
|
||||||
CATCH(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
|
||||||
"firstwindow", sizeof(akgl_game.name) - 1));
|
|
||||||
CATCH(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
|
||||||
"0.1.0", sizeof(akgl_game.version) - 1));
|
|
||||||
CATCH(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
|
||||||
"net.example.firstwindow", sizeof(akgl_game.uri) - 1));
|
|
||||||
|
|
||||||
/* 2. SDL, the pools, the registries, audio, fonts, gamepads. */
|
|
||||||
CATCH(errctx, akgl_game_init());
|
|
||||||
|
|
||||||
/* 3. Configuration. akgl_render_2d_init reads these two. */
|
|
||||||
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
|
|
||||||
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
|
|
||||||
|
|
||||||
/* 4. The window and the renderer, then a physics backend. */
|
|
||||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
|
||||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
|
||||||
CATCH(errctx, akgl_string_initialize(engine, "null"));
|
|
||||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
|
||||||
|
|
||||||
/* 5. Load assets, then loop. */
|
|
||||||
for ( frame = 0; frame < 60; frame++ ) {
|
|
||||||
CATCH(errctx, akgl_game_update(NULL));
|
|
||||||
}
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(engine));
|
|
||||||
IGNORE(akgl_text_unloadallfonts());
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH_NORETURN(errctx);
|
|
||||||
|
|
||||||
SDL_Quit();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Five things in there are worth pointing at.
|
|
||||||
|
|
||||||
**`aksl_strncpy`, not `strncpy`.** Every name in this library is a registry key, handed to
|
|
||||||
`strcmp`, `realpath` and SDL property calls, none of which stop at the end of the field.
|
|
||||||
`strncpy(dest, src, sizeof(dest))` leaves `dest` unterminated whenever the source fills it.
|
|
||||||
Bound `n` at `sizeof(dest) - 1` and let `aksl_strncpy` raise `AKERR_OUTOFBOUNDS` if the bytes
|
|
||||||
do not fit.
|
|
||||||
|
|
||||||
**`FINISH_NORETURN`, not `FINISH`, in `main`.** `FINISH` ends by returning the error context
|
|
||||||
from the enclosing function, and `main` returns `int`. With `FINISH_NORETURN` and no
|
|
||||||
`HANDLE` block, an unhandled error goes to libakerror's default handler, which logs it and
|
|
||||||
calls `akerr_exit()` — so `SDL_Quit()` and `return 0` are reached only on the success path.
|
|
||||||
|
|
||||||
**Never `exit(3)` an akerr status.** An exit status is one byte wide and libakgl's band
|
|
||||||
starts at 256, so `exit(AKGL_ERR_SDL)` is a wait status of 0 and the shell sees a clean run.
|
|
||||||
`akerr_exit()` maps 0 to 0, 1–255 to themselves, and anything else to
|
|
||||||
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). See [Chapter 4](04-errors.md), Table 3.
|
|
||||||
|
|
||||||
**`akgl_text_unloadallfonts()` before `SDL_Quit()`.** `SDL_Quit` destroys the property
|
|
||||||
registry the fonts live in and takes the last reference to every one of them with it. This
|
|
||||||
program loads no fonts, and the call is in the shutdown anyway because that is where it
|
|
||||||
belongs the moment one is loaded — [Chapter 17](17-text-and-fonts.md).
|
|
||||||
|
|
||||||
**Sixty `akgl_game_update` calls log `Low FPS! 0` sixty times.** `akgl_game.fps` is a
|
|
||||||
completed-second average, so it reads 0 for the first second of the process, which is under
|
|
||||||
the low-FPS threshold. `akgl_game.lowfpsfunc` is a hook for exactly this: point it at
|
|
||||||
something that sheds work, or at nothing.
|
|
||||||
|
|
||||||
## Running headless
|
|
||||||
|
|
||||||
On a server, in CI, or under valgrind, force the dummy drivers:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
SDL_VIDEO_DRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIO_DRIVER=dummy ./mygame
|
|
||||||
```
|
|
||||||
|
|
||||||
These are the SDL3 spellings, and the ones `scripts/memcheck.sh` exports. SDL3 also still
|
|
||||||
honours SDL2's `SDL_VIDEODRIVER` and `SDL_AUDIODRIVER` as aliases — `SDL_RENDER_DRIVER` was
|
|
||||||
never spelled any other way. Without them, `akgl_game_init` fails at `SDL_Init` with
|
|
||||||
`AKGL_ERR_SDL` and the message `Couldn't initialize SDL: No available video device`.
|
|
||||||
|
|
||||||
A program can set the same thing from inside itself with `SDL_SetHint` before
|
|
||||||
`akgl_game_init`, which is what most of the test suites in `tests/` do:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
|
|
||||||
void go_headless(void)
|
|
||||||
{
|
|
||||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
|
||||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
|
||||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The software renderer is a real rasterizer, not a stub — everything draws, nothing is
|
|
||||||
displayed, and `SDL_RenderReadPixels` gives you the frame back. That is how the image
|
|
||||||
assertions in `tests/` work.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
[Chapter 5](05-the-heap.md) for the pools you just claimed a string from,
|
|
||||||
[Chapter 7](07-the-game-and-the-frame.md) for what `akgl_game_update` actually does, or
|
|
||||||
[Chapter 20](20-tutorial-sidescroller.md) to start building a game.
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# 04. Errors and status codes
|
|
||||||
|
|
||||||
Every one of libakgl's exported functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.
|
|
||||||
You cannot read a single example in this manual until you can read that return value, which
|
|
||||||
is why this chapter comes before any subsystem.
|
|
||||||
|
|
||||||
## The protocol is libakerror's
|
|
||||||
|
|
||||||
`ATTEMPT` / `CLEANUP` / `PROCESS` / `HANDLE` / `HANDLE_GROUP` / `FINISH`, and the `PASS`,
|
|
||||||
`CATCH`, `IGNORE`, `FAIL_*` and `SUCCEED_RETURN` macros, all belong to **libakerror**.
|
|
||||||
They are documented by the project that owns them, in `deps/libakerror/README.md` —
|
|
||||||
"Library Architecture", "Lifecycle of an error in the AKError library", and "Exit status".
|
|
||||||
This manual does not restate them, because a copy here would be wrong the day libakerror
|
|
||||||
changes and nothing in this repository would notice.
|
|
||||||
|
|
||||||
What is genuinely libakgl's, and is written down nowhere else, is **which statuses these
|
|
||||||
193 functions raise and what each one means here**. That is the three tables below.
|
|
||||||
|
|
||||||
The house rules for *writing* code against the protocol — never a `*_RETURN` inside an
|
|
||||||
`ATTEMPT`, never a bare `return` out of a `HANDLE`, `CLEANUP` before `PROCESS`, an
|
|
||||||
`ATTEMPT` inside the loop rather than a `CATCH` inside one — are in `AGENTS.md` under
|
|
||||||
"Error-Handling Protocol", and two of them are enforced by the `error_protocol` test.
|
|
||||||
|
|
||||||
## Table 1 — libakgl's own status codes
|
|
||||||
|
|
||||||
libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*`
|
|
||||||
codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**.
|
|
||||||
libakgl claims a band of seven starting there, under the owner string `"libakgl"`:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/error.h
|
|
||||||
#define AKGL_ERR_OWNER "libakgl"
|
|
||||||
#define AKGL_ERR_BASE AKERR_FIRST_CONSUMER_STATUS
|
|
||||||
|
|
||||||
#define AKGL_ERR_SDL (AKGL_ERR_BASE + 0) /**< An SDL call failed; the message carries SDL_GetError() */
|
|
||||||
#define AKGL_ERR_REGISTRY (AKGL_ERR_BASE + 1) /**< A registry property or lookup operation failed */
|
|
||||||
#define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */
|
|
||||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
|
||||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
|
||||||
#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */
|
|
||||||
#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */
|
|
||||||
```
|
|
||||||
|
|
||||||
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 7`, one past the last
|
|
||||||
one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises
|
|
||||||
it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)`
|
|
||||||
arm — it would catch nothing, and if an eighth real code is ever added it would silently
|
|
||||||
start catching that instead.
|
|
||||||
|
|
||||||
| Code | Value | Means | Raised by | What the caller does |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `AKGL_ERR_SDL` | 256 | An SDL call failed; the message carries `SDL_GetError()` | Around forty sites — anything touching a window, texture, renderer, mixer or the state mutex | Usually fatal at startup. Check the driver and the asset path |
|
|
||||||
| `AKGL_ERR_REGISTRY` | 257 | A name lookup against a registry failed | **One site only**: `akgl_controller_default`, when `actorname` is not in `AKGL_REGISTRY_ACTOR` | Bind the control map *after* the actor is initialized |
|
|
||||||
| `AKGL_ERR_HEAP` | 258 | A pool has no free slot | every `akgl_heap_next_*` | **Normally a missing release, not a small pool.** See [Chapter 5](05-the-heap.md) before raising `AKGL_MAX_HEAP_*` |
|
|
||||||
| `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code |
|
|
||||||
| `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below |
|
|
||||||
| `AKGL_ERR_COLLISION` | 261 | A collision query could not be answered | **One cause only**: `akgl_collision_test`, when the ccd arena is exhausted. The message carries the high-water mark | Raise `AKGL_CCD_ARENA_BYTES` to the reported figure. See [Chapter 15](15-collision.md) |
|
|
||||||
| `AKGL_ERR_UI` | 262 | The UI subsystem refused, or clay reported a layout error | `akgl_ui_*` — lifecycle misuse (init twice, a widget outside the frame bracket), an arena or table at its ceiling, and layout errors surfacing from `akgl_ui_frame_end` | The message says which; the ceilings are in [Chapter 23](23-appendix-limits.md). See [Chapter 22](22-ui.md) |
|
|
||||||
|
|
||||||
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
|
|
||||||
seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
|
||||||
`"Logic Interrupt"`, `"Collision Error"`, `"UI Error"`. Those names are what a stack trace
|
|
||||||
prints.
|
|
||||||
|
|
||||||
Two rows deserve more than a cell.
|
|
||||||
|
|
||||||
**`AKGL_ERR_REGISTRY` is rarer than its name suggests.** The obvious candidates do not
|
|
||||||
raise it. `akgl_actor_initialize` raises `AKERR_KEY` when the registry write fails, and
|
|
||||||
`akgl_actor_set_character` raises `AKERR_NULLPOINTER` when the named character is not in
|
|
||||||
`AKGL_REGISTRY_CHARACTER`. If you are writing a `HANDLE` arm for "that name was not
|
|
||||||
registered", `AKERR_NULLPOINTER` and `AKERR_KEY` are the statuses you will actually see.
|
|
||||||
|
|
||||||
**`AKGL_ERR_LOGICINTERRUPT` only works from one place.** `akgl_physics_simulate` wraps each
|
|
||||||
actor's step in an `ATTEMPT` and calls `actor->movementlogicfunc` through `CATCH`, with a
|
|
||||||
`HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT)` arm that does nothing — so a raise from your
|
|
||||||
movement logic skips that actor's gravity, drag and move for this step and the loop carries
|
|
||||||
on to the next actor. The backend's own `gravity` and `move` are called through `PASS` in
|
|
||||||
the same block, and `PASS` returns out of the function. **A backend hook that raises
|
|
||||||
`AKGL_ERR_LOGICINTERRUPT` aborts the whole physics step**, leaving every remaining actor
|
|
||||||
unsimulated and `gravity_time` unadvanced. Raise it from `movementlogicfunc` and nowhere
|
|
||||||
else.
|
|
||||||
|
|
||||||
## Table 2 — libakerror statuses libakgl raises, and what they mean here
|
|
||||||
|
|
||||||
The statuses are libakerror's; their libakgl meaning is not. This table was built by
|
|
||||||
grepping every `FAIL_*` and `HANDLE*` site in `src/`, not by reading header prose.
|
|
||||||
|
|
||||||
| Status | What it means when a libakgl function raises it |
|
|
||||||
|---|---|
|
|
||||||
| `AKERR_NULLPOINTER` | By a wide margin the most common — roughly six of every seven raise sites in `src/`. A required pointer argument was `NULL` — *or* a required field was empty (`akgl_game.name`/`.version`/`.uri`), *or* a name was looked up and not found. `akgl_actor_set_character` and `akgl_get_property` both use it for "absent", and `registry_create` uses it for "SDL could not allocate the property set" |
|
|
||||||
| `AKERR_KEY` | **A key is absent, or a registry write was refused.** A missing JSON object member (every `akgl_get_json_*_value`), a character with no sprite for a state, a physics backend name that is neither `null` nor `arcade`, a failed `SDL_SetPointerProperty` in `akgl_actor_initialize`. Frequently a `HANDLE` arm rather than a failure |
|
|
||||||
| `AKERR_TYPE` | **A JSON value was present but the wrong type.** All of `src/json_helpers.c` and `akgl_get_json_tilemap_property`. This is the status that separates "you did not write that key" (`AKERR_KEY`) from "you wrote it as a string and it wants a number" |
|
|
||||||
| `AKERR_OUTOFBOUNDS` | A value did not fit its destination, or an index ran off the end. `aksl_strncpy` truncation, an array index past the end of a JSON array (`akgl_get_json_array_index_*`), a ninth child on an actor, a tile past `AKGL_TILEMAP_MAX_*`, a control map id out of range |
|
|
||||||
| `AKERR_VALUE` | A value parsed but was not usable: a version string that is not semver (`akgl_game_load_versioncmp`), two surfaces that differ (`akgl_compare_sdl_surfaces`), a zero-sized destination or an overlapping copy from the `aksl_` wrappers |
|
|
||||||
| `AKERR_RELATIONSHIP` | **One site**: `akgl_actor_add_child`, when the child already has a parent. Detach it first |
|
|
||||||
| `AKERR_API` | Two distinct meanings, so read the message. (1) **The function is not implemented** — `akgl_render_2d_draw_mesh` does `FAIL_RETURN(..., AKERR_API, "Not implemented")`, and it is reached by an ordinary-looking call. (2) **A savegame does not match this build** — `akgl_game_load` and `akgl_game_load_versioncmp` |
|
|
||||||
| `AKERR_IO` | A read or write failed, or a savegame had trailing data after its name tables. Distinct from `AKERR_EOF` — that separation is the whole reason `aksl_fgetc` exists |
|
|
||||||
| `AKERR_EOF` | End of input. Sometimes the *desired* outcome: `require_at_eof` in `src/game.c` handles it and treats anything else as a failure |
|
|
||||||
| `AKERR_INDEX` | **libakgl never raises this.** It appears once, as a `HANDLE_GROUP` arm in `akgl_get_json_with_default`, so that a caller whose *own* code raises it still gets a default. An out-of-range JSON array index arrives as `AKERR_OUTOFBOUNDS` |
|
|
||||||
|
|
||||||
Statuses raised by the libraries underneath also reach you unchanged. `aksl_fopen` reports
|
|
||||||
`ENOENT` and `EACCES` as themselves; `aksl_fclose` reports `ENOSPC` and `EDQUOT`.
|
|
||||||
`akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
|
|
||||||
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`.
|
|
||||||
|
|
||||||
[Chapter 23](23-appendix-limits.md) has the per-function cross-reference.
|
|
||||||
|
|
||||||
## Table 3 — the exit status trap
|
|
||||||
|
|
||||||
This is arithmetic, and it is silent.
|
|
||||||
|
|
||||||
| You write | Wait status the shell sees | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `exit(AKGL_ERR_SDL)` | **0 — a clean run** | An exit status is one byte. `AKGL_ERR_SDL` is 256, and 256 & 0xFF is 0 |
|
|
||||||
| `akerr_exit(status)` | 0 → 0, 1–255 → itself, anything else → 125 | 125 is `AKERR_EXIT_STATUS_UNREPRESENTABLE` |
|
|
||||||
|
|
||||||
Every suite in `tests/` once reported success on the single most common failure a library
|
|
||||||
built on SDL can have — a renderer that would not come up. `tests/character.c` aborted at
|
|
||||||
its second of four tests and was green until 0.5.0.
|
|
||||||
|
|
||||||
**You do not normally need to do anything about this.** libakerror's default unhandled-error
|
|
||||||
handler already calls `akerr_exit()`, so a `main` that ends in `FINISH_NORETURN` exits
|
|
||||||
correctly. The trap is only live if you replace libakerror's
|
|
||||||
`akerr_handler_unhandled_error` pointer with your own handler, or call `exit(3)` on a
|
|
||||||
status yourself. If you do either, call `akerr_exit()`.
|
|
||||||
|
|
||||||
## Four traps that are libakgl's, not libakerror's
|
|
||||||
|
|
||||||
### `akgl_error_init()` must run before anything that can raise
|
|
||||||
|
|
||||||
`akgl_error_init` claims the status band *and* registers the six names. A code raised
|
|
||||||
before it runs has no name in the registry, so every stack trace carrying it prints
|
|
||||||
**"Unknown Error"** instead of "Heap Error" — and the status number, 258, means nothing to
|
|
||||||
a reader.
|
|
||||||
|
|
||||||
`akgl_game_init` calls it first, before anything else that can fail — that is why the
|
|
||||||
mutex creation and the `akgl_game.name` checks sit *after* it. A host with its own startup
|
|
||||||
path, such as one binding an existing renderer with `akgl_render_2d_bind` instead of
|
|
||||||
calling `akgl_game_init`, must call `akgl_error_init` itself. Repeat calls are a no-op, so
|
|
||||||
a program that cannot order its initialization precisely may simply call it more than once.
|
|
||||||
|
|
||||||
### The SDL callbacks end in `FINISH_NORETURN`, and that exits the process
|
|
||||||
|
|
||||||
`SDL_EnumerateProperties` takes a `void`-returning callback, so a failure inside one has
|
|
||||||
nowhere to go. All six of libakgl's therefore end in `FINISH_NORETURN`, which logs the
|
|
||||||
trace and hands the context to libakerror's unhandled-error handler — **and the default
|
|
||||||
handler exits the process.** They are `akgl_registry_iterate_actor` (`src/actor.c`),
|
|
||||||
`akgl_character_state_sprites_iterate` (`src/character.c`), and the four savegame name
|
|
||||||
iterators in `src/game.c`.
|
|
||||||
|
|
||||||
A reader meets this the first time a sprite name is wrong: an actor's `renderfunc` raises
|
|
||||||
`AKERR_KEY`, the callback cannot return it, and the game exits mid-frame with a stack trace
|
|
||||||
instead of returning an error you could have handled.
|
|
||||||
|
|
||||||
Two consequences worth planning for:
|
|
||||||
|
|
||||||
- A write error part-way through `akgl_game_save` terminates the game rather than reaching
|
|
||||||
`akgl_game_save`'s return value. `game.h` says so on `akgl_game_save_actors`.
|
|
||||||
- If you want a different outcome, point libakerror's `akerr_handler_unhandled_error`
|
|
||||||
function pointer at your own handler — and call `akerr_exit()` from it, per Table 3.
|
|
||||||
|
|
||||||
Note that `akgl_game_update` does **not** go through the callback. It walks
|
|
||||||
`akgl_heap_actors` directly and propagates through `PASS`, so an error from an actor's
|
|
||||||
`updatefunc` does reach you. The callback path is the render sweep and the savegame writer.
|
|
||||||
|
|
||||||
### `akgl_get_json_with_default` hands back the context you gave it
|
|
||||||
|
|
||||||
This is the one libakgl function whose ownership rule you have to know before you write
|
|
||||||
your first loader. It takes an incoming `akerr_ErrorContext *`, and when it does not handle
|
|
||||||
that status it returns **the same pointer**, because its `FINISH(e, true)` passes `e` up.
|
|
||||||
|
|
||||||
So the context has exactly one owner at a time, and it is never both yours and its. A
|
|
||||||
`CLEANUP` block that releases your original pointer *as well as* releasing what the call
|
|
||||||
returned releases the same slot twice, and a double-released context corrupts the failure
|
|
||||||
instead of reporting it. That is not hypothetical: it is how the first draft of the
|
|
||||||
`AKERR_OUTOFBOUNDS` test in `tests/json_helpers.c` passed against the unfixed library.
|
|
||||||
`AGENTS.md` documents the shape under "Testing Guidelines"; `TODO.md` item 18 records the
|
|
||||||
history.
|
|
||||||
|
|
||||||
The correct shape: take the result into a local, `NULL` your own pointer immediately, and
|
|
||||||
decide from the local.
|
|
||||||
|
|
||||||
It defaults on three statuses — `AKERR_KEY`, `AKERR_OUTOFBOUNDS` and `AKERR_INDEX` — so
|
|
||||||
both a missing object member and a short array give you the default. It does **not** default
|
|
||||||
on `AKERR_TYPE`: a key that is present with the wrong type is a mistake in the document, not
|
|
||||||
an absent value, and it propagates.
|
|
||||||
|
|
||||||
### libakerror 2.0.1 is a hard floor, checked at compile time
|
|
||||||
|
|
||||||
`include/akgl/error.h` carries two `#error` feature tests, because libakerror publishes no
|
|
||||||
version macro and each probes the narrowest thing that a required release introduced:
|
|
||||||
|
|
||||||
- `AKERR_FIRST_CONSUMER_STATUS` — added in 1.0.0 with the private status registry. Without
|
|
||||||
it, `AKGL_ERR_BASE` does not exist and the failure lands much later, inside `src/heap.c`.
|
|
||||||
- `AKERR_EXIT_STATUS_UNREPRESENTABLE` — added in 2.0.1 with `akerr_exit()`.
|
|
||||||
|
|
||||||
Both failures are an *installed header* being stale, not a build-tree problem. 2.0.0 is an
|
|
||||||
ABI break — `akerr_next_error()` returns a context that already holds its reference, and
|
|
||||||
`__akerr_last_ignored` is thread-local — and both expand at libakgl's own call sites through
|
|
||||||
`IGNORE` and the `FAIL_*` macros. The soname is `libakerror.so.2`, so the *libraries* cannot
|
|
||||||
be mixed by accident; the header can. Rebuild and reinstall libakerror.
|
|
||||||
|
|
||||||
## One worked example
|
|
||||||
|
|
||||||
A loader reading an optional integer out of a JSON document. It is the smallest piece of
|
|
||||||
real libakgl code that exercises both halves of the chapter: a status that means "absent
|
|
||||||
and that is fine", and the ownership rule on the function that turns it into a default.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <jansson.h>
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/json_helpers.h>
|
|
||||||
|
|
||||||
/* Read an optional integer out of a loaded document, defaulting to 4. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *read_speed(json_t *doc, int *dest)
|
|
||||||
{
|
|
||||||
akerr_ErrorContext *keyerr = NULL;
|
|
||||||
akerr_ErrorContext *unhandled = NULL;
|
|
||||||
int def = 4;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, doc, AKERR_NULLPOINTER, "NULL document");
|
|
||||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
|
||||||
|
|
||||||
keyerr = akgl_get_json_integer_value(doc, "speed", dest);
|
|
||||||
unhandled = akgl_get_json_with_default(keyerr, &def, dest, sizeof(int));
|
|
||||||
keyerr = NULL;
|
|
||||||
if ( unhandled != NULL ) {
|
|
||||||
return unhandled;
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void)
|
|
||||||
{
|
|
||||||
json_t *doc = NULL;
|
|
||||||
int speed = 0;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_error_init());
|
|
||||||
doc = json_pack("{s:i}", "width", 32);
|
|
||||||
CATCH(errctx, read_speed(doc, &speed));
|
|
||||||
} CLEANUP {
|
|
||||||
if ( doc != NULL ) {
|
|
||||||
json_decref(doc);
|
|
||||||
}
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH_NORETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Four things in there are libakgl-specific and worth naming:
|
|
||||||
|
|
||||||
1. **`akgl_error_init()` is the first call**, before anything that can raise.
|
|
||||||
2. **`keyerr = NULL;` on the line after the call.** Ownership passed either way — handled
|
|
||||||
or not — and the local is the only surviving reference. Nothing else in the function may
|
|
||||||
touch `keyerr` again.
|
|
||||||
3. **A `speed` key that is absent is not an error.** `akgl_get_json_integer_value` raises
|
|
||||||
`AKERR_KEY`, `akgl_get_json_with_default` handles it and writes 4. A `speed` key that is
|
|
||||||
present as a string *is* an error: `AKERR_TYPE` is not defaulted and comes back as
|
|
||||||
`unhandled`.
|
|
||||||
4. **`main` ends in `FINISH_NORETURN`.** That is what gets you `akerr_exit()` and a
|
|
||||||
truthful exit status without writing the trap from Table 3.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
- [Chapter 5](05-the-heap.md) — `AKGL_ERR_HEAP`, and why it is almost always a missing
|
|
||||||
release.
|
|
||||||
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
|
|
||||||
`AKERR_NULLPOINTER`.
|
|
||||||
- [Chapter 23](23-appendix-limits.md) — which function raises what.
|
|
||||||
- `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion.
|
|
||||||
- The generated Doxygen reference — every function's own `@throws` list.
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# 05. The heap
|
|
||||||
|
|
||||||
libakgl does not call `malloc`. Every runtime object comes out of a fixed, statically
|
|
||||||
allocated array declared in `include/akgl/heap.h`, and "allocation" is a linear scan for a
|
|
||||||
slot whose `refcount` is zero.
|
|
||||||
|
|
||||||
**The consequence to design around is that exhaustion is an ordinary error, not an
|
|
||||||
out-of-memory catastrophe.** `AKGL_ERR_HEAP` is a status you can handle. It is also,
|
|
||||||
almost always, a missing release rather than a pool that is genuinely too small.
|
|
||||||
|
|
||||||
## The eight pools
|
|
||||||
|
|
||||||
A "heap layer" is one array plus its `next`/`release` pair. There are five:
|
|
||||||
|
|
||||||
| Layer | Ceiling | Default | Element | Total |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Actor | `AKGL_MAX_HEAP_ACTOR` | 64 | 400 B | 25 KiB |
|
|
||||||
| Sprite | `AKGL_MAX_HEAP_SPRITE` | 1024 | 176 B | 176 KiB |
|
|
||||||
| Spritesheet | `AKGL_MAX_HEAP_SPRITESHEET` | 1024 | 536 B | 536 KiB |
|
|
||||||
| Character | `AKGL_MAX_HEAP_CHARACTER` | 256 | 184 B | 46 KiB |
|
|
||||||
| String | `AKGL_MAX_HEAP_STRING` | 256 | 4100 B | 1.0 MiB |
|
|
||||||
|
|
||||||
The whole thing is about 1.8 MiB of BSS, and **the string pool is more than half of it**
|
|
||||||
because every `akgl_String` is `PATH_MAX` bytes whether it holds a path or the four
|
|
||||||
characters `"0.0"`.
|
|
||||||
|
|
||||||
The ceilings are all `#ifndef`-guarded:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/heap.h
|
|
||||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
|
||||||
#define AKGL_MAX_HEAP_ACTOR 64
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
|
||||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
|
||||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
|
||||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_STRING
|
|
||||||
#define AKGL_MAX_HEAP_STRING 256
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
The sprite pool is sixteen per actor, on the assumption of one sprite per state
|
|
||||||
combination. The spritesheet pool is sized to match, though sharing by resolved path means
|
|
||||||
far fewer are used in practice.
|
|
||||||
|
|
||||||
The five arrays are `extern` and public — `akgl_heap_actors`, `akgl_heap_sprites`,
|
|
||||||
`akgl_heap_spritesheets`, `akgl_heap_characters`, `akgl_heap_strings` — so the render and
|
|
||||||
physics sweeps can walk them directly instead of going through a registry.
|
|
||||||
`akgl_game_update` does exactly that.
|
|
||||||
|
|
||||||
### The ceilings are a compile-time ABI constraint
|
|
||||||
|
|
||||||
This is the part that bites, and it does not announce itself.
|
|
||||||
|
|
||||||
The arrays are sized at compile time. Defining `AKGL_MAX_HEAP_ACTOR` to 256 before
|
|
||||||
including `heap.h` changes the *declared* extent of `akgl_heap_actors` in your translation
|
|
||||||
unit — but `src/heap.c` was compiled with whatever the library was built with, and that is
|
|
||||||
where the storage actually lives. Mismatch the two and your code indexes past the end of an
|
|
||||||
array that is still 64 entries long, in another object file, with no diagnostic anywhere.
|
|
||||||
|
|
||||||
```text
|
|
||||||
your game.c libakgl.so
|
|
||||||
------------------------ ------------------------
|
|
||||||
#define AKGL_MAX_HEAP_ACTOR 256 (built with the default 64)
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
akgl_Actor akgl_heap_actors[64];
|
|
||||||
akgl_heap_actors[200] ------------------> off the end. No warning.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Raise a ceiling for the whole build or not at all.** With `add_subdirectory`, put it in a
|
|
||||||
`target_compile_definitions` that reaches libakgl's own sources as well as yours. Against
|
|
||||||
an installed `libakgl.so`, you cannot change it at all without rebuilding and reinstalling
|
|
||||||
the library, and the soname does not encode it — so a rebuilt game against an old installed
|
|
||||||
library is a silent overrun, not a link error.
|
|
||||||
|
|
||||||
## Claiming and releasing
|
|
||||||
|
|
||||||
Each layer has an acquire and a release:
|
|
||||||
|
|
||||||
| Layer | Acquire | Release |
|
|
||||||
|---|---|---|
|
|
||||||
| Actor | `akgl_heap_next_actor` | `akgl_heap_release_actor` |
|
|
||||||
| Sprite | `akgl_heap_next_sprite` | `akgl_heap_release_sprite` |
|
|
||||||
| Spritesheet | `akgl_heap_next_spritesheet` | `akgl_heap_release_spritesheet` |
|
|
||||||
| Character | `akgl_heap_next_character` | `akgl_heap_release_character` |
|
|
||||||
| String | `akgl_heap_next_string` | `akgl_heap_release_string` |
|
|
||||||
|
|
||||||
Every acquire raises `AKGL_ERR_HEAP` and nothing else. Every release raises
|
|
||||||
`AKERR_NULLPOINTER` and nothing else. Full signatures are in the Doxygen reference; the
|
|
||||||
behaviour worth knowing is below.
|
|
||||||
|
|
||||||
**None of the `next_*` functions checks `dest`.** A `NULL` there is a crash, not an error
|
|
||||||
context. That is deliberate — the check would cost a branch on the allocation path — but it
|
|
||||||
means these are the functions where you validate your own argument first.
|
|
||||||
|
|
||||||
**None of them zeroes the slot either.** What you get back is whatever the previous holder
|
|
||||||
left. The `*_initialize` functions `memset` before they do anything else, which is why the
|
|
||||||
normal shape is acquire-then-initialize and never acquire-then-use.
|
|
||||||
|
|
||||||
### The refcount asymmetry
|
|
||||||
|
|
||||||
This is the one thing about the heap that will surprise you, and it is a known defect
|
|
||||||
rather than a design (`TODO.md`, "Known and still open" item 8; also the `@warning` on
|
|
||||||
`heap.h` itself).
|
|
||||||
|
|
||||||
| Acquire | Takes the reference? | Who takes it |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_heap_next_string` | **Yes** — `refcount` is 1 on return | the acquire itself |
|
|
||||||
| `akgl_heap_next_actor` | No | `akgl_actor_initialize` |
|
|
||||||
| `akgl_heap_next_sprite` | No | `akgl_sprite_initialize` |
|
|
||||||
| `akgl_heap_next_spritesheet` | No | `akgl_spritesheet_initialize` |
|
|
||||||
| `akgl_heap_next_character` | No | `akgl_character_initialize` |
|
|
||||||
|
|
||||||
**Until a reference is taken, the slot is still free and the next acquire hands out the
|
|
||||||
same pointer.** Seven of the eight acquires therefore leave a window in which two callers can
|
|
||||||
be holding the same object, and the window closes only when `*_initialize` runs. In
|
|
||||||
practice you never see it, because the four are always immediately followed by their
|
|
||||||
initializer — which is exactly why the asymmetry has survived. Write them adjacent and
|
|
||||||
nothing can get in between:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
|
|
||||||
/* Claim an actor slot and take the reference on it. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *spawn_actor(akgl_Actor **dest, char *name)
|
|
||||||
{
|
|
||||||
akgl_Actor *obj = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
|
||||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name");
|
|
||||||
|
|
||||||
PASS(errctx, akgl_heap_next_actor(&obj));
|
|
||||||
/* obj->refcount is still 0. The slot is not yours yet. */
|
|
||||||
PASS(errctx, akgl_actor_initialize(obj, name));
|
|
||||||
/* obj->refcount is 1 now, and the actor is in AKGL_REGISTRY_ACTOR. */
|
|
||||||
*dest = obj;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You do not have to take that on trust. This program is compiled, linked against the library
|
|
||||||
and run by the test suite, and the output below it is what it actually prints:
|
|
||||||
|
|
||||||
```c run=akglapp
|
|
||||||
akgl_Actor *first = NULL;
|
|
||||||
akgl_Actor *second = NULL;
|
|
||||||
akgl_String *str = NULL;
|
|
||||||
|
|
||||||
/* A string acquire takes the reference for you. */
|
|
||||||
PASS(errctx, akgl_heap_next_string(&str));
|
|
||||||
printf("string refcount after acquire: %d\n", str->refcount);
|
|
||||||
|
|
||||||
/* An actor acquire does not. */
|
|
||||||
PASS(errctx, akgl_heap_next_actor(&first));
|
|
||||||
printf("actor refcount after acquire: %d\n", first->refcount);
|
|
||||||
|
|
||||||
/* So a second acquire, before the initializer runs, finds the same free slot. */
|
|
||||||
PASS(errctx, akgl_heap_next_actor(&second));
|
|
||||||
printf("same slot handed out twice: %s\n", (first == second) ? "yes" : "no");
|
|
||||||
|
|
||||||
/* Initializing is what takes the reference and closes the window. */
|
|
||||||
PASS(errctx, akgl_actor_initialize(first, "player"));
|
|
||||||
printf("actor refcount after init: %d\n", first->refcount);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_heap_next_actor(&second));
|
|
||||||
printf("now a distinct slot: %s\n", (first == second) ? "no" : "yes");
|
|
||||||
|
|
||||||
PASS(errctx, akgl_heap_release_actor(first));
|
|
||||||
PASS(errctx, akgl_heap_release_string(str));
|
|
||||||
```
|
|
||||||
|
|
||||||
```output
|
|
||||||
string refcount after acquire: 1
|
|
||||||
actor refcount after acquire: 0
|
|
||||||
same slot handed out twice: yes
|
|
||||||
actor refcount after init: 1
|
|
||||||
now a distinct slot: yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Note also that a failure between the two — `akgl_actor_initialize` rejecting the name, say
|
|
||||||
— needs no cleanup, precisely because nothing was claimed. That is the one thing the
|
|
||||||
asymmetry is good for.
|
|
||||||
|
|
||||||
The string layer is the opposite and is safe on its own, which is what makes the
|
|
||||||
scratch-buffer idiom below work.
|
|
||||||
|
|
||||||
### What each release actually does
|
|
||||||
|
|
||||||
They all decrement, and they all do the teardown when the count reaches zero. What the
|
|
||||||
teardown *is* differs, and two of them reach outside the pool:
|
|
||||||
|
|
||||||
- **`akgl_heap_release_actor`** releases every non-`NULL` child recursively, clears the
|
|
||||||
actor's entry from `AKGL_REGISTRY_ACTOR`, and zeroes the slot. The recursion has **no
|
|
||||||
cycle check**: an actor reachable from its own child list recurses until the stack runs
|
|
||||||
out.
|
|
||||||
- **`akgl_heap_release_sprite`** clears the `AKGL_REGISTRY_SPRITE` entry and zeroes the
|
|
||||||
slot. **It does not release the spritesheet** — the sprite only borrowed it.
|
|
||||||
- **`akgl_heap_release_spritesheet`** clears the registry entry, **destroys the
|
|
||||||
`SDL_Texture`**, and zeroes the slot. Any sprite still pointing at the sheet is left with
|
|
||||||
a dangling pointer, since nothing takes a reference on a sheet on a sprite's behalf.
|
|
||||||
Destroying a texture is a main-thread operation in SDL, so this must be called from the
|
|
||||||
thread that owns the renderer.
|
|
||||||
- **`akgl_heap_release_character`** walks the state-to-sprite map with
|
|
||||||
`akgl_character_state_sprites_iterate` and `AKGL_ITERATOR_OP_RELEASE`, giving back every
|
|
||||||
reference `akgl_character_sprite_add` took, then destroys the `SDL_PropertiesID` holding
|
|
||||||
the map, clears the registry entry, and zeroes the slot.
|
|
||||||
- **`akgl_heap_release_string`** zeroes the character data. Strings are not registered
|
|
||||||
anywhere, so that is all there is to it.
|
|
||||||
|
|
||||||
One shared hazard: **a slot whose `refcount` is already 0 is re-torn-down rather than
|
|
||||||
rejected.** Harmless on a zeroed slot, destructive on a live one that was never registered.
|
|
||||||
|
|
||||||
`akgl_heap_release_string` is the exception to the library's usual `NULL`-is-a-no-op
|
|
||||||
convention: a `NULL` here is `AKERR_NULLPOINTER`, which is why every `CLEANUP` block in the
|
|
||||||
library wraps it in `IGNORE()`.
|
|
||||||
|
|
||||||
## `akgl_String`
|
|
||||||
|
|
||||||
A pooled string is a `refcount` and a `PATH_MAX` buffer:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/staticstring.h
|
|
||||||
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
|
|
||||||
typedef struct
|
|
||||||
{
|
|
||||||
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
|
|
||||||
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
|
|
||||||
} akgl_String;
|
|
||||||
```
|
|
||||||
|
|
||||||
Capacity is fixed. `akgl_string_initialize` and `akgl_string_copy` **truncate rather than
|
|
||||||
grow, and the truncation is silent** — they both always NUL-terminate, but they do not tell
|
|
||||||
you that something was lost. If you need to know, measure first, or go through
|
|
||||||
`aksl_strncpy` with the full destination size and let it raise `AKERR_OUTOFBOUNDS`.
|
|
||||||
|
|
||||||
Two details that catch people:
|
|
||||||
|
|
||||||
- **`akgl_heap_next_string` does not clean the buffer.** The contents are whatever the last
|
|
||||||
holder left. Call `akgl_string_initialize(str, NULL)` if you need it clean — and note
|
|
||||||
that `akgl_get_property` only copies the value and its terminator, leaving the rest of
|
|
||||||
the buffer alone, so read the result as a C string rather than as
|
|
||||||
`AKGL_MAX_STRING_LENGTH` bytes.
|
|
||||||
- **Several functions claim one for you.** `akgl_get_property`,
|
|
||||||
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` all claim a slot when
|
|
||||||
`*dest` is `NULL`, and write in place when it is not. Either way **the caller releases
|
|
||||||
it.**
|
|
||||||
|
|
||||||
The scratch-buffer idiom is claim, use, release unconditionally in `CLEANUP`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akstdlib.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/registry.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
|
|
||||||
/* The scratch-string idiom: claim, use, release unconditionally in CLEANUP. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *screen_width(int *dest)
|
|
||||||
{
|
|
||||||
akgl_String *value = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_string(&value));
|
|
||||||
CATCH(errctx, akgl_get_property("game.screenwidth", &value, "800"));
|
|
||||||
CATCH(errctx, aksl_atoi(value->data, dest));
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(value));
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `CLEANUP` block runs on every path out of the `ATTEMPT`, which is the whole reason the
|
|
||||||
release goes there and not after the last `CATCH`. **Never use a `*_RETURN` macro inside
|
|
||||||
that `ATTEMPT`** — it returns past `CLEANUP` and leaks the slot. That mistake has shipped
|
|
||||||
in this library before.
|
|
||||||
|
|
||||||
## Reading `AKGL_ERR_HEAP`
|
|
||||||
|
|
||||||
When a pool is exhausted the message names the layer:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Unable to find unused string on the heap
|
|
||||||
```
|
|
||||||
|
|
||||||
Before you raise the ceiling, look for the missing release. The realistic causes, in the
|
|
||||||
order they actually happen:
|
|
||||||
|
|
||||||
1. **A string claimed and never released.** By far the most common: the pool is 256 slots
|
|
||||||
and every `akgl_get_property` call can claim one. A per-frame read that forgets the
|
|
||||||
release exhausts it in four seconds at 60 Hz.
|
|
||||||
2. **A `*_RETURN` macro inside an `ATTEMPT`**, skipping the `CLEANUP` that held the
|
|
||||||
release.
|
|
||||||
3. **A `return` out of a `HANDLE` block.** That leaks a libakerror context slot rather than
|
|
||||||
an akgl one, and shows up as `"Unable to pull an error context from the array!"` on the
|
|
||||||
129th call — different pool, same shape of bug.
|
|
||||||
4. **Characters reloaded per level without releasing the previous ones**, which exhausts
|
|
||||||
the *sprite* pool rather than the character pool, since each character holds a reference
|
|
||||||
per bound state.
|
|
||||||
|
|
||||||
Only after all four are ruled out is the ceiling actually too small — and a game that
|
|
||||||
genuinely needs 256 actors should say so once, for the whole build, per the ABI note above.
|
|
||||||
|
|
||||||
## Initialization and reset
|
|
||||||
|
|
||||||
`akgl_heap_init` zeroes all eight pools. `akgl_game_init` calls it for you, before any
|
|
||||||
registry exists.
|
|
||||||
|
|
||||||
**Calling it again is a reset, not a refresh.** It does not release textures, clear
|
|
||||||
registries, or consult reference counts, so every live object becomes a dangling pointer
|
|
||||||
and every registry entry points at a zeroed slot.
|
|
||||||
|
|
||||||
`akgl_heap_init_actor` zeroes the actor pool only, which is what makes it usable between
|
|
||||||
levels: the sprites, sheets and characters are the expensive ones because they own
|
|
||||||
textures. Pair it with `akgl_registry_init_actor`, the one registry initializer that
|
|
||||||
destroys the old property set before creating the replacement. See
|
|
||||||
[Chapter 6](06-the-registry.md).
|
|
||||||
|
|
||||||
Both return `NULL` and have no failure path today — they are a series of `memset`s. Check
|
|
||||||
them anyway; the signature exists so a layer that needs real setup has somewhere to report
|
|
||||||
from.
|
|
||||||
|
|
||||||
## If you need a new kind of object
|
|
||||||
|
|
||||||
Add a layer here. Do not reach for the allocator: `AGENTS.md` makes that a standing rule,
|
|
||||||
and a `malloc` in the middle of a frame is the failure mode the pools exist to remove.
|
|
||||||
A layer is an array, an `AKGL_MAX_HEAP_*` ceiling, a `next`, and a `release` — five things,
|
|
||||||
all visible in `src/heap.c`.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
|
|
||||||
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
|
|
||||||
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
# 06. The registry
|
|
||||||
|
|
||||||
Nothing in this library is passed around by pointer where a name will do. An actor names
|
|
||||||
the character it instantiates, a character names the sprites it draws, a sprite names the
|
|
||||||
sheet it cuts frames from. All of it is resolved at load time through eight registries,
|
|
||||||
and that is what lets the whole asset graph be described in JSON files that reference each
|
|
||||||
other by name.
|
|
||||||
|
|
||||||
**The registries are SDL property sets, not akgl types.** They are `SDL_PropertiesID`
|
|
||||||
values with external linkage, so you can enumerate one with `SDL_EnumerateProperties`,
|
|
||||||
which is exactly what `akgl_registry_iterate_actor` and
|
|
||||||
`akgl_character_state_sprites_iterate` do.
|
|
||||||
|
|
||||||
## The eight registries
|
|
||||||
|
|
||||||
| Registry | Key | Value | Written by | Cleared by |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `AKGL_REGISTRY_ACTOR` | actor name | `akgl_Actor *` | `akgl_actor_initialize` | `akgl_heap_release_actor` at refcount 0 |
|
|
||||||
| `AKGL_REGISTRY_SPRITE` | sprite name | `akgl_Sprite *` | `akgl_sprite_initialize` | `akgl_heap_release_sprite` at refcount 0 |
|
|
||||||
| `AKGL_REGISTRY_SPRITESHEET` | **resolved image path** | `akgl_SpriteSheet *` | `akgl_spritesheet_initialize` | `akgl_heap_release_spritesheet` at refcount 0 |
|
|
||||||
| `AKGL_REGISTRY_CHARACTER` | character name | `akgl_Character *` | `akgl_character_initialize` | `akgl_heap_release_character` at refcount 0 |
|
|
||||||
| `AKGL_REGISTRY_ACTOR_STATE_STRINGS` | state name | its bit value, as a number | `akgl_registry_init_actor_state_strings` | never |
|
|
||||||
| `AKGL_REGISTRY_FONT` | font name (yours) | `TTF_Font *` | `akgl_text_loadfont` | `akgl_text_unloadfont` |
|
|
||||||
| `AKGL_REGISTRY_MUSIC` | music name | audio handle | **nothing** — created but never populated | — |
|
|
||||||
| `AKGL_REGISTRY_PROPERTIES` | configuration key | string value | `akgl_set_property`, `akgl_registry_load_properties` | never |
|
|
||||||
|
|
||||||
Three of those rows are worth expanding.
|
|
||||||
|
|
||||||
**The spritesheet registry is keyed by path, not by name.** That is the sharing mechanism:
|
|
||||||
two sprites cutting frames out of the same image find the same `akgl_SpriteSheet` and the
|
|
||||||
same `SDL_Texture`, and the texture is loaded once.
|
|
||||||
|
|
||||||
**The actor-state-string registry exists so JSON can name a bit.**
|
|
||||||
`akgl_registry_init_actor_state_strings` walks `AKGL_ACTOR_STATE_STRING_NAMES` and maps
|
|
||||||
entry `i` to `1 << i`, which is what lets a character definition write
|
|
||||||
`"AKGL_ACTOR_STATE_FACE_LEFT"` instead of `2`. Two entries in that name table disagree with
|
|
||||||
`actor.h`: bits 11 and 12 are `UNDEFINED_11` and `UNDEFINED_12` rather than `MOVING_IN` and
|
|
||||||
`MOVING_OUT`, **so those two states cannot be named from JSON at all** (`TODO.md` items
|
|
||||||
24–26). The individual name registrations are not checked, either.
|
|
||||||
|
|
||||||
**The music registry is empty.** `akgl_registry_init_music` creates it and nothing in the
|
|
||||||
library ever writes to it. It is there for you.
|
|
||||||
|
|
||||||
## What actually runs at startup
|
|
||||||
|
|
||||||
`registry.h` documents `akgl_registry_init` as creating "the seven asset registries", with
|
|
||||||
a `@warning` that it does not create `AKGL_REGISTRY_PROPERTIES`. **Both halves of that are
|
|
||||||
now wrong**, and the correction matters because the warning is what a reader would design
|
|
||||||
around.
|
|
||||||
|
|
||||||
Read against `src/registry.c` and `src/game.c`, this is what is true:
|
|
||||||
|
|
||||||
- `akgl_registry_init` creates **eight**, including `AKGL_REGISTRY_PROPERTIES`. The
|
|
||||||
properties call was added in 0.5.0 (`TODO.md`, "Known and still open" item 3, marked
|
|
||||||
fixed). Its order is spritesheet, sprite, character, actor, actor-state-strings, font,
|
|
||||||
music, properties.
|
|
||||||
- **`akgl_game_init` never calls `akgl_registry_init`.** It calls the eight individual
|
|
||||||
initializers itself, in a different order: actor, sprite, spritesheet, character, font,
|
|
||||||
music, properties, actor-state-strings.
|
|
||||||
|
|
||||||
So there are two supported startup paths and they do not share code. If you call
|
|
||||||
`akgl_game_init`, you have all eight and need do nothing. If you are building your own
|
|
||||||
startup — an embedder binding an existing renderer, say — call `akgl_registry_init` and you
|
|
||||||
also have all eight. What you must not do is call some subset by hand and assume the rest
|
|
||||||
followed.
|
|
||||||
|
|
||||||
`akgl_registry_init_actor` is the one initializer that behaves differently in a way you
|
|
||||||
would notice: since 0.5.0 every initializer destroys the previous property set before
|
|
||||||
creating the replacement, so none of them leaks on a second call, but the *actor* one is
|
|
||||||
the one meant to be called again — between levels, paired with `akgl_heap_init_actor`. Note
|
|
||||||
that it destroys the **registry**, not the actors; releasing those is
|
|
||||||
`akgl_heap_release_actor`'s job.
|
|
||||||
|
|
||||||
## The id-0 trap
|
|
||||||
|
|
||||||
Every `SDL_PropertiesID` in `src/registry.c` starts at 0, and SDL treats 0 as "no such
|
|
||||||
property set". **Reads return the default and writes are refused.** An uninitialized
|
|
||||||
registry therefore does not fail the way you would want it to.
|
|
||||||
|
|
||||||
How loud that failure is depends entirely on whether libakgl checks SDL's return value, and
|
|
||||||
it does not check everywhere:
|
|
||||||
|
|
||||||
| Call | Behaviour against an id of 0 |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_actor_initialize`, `akgl_sprite_initialize`, `akgl_spritesheet_initialize`, `akgl_character_initialize` | **Loud.** The `SDL_SetPointerProperty` result is checked; you get `AKERR_KEY`, "Unable to add … to registry" |
|
|
||||||
| `akgl_actor_set_character` | **Loud.** `SDL_GetPointerProperty` returns `NULL` and the result is checked; you get `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_set_property` | **Silent.** The `SDL_SetStringProperty` result is not checked. The value is discarded and the call returns success |
|
|
||||||
| `akgl_registry_load_properties` | **Silent, and worse.** Same unchecked write, and it logs `Set property x = y` for every entry it dropped, then logs `Properties loaded` |
|
|
||||||
| `akgl_get_property` | **Silent.** You get the default you passed, every time |
|
|
||||||
| `akgl_registry_init_actor_state_strings` | **Silent.** The `SDL_SetNumberProperty` results are not checked |
|
|
||||||
|
|
||||||
The consequence is the one `registry.h` warns about, and it is real even though the
|
|
||||||
attribution has moved: **a startup path that leaves `AKGL_REGISTRY_PROPERTIES` at 0 gives
|
|
||||||
you a no-op `akgl_set_property` and an `akgl_get_property` that always hands back your
|
|
||||||
default** — which in turn means `akgl_render_2d_init` builds a window from its fallback
|
|
||||||
size and `akgl_physics_init_arcade` runs with no gravity, both reporting success.
|
|
||||||
|
|
||||||
If you are debugging configuration that appears not to apply, check
|
|
||||||
`AKGL_REGISTRY_PROPERTIES != 0` before you check anything else.
|
|
||||||
|
|
||||||
## Truncated keys
|
|
||||||
|
|
||||||
Every name is copied into a fixed-width field with `aksl_strncpy` bounded to
|
|
||||||
`sizeof(field) - 1`, which is the "**truncated, not rejected**" contract those headers
|
|
||||||
document. The fields:
|
|
||||||
|
|
||||||
| Object | Field | Bytes |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_Actor::name` | `AKGL_ACTOR_MAX_NAME_LENGTH` | 128 |
|
|
||||||
| `akgl_Sprite::name` | `AKGL_SPRITE_MAX_NAME_LENGTH` | 128 |
|
|
||||||
| `akgl_Character::name` | `AKGL_CHARACTER_MAX_NAME_LENGTH` | 128 |
|
|
||||||
| `akgl_SpriteSheet::name` | `AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` | 512 |
|
|
||||||
|
|
||||||
**Two distinct names that agree on their first 127 bytes truncate to the same key**, and
|
|
||||||
the second `SDL_SetPointerProperty` silently replaces the first. The objects are different;
|
|
||||||
the registry cannot tell. That is recorded in `TODO.md`, "Truncated registry keys can
|
|
||||||
collide", and it is not being fixed because the fix is a contract change — refusing an
|
|
||||||
over-long name with `AKERR_OUTOFBOUNDS` — and every one of those headers currently promises
|
|
||||||
the opposite.
|
|
||||||
|
|
||||||
There is a second half that the `TODO.md` entry understates. It says the truncated name *is*
|
|
||||||
the registry key. That is true for sprites and spritesheets, which register under their own
|
|
||||||
copied field. It is **not** true for actors and characters:
|
|
||||||
|
|
||||||
- `akgl_actor_initialize` and `akgl_character_initialize` register under the **caller's**
|
|
||||||
string, untruncated.
|
|
||||||
- `akgl_heap_release_actor` and `akgl_heap_release_character` clear under the object's
|
|
||||||
**truncated** field.
|
|
||||||
|
|
||||||
For any name of 127 bytes or fewer those are the same string and nothing happens. Over that,
|
|
||||||
the entry is written under one key and cleared under another, so **releasing the object
|
|
||||||
leaves a live registry entry pointing at a zeroed pool slot** — and the next lookup of that
|
|
||||||
name hands out a pointer to freed storage. Keep asset names short; 127 bytes is generous for
|
|
||||||
something a human types into a JSON file.
|
|
||||||
|
|
||||||
## Configuration properties
|
|
||||||
|
|
||||||
`AKGL_REGISTRY_PROPERTIES` is a string-to-string store. **Everything is a string, including
|
|
||||||
numbers** — `game.screenwidth` is `"800"`, not `800` — and whoever reads a property parses
|
|
||||||
it.
|
|
||||||
|
|
||||||
These are every property the library itself reads, with the default it falls back to and
|
|
||||||
where it is read:
|
|
||||||
|
|
||||||
| Property | Default | Read by | Meaning |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | Window and camera width, in pixels |
|
|
||||||
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | Window and camera height, in pixels |
|
|
||||||
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on x, px/s² |
|
|
||||||
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on y, px/s². Positive is down |
|
|
||||||
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on z, px/s² |
|
|
||||||
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | Fraction of environmental velocity shed per second on x |
|
|
||||||
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | Same for y. **This is the only brake on falling** — there is no terminal velocity |
|
|
||||||
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | Same for z |
|
|
||||||
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | Upper bound on a single simulation `dt`, in seconds |
|
|
||||||
|
|
||||||
That is the whole list. Two things follow from it:
|
|
||||||
|
|
||||||
**The screen size defaults to 0×0.** `akgl_render_2d_init` passes whatever it parsed
|
|
||||||
straight to `SDL_CreateWindowAndRenderer`, so a program that never sets those two asks SDL
|
|
||||||
for a zero-sized window. Set them before calling it.
|
|
||||||
|
|
||||||
**`physics.engine` is not a property.** `physics.h` says in two places that `akgl_game_init`
|
|
||||||
passes a `physics.engine` property to `akgl_physics_factory`. It does not — `akgl_game_init`
|
|
||||||
never calls the factory at all, and the string `physics.engine` does not appear anywhere in
|
|
||||||
`src/`. Choosing a backend is an explicit call you make yourself, with a name of `"null"` or
|
|
||||||
`"arcade"`; see [Chapter 14](14-physics.md). The header comment is a known-false claim left
|
|
||||||
alone deliberately, since correcting it is a source change rather than a documentation one.
|
|
||||||
|
|
||||||
### Setting properties
|
|
||||||
|
|
||||||
Two ways in, and both must happen **after** `akgl_game_init` (which creates the registry)
|
|
||||||
and **before** `akgl_render_2d_init` or `akgl_physics_init_arcade` (which read it).
|
|
||||||
|
|
||||||
`akgl_set_property(name, value)` sets one. SDL copies the value, so your buffer can go away
|
|
||||||
afterwards.
|
|
||||||
|
|
||||||
`akgl_registry_load_properties(fname)` loads a JSON document. It expects a top-level
|
|
||||||
`properties` object whose members are **all strings**:
|
|
||||||
|
|
||||||
```json kind=properties
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"game.screenwidth": "800",
|
|
||||||
"game.screenheight": "600",
|
|
||||||
"physics.gravity.y": "400.0",
|
|
||||||
"physics.drag.y": "0.9",
|
|
||||||
"physics.max_timestep": "0.05"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A member that is not a string is `AKERR_TYPE`; a document with no `properties` member is
|
|
||||||
`AKERR_KEY`; a file that will not open or will not parse is `AKERR_NULLPOINTER` with
|
|
||||||
jansson's line number and text in the message. Note that a failure part-way through leaks
|
|
||||||
one string-pool slot, because the per-property `CLEANUP` block is empty — a malformed
|
|
||||||
configuration file is worth failing on rather than retrying in a loop.
|
|
||||||
|
|
||||||
### Reading properties
|
|
||||||
|
|
||||||
`akgl_get_property(name, dest, def)` reads one into a pooled string. **Absence is not an
|
|
||||||
error**: an unset property yields `def`, which is how every caller inside the library gets a
|
|
||||||
working default without checking first.
|
|
||||||
|
|
||||||
Three details, all of which have caused a defect here before:
|
|
||||||
|
|
||||||
- **`*dest` decides who allocates.** `NULL` claims a slot from the string pool for you;
|
|
||||||
non-`NULL` is written in place. Either way **you** release it with
|
|
||||||
`akgl_heap_release_string`. Initialize your local to `NULL` on the first call.
|
|
||||||
- **`def` is effectively required.** A `NULL` default on an unset property is
|
|
||||||
`AKERR_NULLPOINTER`, not an empty result.
|
|
||||||
- **Only the value and its terminator are copied.** The rest of the destination keeps
|
|
||||||
whatever the previous holder left, so read the result as a C string and never as
|
|
||||||
`AKGL_MAX_STRING_LENGTH` bytes. A value of `AKGL_MAX_STRING_LENGTH` bytes or more is
|
|
||||||
`AKERR_OUTOFBOUNDS`; it will not fit an `akgl_String` with its terminator.
|
|
||||||
|
|
||||||
The full worked example — claim, read, parse, release in `CLEANUP` — is in
|
|
||||||
[Chapter 5](05-the-heap.md), because the ownership rule is the string pool's rather than the
|
|
||||||
registry's.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
- [Chapter 5](05-the-heap.md) — the pools the registries point into, and who releases what.
|
|
||||||
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created,
|
|
||||||
and where configuration has to be in place by.
|
|
||||||
- [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do.
|
|
||||||
- [Chapter 23](23-appendix-limits.md) — the configuration table again, alongside every
|
|
||||||
`AKGL_MAX_*`.
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
# 07. The game and the frame
|
|
||||||
|
|
||||||
`game.h` is the top of the library. `akgl_game_init` brings up SDL, the pools, the
|
|
||||||
registries and the audio and font engines; `akgl_game_update` is one frame — update every
|
|
||||||
actor, step the physics, draw the world.
|
|
||||||
|
|
||||||
**There is exactly one of everything.** `akgl_renderer`, `akgl_physics`, `akgl_camera` and
|
|
||||||
`akgl_gamemap` are globals pointing at `akgl_default_*` storage, so a program can swap in
|
|
||||||
its own instance by reassigning the pointer without the rest of the library knowing. That
|
|
||||||
is the whole extent of the indirection: there is no notion of two worlds at once.
|
|
||||||
|
|
||||||
## The startup order
|
|
||||||
|
|
||||||
`game.h` documents a five-step sequence, and it holds up against `src/game.c`:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akstdlib.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
#include <akgl/registry.h>
|
|
||||||
#include <akgl/renderer.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
|
|
||||||
/* The startup order, in the order it has to happen. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *startup(void)
|
|
||||||
{
|
|
||||||
akgl_String *engine = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
/* 1. The three required fields. akgl_game_init refuses to run without them. */
|
|
||||||
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
|
||||||
"manual-demo", sizeof(akgl_game.name) - 1));
|
|
||||||
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
|
||||||
"1.0.0", sizeof(akgl_game.version) - 1));
|
|
||||||
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
|
||||||
"tech.starfort.manual-demo", sizeof(akgl_game.uri) - 1));
|
|
||||||
|
|
||||||
/* 2. Bring the library up: errors, pools, registries, SDL, audio, fonts. */
|
|
||||||
PASS(errctx, akgl_game_init());
|
|
||||||
|
|
||||||
/* 3. Configure, before anything reads the configuration. */
|
|
||||||
PASS(errctx, akgl_set_property("game.screenwidth", "800"));
|
|
||||||
PASS(errctx, akgl_set_property("game.screenheight", "600"));
|
|
||||||
PASS(errctx, akgl_set_property("physics.gravity.y", "400.0"));
|
|
||||||
|
|
||||||
/* 4. The two subsystems that read it. This is where the window appears. */
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
|
||||||
CATCH(errctx, akgl_string_initialize(engine, "arcade"));
|
|
||||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
|
||||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(engine));
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
|
|
||||||
/* 5. Load assets, then loop on akgl_game_update. */
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**The three required fields have no defaults.** `akgl_game.name`, `.version` and `.uri` are
|
|
||||||
each checked for a non-zero length and each raises `AKERR_NULLPOINTER` if empty. All three
|
|
||||||
are load-bearing: `.uri` becomes the window title, all three become SDL's app metadata, and
|
|
||||||
`.name`, `.uri` and `.version` are compared on savegame load. `.version` must be a valid
|
|
||||||
semver string, or `akgl_game_load` fails later rather than here.
|
|
||||||
|
|
||||||
**Steps 3 and 4 cannot be swapped.** `akgl_render_2d_init` and `akgl_physics_init_arcade`
|
|
||||||
each read their configuration once, at init, and never look again. Setting
|
|
||||||
`physics.gravity.y` after the factory call changes nothing. See
|
|
||||||
[Chapter 6](06-the-registry.md).
|
|
||||||
|
|
||||||
### What `akgl_game_init` does, in order
|
|
||||||
|
|
||||||
Verified against `src/game.c`. The order matters in three places, marked:
|
|
||||||
|
|
||||||
1. **`akgl_error_init()` — first, before anything that can raise.** Everything below reports
|
|
||||||
through `AKGL_ERR_*` codes, and a code raised before its name is registered prints as
|
|
||||||
"Unknown Error". See [Chapter 4](04-errors.md).
|
|
||||||
2. Stamp `akgl_game.libversion` from `AKGL_VERSION`.
|
|
||||||
3. Seed the frame clock: `gameStartTime`, `lastIterTime` and `lastFPSTime` all get
|
|
||||||
`SDL_GetTicksNS()`.
|
|
||||||
4. Install `akgl_game_lowfps` as `lowfpsfunc`.
|
|
||||||
5. Create the state mutex. Failure is `AKGL_ERR_SDL`.
|
|
||||||
6. **Take the state lock.** Everything from here to the end runs holding it.
|
|
||||||
7. Check `name`, `version` and `uri` are non-empty.
|
|
||||||
8. `akgl_heap_init()` — zero all eight pools.
|
|
||||||
9. The eight registry initializers: actor, sprite, spritesheet, character, font, music,
|
|
||||||
properties, actor-state-strings. **Note that `akgl_registry_init` is not called** —
|
|
||||||
`akgl_game_init` calls the eight individually. See [Chapter 6](06-the-registry.md).
|
|
||||||
10. `SDL_SetAppMetadata` from the three fields.
|
|
||||||
11. Zero all `AKGL_MAX_CONTROL_MAPS` control maps.
|
|
||||||
12. `SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD | SDL_INIT_AUDIO)`.
|
|
||||||
13. Feed the bundled controller database to `SDL_AddGamepadMapping`, one entry at a time.
|
|
||||||
A rejected entry is `AKGL_ERR_SDL` and aborts startup.
|
|
||||||
14. `akgl_controller_open_gamepads()`.
|
|
||||||
15. `MIX_Init()` and `MIX_CreateMixerDevice()` on the default playback device.
|
|
||||||
16. `TTF_Init()`.
|
|
||||||
17. Point `akgl_camera`, `akgl_renderer`, `akgl_physics` and `akgl_gamemap` at their default
|
|
||||||
storage.
|
|
||||||
18. Release the state lock.
|
|
||||||
|
|
||||||
**What it does not do: create the window, choose a physics backend, or load any
|
|
||||||
configuration.** Those three read properties, so they belong to the caller, after step 3
|
|
||||||
above.
|
|
||||||
|
|
||||||
**A failure anywhere from step 6 onward returns with the state mutex held.** `game.h` says
|
|
||||||
so. On a single-threaded program that is survivable — SDL mutexes are recursive, so the
|
|
||||||
same thread can take it again — but the mutex is never made available to another thread, so
|
|
||||||
anything else that calls `akgl_game_state_lock` will spend a second retrying and then fail.
|
|
||||||
A failed `akgl_game_init` is not something to retry; report it and exit.
|
|
||||||
|
|
||||||
## One frame
|
|
||||||
|
|
||||||
`akgl_game_update(opflags)` is the whole frame, and it is short enough to state exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_game_state_lock() <- retries, does not block
|
|
||||||
akgl_game_update_fps() <- counts the frame, maybe fires lowfpsfunc
|
|
||||||
for each slot in akgl_heap_actors:
|
|
||||||
skip if refcount == 0
|
|
||||||
skip if OP_LAYERMASK and actor->layer != opflags->layerid
|
|
||||||
OP_TILEMAPSCALE ? akgl_tilemap_scale_actor(...) : actor->scale = 1.0
|
|
||||||
actor->updatefunc(actor) <- exactly once per actor
|
|
||||||
akgl_physics->simulate(akgl_physics, NULL)
|
|
||||||
akgl_renderer->draw_world(akgl_renderer, NULL)
|
|
||||||
akgl_game_state_unlock()
|
|
||||||
```
|
|
||||||
|
|
||||||
Five things follow from that, and four of them are not obvious.
|
|
||||||
|
|
||||||
**The sweep walks the pool, not the registry.** It reads `akgl_heap_actors` directly and
|
|
||||||
skips slots whose `refcount` is 0. That is why an actor released mid-frame simply stops
|
|
||||||
being visited, and why the actor registry's contents do not affect the update pass at all.
|
|
||||||
|
|
||||||
**Each actor's `updatefunc` runs exactly once.** Until 0.5.0 this loop sat inside a walk
|
|
||||||
over `AKGL_TILEMAP_MAX_LAYERS` that never compared an actor's `layer` to the layer being
|
|
||||||
walked, so every actor updated sixteen times a frame — 70 µs of work to do 4.4 µs of it.
|
|
||||||
Updating an actor is not a per-layer operation. **Drawing is**, and
|
|
||||||
`akgl_render_2d_draw_world` walks the layers itself.
|
|
||||||
|
|
||||||
**`opflags` is not forwarded.** `simulate` and `draw_world` are both passed `NULL`, so they
|
|
||||||
use their own defaults regardless of what you asked for. Setting
|
|
||||||
`AKGL_ITERATOR_OP_LAYERMASK` restricts the *update* sweep and nothing else; the whole world
|
|
||||||
still draws.
|
|
||||||
|
|
||||||
**A `NULL` `opflags` means `AKGL_ITERATOR_OP_UPDATE` alone** — every live actor once, no
|
|
||||||
layer mask, and every actor's `scale` forced to 1.0 because `AKGL_ITERATOR_OP_TILEMAPSCALE`
|
|
||||||
is not set. If your sprites are rendering at the wrong size, that flag is the first thing to
|
|
||||||
check.
|
|
||||||
|
|
||||||
**A live actor's `updatefunc` is called without a `NULL` check.** A hand-built `akgl_Actor`
|
|
||||||
that never went through `akgl_actor_initialize` crashes here rather than raising.
|
|
||||||
|
|
||||||
### There is no frame pacing
|
|
||||||
|
|
||||||
`akgl_game_update` does not sleep, does not wait for vertical blank, and does not ask SDL
|
|
||||||
to. `akgl_render_2d_init` calls `SDL_CreateWindowAndRenderer` with no flags and never calls
|
|
||||||
`SDL_SetRenderVSync`, so **the loop runs as fast as the machine will let it** and burns a
|
|
||||||
core doing it.
|
|
||||||
|
|
||||||
That is a deliberate omission rather than an oversight — pacing policy belongs to the game —
|
|
||||||
but it means the pacing is yours to add. Either call `SDL_SetRenderVSync(renderer, 1)` on
|
|
||||||
`akgl_renderer->sdl_renderer` after `akgl_render_2d_init`, or measure the frame and
|
|
||||||
`SDL_DelayNS` the remainder yourself. Physics is unaffected either way:
|
|
||||||
`akgl_physics_simulate` computes its own `dt` from `SDL_GetTicksNS` and bounds it at
|
|
||||||
`physics.max_timestep`.
|
|
||||||
|
|
||||||
### `akgl_game.fps` reads 0 for the first second
|
|
||||||
|
|
||||||
`akgl_game_update_fps` recomputes `fps` only when a **full second** has elapsed since the
|
|
||||||
last recomputation, so it is a completed-second average and not an instantaneous figure. For
|
|
||||||
the first second of the process it is 0.
|
|
||||||
|
|
||||||
0 is below the low-FPS threshold of 30. **So `lowfpsfunc` fires on every single frame until
|
|
||||||
the first second is up** — several hundred `Low FPS! 0` lines from the default handler
|
|
||||||
before the game has drawn anything worth measuring. That is expected, not a symptom.
|
|
||||||
|
|
||||||
If you replace `lowfpsfunc` with something that sheds work, guard it on `fps > 0` or it will
|
|
||||||
shed work during startup, which is the worst moment for it. The hook is also what an
|
|
||||||
embedder has to know about: `akgl_game_update_fps` installs the default if it finds the
|
|
||||||
pointer `NULL`, because a host binding its own renderer with `akgl_render_2d_bind` never ran
|
|
||||||
`akgl_game_init` and used to crash here on frame one.
|
|
||||||
|
|
||||||
A caller running its own loop rather than `akgl_game_update` has to call
|
|
||||||
`akgl_game_update_fps` itself; nothing else does.
|
|
||||||
|
|
||||||
## The state lock
|
|
||||||
|
|
||||||
`akgl_game.state` is a single `int32_t` of application-defined flags. **The library never
|
|
||||||
reads it.** The mutex guarding it, `akgl_game.statelock`, is the only thread-safety
|
|
||||||
libakgl offers, and it protects the state flags — not the pools, not the registries, not
|
|
||||||
the renderer.
|
|
||||||
|
|
||||||
`akgl_game_state_lock` **polls; it does not block.** It calls `SDL_TryLockMutex` every
|
|
||||||
`AKGL_GAME_STATE_LOCK_RETRY_MS` (100 ms) until `AKGL_GAME_STATE_LOCK_BUDGET_MS` (1000 ms) is
|
|
||||||
spent, then raises `AKGL_ERR_SDL`. The point is that a deadlock reports an error you can act
|
|
||||||
on instead of hanging the process. `SDL_GetError()` is appended for whatever it is worth,
|
|
||||||
but after a failed `SDL_TryLockMutex` it is usually stale or empty — contention is reported
|
|
||||||
by the return value, not by an error string. **The status is the signal, not the text.**
|
|
||||||
|
|
||||||
Two behaviours to plan around:
|
|
||||||
|
|
||||||
**Every failure path in `akgl_game_update` returns holding the lock.** Every step from
|
|
||||||
`akgl_game_state_lock` onward propagates with `PASS`, which returns immediately, so the
|
|
||||||
`akgl_game_state_unlock` at the bottom is never reached. Because SDL mutexes are recursive
|
|
||||||
the next frame on the same thread still gets the lock, so a single-threaded game does not
|
|
||||||
deadlock — it accumulates one unmatched lock per failed frame, and the mutex is never
|
|
||||||
released to any other thread again. **Treat a failed `akgl_game_update` as terminal.** If
|
|
||||||
you genuinely must continue, unlock once yourself before the next frame — but only when you
|
|
||||||
know the failure came from *inside* the frame rather than from the lock acquisition itself,
|
|
||||||
because unlocking a mutex this thread does not hold is undefined behaviour in SDL.
|
|
||||||
|
|
||||||
**`akgl_game_state_lock` succeeds trivially before `akgl_game_init`.** `SDL_TryLockMutex`
|
|
||||||
returns true when passed `NULL`, and `akgl_game.statelock` is `NULL` until step 5 of
|
|
||||||
startup. It locks nothing and reports success.
|
|
||||||
|
|
||||||
`akgl_game_state_unlock` has no failure path and returns `NULL`. That includes the case
|
|
||||||
that matters — unlocking a mutex this thread does not hold — which is undefined behaviour in
|
|
||||||
SDL rather than an error here.
|
|
||||||
|
|
||||||
## Iterators are a work order
|
|
||||||
|
|
||||||
There is no iterator object, and **there is no `next()`**. Traversal is SDL's —
|
|
||||||
`SDL_EnumerateProperties` over a registry — and `akgl_Iterator` is the `userdata` carried
|
|
||||||
into each callback, saying *which* entries to touch and *what* to do to each one.
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/iterator.h
|
|
||||||
/** @brief Selects operations and an optional layer for actor traversal. */
|
|
||||||
typedef struct {
|
|
||||||
uint32_t flags; /**< Bitwise OR of the `AKGL_ITERATOR_OP_*` values below. */
|
|
||||||
uint8_t layerid; /**< Layer to restrict the sweep to. Read only when #AKGL_ITERATOR_OP_LAYERMASK is set. */
|
|
||||||
} akgl_Iterator;
|
|
||||||
|
|
||||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
|
||||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
|
||||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
|
||||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
|
||||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The operations are **independent bits, not an enum**: one sweep can update, scale and
|
|
||||||
render, and they run in that fixed order regardless of the order the bits were set in. Bits
|
|
||||||
5 through 31 are declared `AKGL_ITERATOR_OP_UNDEFINED_*` and are unused.
|
|
||||||
|
|
||||||
Passing `NULL` where an `akgl_Iterator *` is expected is fine at the top-level entry points
|
|
||||||
— `akgl_game_update`, `akgl_physics_simulate` and `akgl_render_2d_draw_world` each
|
|
||||||
substitute their own defaults — but it is `AKERR_NULLPOINTER` once inside a callback.
|
|
||||||
|
|
||||||
**`akgl_game_update` reads only two of the five bits**: `AKGL_ITERATOR_OP_LAYERMASK` and
|
|
||||||
`AKGL_ITERATOR_OP_TILEMAPSCALE`. It calls `updatefunc` unconditionally rather than checking
|
|
||||||
`AKGL_ITERATOR_OP_UPDATE`, because updating every live actor once is the whole job. The
|
|
||||||
callback `akgl_registry_iterate_actor` does check all of them, and that is the one the
|
|
||||||
render sweep goes through.
|
|
||||||
|
|
||||||
## The main loop
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/iterator.h>
|
|
||||||
|
|
||||||
/* A frame loop. A failed akgl_game_update is terminal: it returns holding the
|
|
||||||
* state lock, and FINISH_NORETURN is what turns that into a stack trace and a
|
|
||||||
* truthful exit status instead of a second frame on a poisoned mutex. */
|
|
||||||
int main(void)
|
|
||||||
{
|
|
||||||
akgl_Iterator work = {
|
|
||||||
.flags = (AKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_TILEMAPSCALE),
|
|
||||||
.layerid = 0
|
|
||||||
};
|
|
||||||
SDL_Event event;
|
|
||||||
bool running = true;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
ATTEMPT {
|
|
||||||
while ( running == true ) {
|
|
||||||
while ( SDL_PollEvent(&event) == true ) {
|
|
||||||
if ( event.type == SDL_EVENT_QUIT ) {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CATCH(errctx, akgl_game_update(&work));
|
|
||||||
}
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH_NORETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `CATCH` inside the `while` is safe **only because the loop is the entire body of the
|
|
||||||
`ATTEMPT`**. `CATCH` reports failure by `break`ing, and a `break` binds to the innermost
|
|
||||||
enclosing loop, so it leaves the `while` and falls into `CLEANUP`/`PROCESS`/`FINISH` — which
|
|
||||||
is what you want. Put anything after the loop and you have to account for reaching it after
|
|
||||||
a failure. `AGENTS.md` spells the rule out, and `scripts/check_error_protocol.py` cannot see
|
|
||||||
this one.
|
|
||||||
|
|
||||||
## There is no `akgl_game_shutdown`
|
|
||||||
|
|
||||||
**Teardown is entirely the caller's.** No function in this library undoes `akgl_game_init`.
|
|
||||||
What there is, in the order it has to run:
|
|
||||||
|
|
||||||
| Step | Call | Why the order |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | `akgl_text_unloadfont` for each font you loaded | Must precede `TTF_Quit`. `text.h` documents the trap: `SDL_Quit` destroying the engine out from under a live `TTF_Font` is not a clean unload |
|
|
||||||
| 2 | `akgl_heap_release_*` for anything you still hold | `akgl_heap_release_spritesheet` destroys `SDL_Texture`s and must run on the renderer's thread, before the renderer goes |
|
|
||||||
| 3 | `akgl_audio_shutdown()` | Puts the synthesizer voices back |
|
|
||||||
| 4 | `akgl_renderer->shutdown(akgl_renderer)` | `akgl_render_2d_shutdown` |
|
|
||||||
| 5 | `TTF_Quit()`, `MIX_Quit()`, `SDL_Quit()` | SDL's own |
|
|
||||||
|
|
||||||
A process that is exiting anyway can skip most of it — the OS reclaims the pools, and SDL
|
|
||||||
reclaims what it owns — but a program that tears a *level* down and builds another one has
|
|
||||||
to get steps 1 and 2 right or it exhausts the pools. `akgl_heap_init_actor` plus
|
|
||||||
`akgl_registry_init_actor` is the cheap version of that for actors only; see
|
|
||||||
[Chapter 5](05-the-heap.md).
|
|
||||||
|
|
||||||
## Savegames are partial
|
|
||||||
|
|
||||||
`akgl_game_save` and `akgl_game_load` exist and work, and **they do not yet save your game.**
|
|
||||||
Read this before building anything on them.
|
|
||||||
|
|
||||||
What `akgl_game_save` writes:
|
|
||||||
|
|
||||||
1. The `akgl_Game` struct verbatim — metadata, timing, FPS accounting and the state flags.
|
|
||||||
2. Four name tables — actors, sprites, spritesheets, characters — each mapping a registered
|
|
||||||
name to **the address the object had at save time**, each terminated by a zeroed name
|
|
||||||
field and a zeroed pointer.
|
|
||||||
|
|
||||||
What it does not write: **the objects themselves.** Not one actor, sprite, sheet or
|
|
||||||
character. The name tables are the mechanism that would let a loader reconnect pointers
|
|
||||||
between objects that will sit at different addresses next run; the objects those pointers
|
|
||||||
would point at are not in the file. `akgl_game_load` correspondingly rebuilds the four
|
|
||||||
old-address-to-current-object maps and stops there. `game.h` says so on both functions.
|
|
||||||
|
|
||||||
Three further things to know:
|
|
||||||
|
|
||||||
- **`akgl_game_load` refuses a save that does not match this build**, comparing the library
|
|
||||||
version, the game version, the game name and the game URI. Versions are compared by
|
|
||||||
**exact** semver equality, not compatibility, because the file is a raw memory image and
|
|
||||||
semver has no way to say "the layout did not move". A mismatch is `AKERR_API`.
|
|
||||||
- **A save containing any registered spritesheet cannot be read back.** The writer uses
|
|
||||||
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) for that table and the reader used
|
|
||||||
`AKGL_ACTOR_MAX_NAME_LENGTH` (128) for all four. That is fixed as of 0.5.0 (`TODO.md`,
|
|
||||||
"Known and still open" item 7) — and what catches it is the `require_at_eof` check, which
|
|
||||||
turns a field-width disagreement into `AKERR_IO` instead of a silent load with wrong maps.
|
|
||||||
A reader whose widths disagree finds a run of zeros mid-entry, stops early, and would
|
|
||||||
otherwise report success.
|
|
||||||
- **`akgl_game_load` leaks four `SDL_PropertiesID` per call.** The maps it builds are never
|
|
||||||
destroyed.
|
|
||||||
|
|
||||||
A write error part-way through the name tables **terminates the process** rather than
|
|
||||||
returning. The entries go through `SDL_EnumerateProperties`, whose callbacks cannot report
|
|
||||||
failure upward and end in `FINISH_NORETURN`; only the terminators are written through the
|
|
||||||
error-reporting path. See [Chapter 4](04-errors.md).
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
- [Chapter 4](04-errors.md) — the statuses everything here raises.
|
|
||||||
- [Chapter 6](06-the-registry.md) — the configuration step 3 above writes into.
|
|
||||||
- [Chapter 8](08-rendering.md) — `akgl_render_2d_init` versus `akgl_render_2d_bind`, and
|
|
||||||
what `draw_world` does with the layers.
|
|
||||||
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
|
|
||||||
pace.
|
|
||||||
- [Chapter 23](23-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# 08. Rendering
|
|
||||||
|
|
||||||
SDL3 already owns the renderer. `SDL_Renderer`, `SDL_Texture`, `SDL_FRect`, blend
|
|
||||||
modes and the batching behaviour behind `SDL_RenderPresent` are documented in the
|
|
||||||
[SDL3 wiki](https://wiki.libsdl.org/SDL3/CategoryRender), and this chapter does not
|
|
||||||
restate any of it.
|
|
||||||
|
|
||||||
What libakgl adds is three things, and only three:
|
|
||||||
|
|
||||||
1. **A backend struct** — a record of function pointers, so a renderer can be
|
|
||||||
replaced without a branch anywhere in the library.
|
|
||||||
2. **A frame contract** — `frame_start`, then any number of `draw_*` calls, then
|
|
||||||
`frame_end`.
|
|
||||||
3. **A scene walk** — `draw_world`, which interleaves tilemap layers with the
|
|
||||||
actors standing on them.
|
|
||||||
|
|
||||||
Per-function reference is generated from the headers by Doxygen and is CI-gated;
|
|
||||||
this chapter teaches the shape and links there for signatures.
|
|
||||||
|
|
||||||
## The backend is a record of function pointers
|
|
||||||
|
|
||||||
There is no renderer base class, no vtable pointer, and no dynamic dispatch. There
|
|
||||||
is a struct with an `SDL_Renderer *` and six function pointers on it:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/renderer.h
|
|
||||||
typedef struct akgl_RenderBackend {
|
|
||||||
SDL_Renderer *sdl_renderer;
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self);
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self);
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self);
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self);
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags);
|
|
||||||
} akgl_RenderBackend;
|
|
||||||
```
|
|
||||||
|
|
||||||
A *backend* is therefore an initializer that fills those six slots in. One is
|
|
||||||
shipped — the 2D SDL one, whose entry points are the `akgl_render_2d_*` functions —
|
|
||||||
and a second renderer would be a second initializer pointing the same six slots at
|
|
||||||
different functions. Nothing in the library tests "which renderer is this".
|
|
||||||
|
|
||||||
**Call through the pointers, not the names.** `akgl_render_2d_frame_start(b)` and
|
|
||||||
`b->frame_start(b)` do the same thing today and stop doing the same thing the
|
|
||||||
moment anybody swaps a backend in. Every call site inside libakgl goes through the
|
|
||||||
pointer, and so should yours:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/renderer.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *draw_one_frame(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
||||||
PASS(errctx, akgl_renderer->draw_world(akgl_renderer, NULL));
|
|
||||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_renderer` is the global the rest of the library draws through. It is a
|
|
||||||
pointer into `akgl_default_renderer`, set by `akgl_game_init`; see
|
|
||||||
[Chapter 7](07-the-game-and-the-frame.md). Reassigning `akgl_renderer` is how a
|
|
||||||
program installs its own backend — there is exactly one world, so there is exactly
|
|
||||||
one of these.
|
|
||||||
|
|
||||||
Every entry point returns `akerr_ErrorContext AKERR_NOIGNORE *`. Statuses and what
|
|
||||||
they mean are [Chapter 4](04-errors.md); the protocol itself is libakerror's and is
|
|
||||||
documented in `deps/libakerror`.
|
|
||||||
|
|
||||||
## The frame contract
|
|
||||||
|
|
||||||
```text
|
|
||||||
frame_start(self) clear the target to opaque black
|
|
||||||
|
|
|
||||||
+--> draw_world(self, opflags) the tilemap and its actors
|
|
||||||
+--> draw_texture(self, ...) one blit
|
|
||||||
+--> akgl_draw_* (chapter 09) lines, boxes, circles, fills
|
|
||||||
|
|
|
||||||
frame_end(self) present
|
|
||||||
```
|
|
||||||
|
|
||||||
`frame_start` sets the draw colour to opaque black and calls `SDL_RenderClear`
|
|
||||||
itself. **You do not need to clear again.** The FAQ half of the old `README.md`
|
|
||||||
showed this:
|
|
||||||
|
|
||||||
```c norun
|
|
||||||
PASS(e, akgl_renderer->frame_start(akgl_renderer));
|
|
||||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
|
||||||
```
|
|
||||||
|
|
||||||
The second line is redundant — it clears an already-cleared target, unchecked, and
|
|
||||||
at whatever draw colour was left behind. Drop it. (`norun` because it is quoted as
|
|
||||||
an example of what *not* to write; it compiles, and that is the problem.)
|
|
||||||
|
|
||||||
Both check `self` and then `self->sdl_renderer` before touching either, so a `NULL`
|
|
||||||
backend and a backend that was bound but never given a renderer both report
|
|
||||||
`AKERR_NULLPOINTER` rather than crashing. `renderer.h` says the opposite — its
|
|
||||||
`@param self` for `frame_start`, `frame_end` and `draw_texture` reads "dereferenced
|
|
||||||
*before* it is checked -- a `NULL` @p self is a crash, not an error". Read
|
|
||||||
`src/renderer.c`: the first statement in each is a `FAIL_ZERO_RETURN` on `self`.
|
|
||||||
|
|
||||||
## The camera
|
|
||||||
|
|
||||||
`akgl_camera` is an `SDL_FRect *` in **map coordinates**: `x`/`y` are the top-left
|
|
||||||
corner of the view into the world, `w`/`h` its size. It is a plain pointer into
|
|
||||||
`akgl_default_camera`, and everything that draws world content subtracts it.
|
|
||||||
|
|
||||||
`akgl_render_2d_init` points it at the full screen rectangle at startup. Scrolling
|
|
||||||
is just writing to it:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/* Centre the view on an actor, and do not scroll past the top-left of the map. */
|
|
||||||
akerr_ErrorContext *camera_follow(akgl_Actor *obj)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
|
|
||||||
akgl_camera->x = obj->x - (akgl_camera->w / 2.0f);
|
|
||||||
akgl_camera->y = obj->y - (akgl_camera->h / 2.0f);
|
|
||||||
if ( akgl_camera->x < 0.0f ) {
|
|
||||||
akgl_camera->x = 0.0f;
|
|
||||||
}
|
|
||||||
if ( akgl_camera->y < 0.0f ) {
|
|
||||||
akgl_camera->y = 0.0f;
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no camera object and no camera API — a rectangle is the whole model. A
|
|
||||||
game that wants two views draws twice with the pointer moved between them.
|
|
||||||
|
|
||||||
## draw_world walks sixteen layers
|
|
||||||
|
|
||||||
`draw_world` is the one call that draws a scene rather than a thing. For each
|
|
||||||
layer index `i` from 0 to `AKGL_TILEMAP_MAX_LAYERS` (16), it:
|
|
||||||
|
|
||||||
1. draws layer `i` of the global `akgl_gamemap` through `akgl_camera`, **if the map
|
|
||||||
has that many layers**; then
|
|
||||||
2. sweeps all `AKGL_MAX_HEAP_ACTOR` (64) actor pool slots and calls `renderfunc` on
|
|
||||||
every live actor whose `layer` equals `i`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
layer 0 tilemap layer 0 ....... actors with layer == 0
|
|
||||||
layer 1 tilemap layer 1 ....... actors with layer == 1
|
|
||||||
layer 2 (map has only 2) actors with layer == 2
|
|
||||||
... ...
|
|
||||||
layer 15 -- actors with layer == 15
|
|
||||||
```
|
|
||||||
|
|
||||||
That interleaving is the point: it is what puts an actor in front of the ground it
|
|
||||||
stands on and behind the archway it walks under. An actor's `layer` field is set
|
|
||||||
from the object layer it was placed in — see [Chapter 12](12-actors.md) and
|
|
||||||
[Chapter 13](13-tilemaps.md).
|
|
||||||
|
|
||||||
The map layer is skipped once `i` reaches `akgl_gamemap->numlayers`, but the actor
|
|
||||||
sweep is not: **an actor on layer 9 of a two-layer map still draws.** It just draws
|
|
||||||
over bare background.
|
|
||||||
|
|
||||||
`draw_world` reads `akgl_gamemap`, `akgl_camera` and `akgl_heap_actors` directly
|
|
||||||
rather than taking them as arguments. There is one world.
|
|
||||||
|
|
||||||
### opflags is accepted and ignored
|
|
||||||
|
|
||||||
The `akgl_Iterator *opflags` argument is part of the backend signature and is
|
|
||||||
currently **ignored entirely**. `NULL` substitutes a zeroed iterator, and neither
|
|
||||||
that nor a set of flags you built yourself reaches anything: `draw_world` sweeps
|
|
||||||
the actor pool itself instead of going through `akgl_registry_iterate_actor`, so no
|
|
||||||
`AKGL_ITERATOR_OP_*` bit — including `AKGL_ITERATOR_OP_LAYERMASK` — has any effect
|
|
||||||
here. Pass `NULL`. When layer masking is wired up, the parameter is where it will
|
|
||||||
land.
|
|
||||||
|
|
||||||
### Two things draw_world does not check
|
|
||||||
|
|
||||||
- **`akgl_gamemap` is dereferenced unguarded.** Calling `draw_world` before
|
|
||||||
`akgl_tilemap_load` is a crash, not an error context.
|
|
||||||
- **A live actor's `renderfunc` is called unguarded.** A hand-built actor that was
|
|
||||||
never run through `akgl_actor_initialize` has a `NULL` there. Always initialize
|
|
||||||
actors through the library.
|
|
||||||
|
|
||||||
The first failure aborts the frame and propagates unchanged; there is no
|
|
||||||
draw-what-you-can behaviour.
|
|
||||||
|
|
||||||
> **Known defect (`TODO.md`, "Performance", item 6).** The layer loop always runs
|
|
||||||
> all 16 iterations and rescans all 64 actor slots on each — 1024 refcount checks
|
|
||||||
> per frame for a one-layer map. Invisible at 60 fps under the software renderer,
|
|
||||||
> measurable on a 2 ms GPU frame. Bounding the walk by `numlayers` and building
|
|
||||||
> per-layer actor lists in one pool pass is the recorded fix.
|
|
||||||
|
|
||||||
## The embedding seam: akgl_render_2d_bind vs akgl_render_2d_init
|
|
||||||
|
|
||||||
This is a first-class use case, not a footnote. Creating a window and populating a
|
|
||||||
vtable are two separable jobs, and libakgl separates them.
|
|
||||||
|
|
||||||
| | `akgl_render_2d_init` | `akgl_render_2d_bind` |
|
|
||||||
|---|---|---|
|
|
||||||
| Reads configuration properties | yes — `game.screenwidth`, `game.screenheight` | no |
|
|
||||||
| Creates a window and `SDL_Renderer` | yes | **no** |
|
|
||||||
| Touches `self->sdl_renderer` | overwrites it | **never touches it** |
|
|
||||||
| Points `akgl_camera` at the screen | yes | no |
|
|
||||||
| Installs the six function pointers | yes, by calling `_bind` | yes |
|
|
||||||
| Needs the registry initialized first | yes | no |
|
|
||||||
|
|
||||||
`akgl_render_2d_init` is the ordinary path for a program that owns its whole
|
|
||||||
process. It reads `game.screenwidth` and `game.screenheight` from the property
|
|
||||||
registry — both defaulting to the string `"0"`, which asks SDL for a zero-sized
|
|
||||||
window — creates the window and renderer, sets `akgl_camera` to the full screen
|
|
||||||
rectangle, and then calls `akgl_render_2d_bind` to fill in the vtable. Because it
|
|
||||||
reads properties, `akgl_registry_init_properties` and your property writes have to
|
|
||||||
happen first; see [Chapter 6](06-the-registry.md).
|
|
||||||
|
|
||||||
**The window title is `akgl_game.uri`.** Not `akgl_game.name` — the reverse-DNS
|
|
||||||
identifier is what `SDL_CreateWindowAndRenderer` is handed. If your title bar reads
|
|
||||||
`tech.starfort.mygame`, that is why.
|
|
||||||
|
|
||||||
`akgl_render_2d_bind` is the other half on its own. It writes six pointers and
|
|
||||||
returns. It deliberately does not touch `sdl_renderer`, which is exactly what makes
|
|
||||||
it usable by a host that already has one — an embedded interpreter, a level editor,
|
|
||||||
an application with its own window that wants libakgl to draw inside it. This is
|
|
||||||
what the sibling `akbasic` consumer uses.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/renderer.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A host that already owns its window and SDL_Renderer. It never calls
|
|
||||||
* akgl_render_2d_init, so libakgl never creates a second window.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext *host_bind_renderer(SDL_Renderer *mine, int w, int h)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, mine, AKERR_NULLPOINTER, "mine");
|
|
||||||
|
|
||||||
/* _bind never writes sdl_renderer, so put yours there first. */
|
|
||||||
akgl_default_renderer.sdl_renderer = mine;
|
|
||||||
PASS(errctx, akgl_render_2d_bind(&akgl_default_renderer));
|
|
||||||
|
|
||||||
/* Everything else in the library draws through the global. */
|
|
||||||
akgl_renderer = &akgl_default_renderer;
|
|
||||||
|
|
||||||
/* _bind does not set the camera; _init would have. */
|
|
||||||
akgl_camera = &akgl_default_camera;
|
|
||||||
akgl_camera->x = 0.0f;
|
|
||||||
akgl_camera->y = 0.0f;
|
|
||||||
akgl_camera->w = (float)w;
|
|
||||||
akgl_camera->h = (float)h;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bind the global, not a backend of your own
|
|
||||||
|
|
||||||
This is the trap in the seam, and it is worth stating flatly:
|
|
||||||
|
|
||||||
**`draw_world` hands `self` to the tilemap and to each actor's `renderfunc`, but
|
|
||||||
neither of them uses it.** `akgl_actor_render` and `akgl_tilemap_draw` both reach
|
|
||||||
straight for the global `akgl_renderer`. So does `akgl_spritesheet_initialize`, and
|
|
||||||
so does every texture load in the tilemap loader.
|
|
||||||
|
|
||||||
A host that binds a *private* `akgl_RenderBackend` and calls
|
|
||||||
`mine.draw_world(&mine, NULL)` gets its tilemap and its actors drawn through
|
|
||||||
whatever `akgl_renderer` points at — which, if `akgl_game_init` ran, is
|
|
||||||
`akgl_default_renderer` with a `NULL` `sdl_renderer`. The failure is an
|
|
||||||
`AKERR_NULLPOINTER` from deep inside a texture load, and it reads like a missing
|
|
||||||
asset.
|
|
||||||
|
|
||||||
Set `akgl_renderer` to the backend you bound. The example above does.
|
|
||||||
|
|
||||||
A backend that has been bound but never given an `SDL_Renderer` refuses cleanly
|
|
||||||
rather than crashing: every entry point checks `self->sdl_renderer` and reports
|
|
||||||
`AKERR_NULLPOINTER`. That is the designed-for state and is what makes `_bind` safe
|
|
||||||
to call before you have a renderer.
|
|
||||||
|
|
||||||
> **A header claim that is no longer true.** `renderer.h` ends
|
|
||||||
> `akgl_render_2d_init`'s documentation with a `@note` saying the two pooled
|
|
||||||
> strings holding the dimensions "are only released on the success path, so each
|
|
||||||
> failed initialization leaks two string slots". They are released in a `CLEANUP`
|
|
||||||
> block that runs on both paths, and `src/renderer.c` carries a comment saying
|
|
||||||
> that is exactly why it was moved there. **There is no leak on the current
|
|
||||||
> code.** Correcting the header comment is a separate commit; this chapter
|
|
||||||
> documents what the code does.
|
|
||||||
|
|
||||||
## What the 2D backend refuses
|
|
||||||
|
|
||||||
**`draw_mesh` always fails.** Every call raises `AKERR_API` with the message "Not
|
|
||||||
implemented". The hook exists so a 3D backend has a slot to fill. It is not a stub
|
|
||||||
that quietly does nothing — a caller that reaches it finds out on the first call,
|
|
||||||
which is the intent:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/renderer.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *try_the_mesh_hook(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_renderer->draw_mesh(akgl_renderer));
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} HANDLE(errctx, AKERR_API) {
|
|
||||||
/* The 2D backend has no geometry path. Draw it as textures instead. */
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`akgl_render_2d_shutdown` is a documented no-op.** It validates `self` and
|
|
||||||
succeeds. The window and `SDL_Renderer` are still SDL's and are reclaimed by
|
|
||||||
`SDL_Quit`, so there is nothing here to release yet. Call it anyway — a backend
|
|
||||||
that acquires anything will grow a failure path, and the hook is where it will go.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 09](09-drawing.md) — the immediate-mode primitives that draw through
|
|
||||||
the same backend.
|
|
||||||
- [Chapter 12](12-actors.md) — `renderfunc`, `layer`, and what `draw_world`
|
|
||||||
actually calls.
|
|
||||||
- [Chapter 13](13-tilemaps.md) — layers, and what `akgl_tilemap_draw` does with
|
|
||||||
the camera.
|
|
||||||
- [Chapter 07](07-the-game-and-the-frame.md) — where `frame_start` and `frame_end`
|
|
||||||
sit inside `akgl_game_update`.
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# 09. Drawing
|
|
||||||
|
|
||||||
`draw.h` is immediate-mode plotting: put this on the screen *now*, as opposed to
|
|
||||||
add this to the scene. It sits alongside the actor and tilemap rendering in
|
|
||||||
[Chapter 08](08-rendering.md) rather than replacing it — a game can draw a world
|
|
||||||
through `draw_world` and then scribble a debug rectangle over it in the same frame.
|
|
||||||
|
|
||||||
The shape comes from a BASIC-style graphics vocabulary. `DRAW`, `BOX`, `CIRCLE`,
|
|
||||||
`PAINT`, `SSHAPE` and `GSHAPE` all map onto one of these, which is why the set is
|
|
||||||
what it is and why there is a flood fill in a game library at all.
|
|
||||||
|
|
||||||
| Function | Draws | Notable |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_draw_point` | one pixel | clipping is SDL's, not reported |
|
|
||||||
| `akgl_draw_line` | a line, endpoints included | coincident endpoints draw one pixel |
|
|
||||||
| `akgl_draw_rect` | an outline | zero or negative w/h draws nothing, silently |
|
|
||||||
| `akgl_draw_filled_rect` | a solid box, outline included | same |
|
|
||||||
| `akgl_draw_circle` | an outline | midpoint algorithm, integer, no AA |
|
|
||||||
| `akgl_draw_flood_fill` | a connected region | CPU-side, bounded, **not reentrant** |
|
|
||||||
| `akgl_draw_copy_region` | *reads* a rectangle into a surface | allocates when you ask it to |
|
|
||||||
| `akgl_draw_paste_region` | a saved surface back onto the target | replaces, does not blend |
|
|
||||||
| `akgl_draw_background` | an 8x8 grey checkerboard | diagnostic, not a general primitive |
|
|
||||||
|
|
||||||
Signatures are in the generated Doxygen. Statuses are [Chapter 04](04-errors.md).
|
|
||||||
|
|
||||||
**These are ordinary functions, not backend methods.** Unlike `frame_start` and
|
|
||||||
`draw_world`, they are not slots on `akgl_RenderBackend` — they take an
|
|
||||||
`akgl_RenderBackend *` as their first argument and reach through its
|
|
||||||
`sdl_renderer`. Pass `akgl_renderer` unless you are deliberately drawing somewhere
|
|
||||||
else. Every one of them validates both `self` and `self->sdl_renderer` before
|
|
||||||
touching either.
|
|
||||||
|
|
||||||
## Colour is an argument, never a global
|
|
||||||
|
|
||||||
**Every entry point takes its colour as a parameter, and none of them reads or
|
|
||||||
writes a current-colour global.**
|
|
||||||
|
|
||||||
The reasoning is worth stating because it looks like extra typing. A caller that
|
|
||||||
has a notion of a current colour — a BASIC `COLOR` statement, a UI theme, a palette
|
|
||||||
index — *already owns that state*. Giving the library a second copy creates two
|
|
||||||
places for the answer to live and one opportunity for them to disagree. So there is
|
|
||||||
no `akgl_draw_set_color`, and there never will be.
|
|
||||||
|
|
||||||
The other half of the rule is the one that matters at a call site: **the renderer's
|
|
||||||
own draw colour is saved before each call and restored after it.** Drawing a red
|
|
||||||
line does not leave the renderer red. In particular, it does not change what the
|
|
||||||
next `SDL_RenderClear` — or the next `frame_start`, which clears — paints.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/draw.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/* Two rectangles, two colours, no state carried between them. */
|
|
||||||
akerr_ErrorContext *draw_health_bar(float32_t x, float32_t y, float32_t frac)
|
|
||||||
{
|
|
||||||
SDL_Color back = { 0x20, 0x20, 0x20, SDL_ALPHA_OPAQUE };
|
|
||||||
SDL_Color fill = { 0xd0, 0x30, 0x30, SDL_ALPHA_OPAQUE };
|
|
||||||
SDL_FRect rect;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
rect.x = x;
|
|
||||||
rect.y = y;
|
|
||||||
rect.w = 100.0f;
|
|
||||||
rect.h = 8.0f;
|
|
||||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, back));
|
|
||||||
|
|
||||||
rect.w = 100.0f * frac;
|
|
||||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, fill));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Alpha is in the colour and blending follows the renderer's **current blend mode**,
|
|
||||||
which these functions do not change. If you want alpha to blend rather than
|
|
||||||
overwrite, set the blend mode yourself with
|
|
||||||
[`SDL_SetRenderDrawBlendMode`](https://wiki.libsdl.org/SDL3/SDL_SetRenderDrawBlendMode).
|
|
||||||
That one *is* renderer state libakgl leaves alone.
|
|
||||||
|
|
||||||
Restoring the colour happens in a `CLEANUP` block under `IGNORE()`. A failure to
|
|
||||||
restore is logged rather than propagated, on the grounds that it must not mask the
|
|
||||||
failure the cleanup is unwinding from.
|
|
||||||
|
|
||||||
## What the primitives look like
|
|
||||||
|
|
||||||
Every primitive in one frame, on a 320x240 surface. The picture below is generated by
|
|
||||||
running exactly this listing — see `MAINTENANCE.md` if you are editing it.
|
|
||||||
|
|
||||||
```c screenshot=primitives
|
|
||||||
SDL_Color red = { 0xd0, 0x30, 0x30, 0xff };
|
|
||||||
SDL_Color green = { 0x30, 0xc0, 0x50, 0xff };
|
|
||||||
SDL_Color blue = { 0x40, 0x70, 0xe0, 0xff };
|
|
||||||
SDL_Color yellow = { 0xe0, 0xc0, 0x40, 0xff };
|
|
||||||
SDL_FRect filled = { 20.0f, 20.0f, 80.0f, 50.0f };
|
|
||||||
SDL_FRect framed = { 120.0f, 20.0f, 80.0f, 50.0f };
|
|
||||||
|
|
||||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &filled, blue));
|
|
||||||
PASS(errctx, akgl_draw_rect(akgl_renderer, &framed, green));
|
|
||||||
PASS(errctx, akgl_draw_circle(akgl_renderer, 260.0f, 45.0f, 25.0f, yellow));
|
|
||||||
PASS(errctx, akgl_draw_line(akgl_renderer, 20.0f, 100.0f, 300.0f, 100.0f, red));
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A row of points, to show that a point is a pixel and not a dot of some
|
|
||||||
* nominal size. At this scale they read as a dashed line.
|
|
||||||
*/
|
|
||||||
for ( float32_t x = 20.0f; x < 300.0f; x += 8.0f ) {
|
|
||||||
PASS(errctx, akgl_draw_point(akgl_renderer, x, 115.0f, yellow));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The filled rectangle is `akgl_draw_filled_rect`; the outlined one beside it is
|
|
||||||
`akgl_draw_rect`. Neither left the renderer blue or green — the line drawn afterwards is
|
|
||||||
red because it asked to be, not because it inherited anything.
|
|
||||||
|
|
||||||
## Circles are integer midpoint circles
|
|
||||||
|
|
||||||
SDL3 has no circle primitive, so `akgl_draw_circle` plots one: the midpoint circle
|
|
||||||
algorithm, integer arithmetic throughout, eight-way symmetry — one computed point
|
|
||||||
in the second octant gives the other seven by reflection.
|
|
||||||
|
|
||||||
Three consequences you will see on screen:
|
|
||||||
|
|
||||||
- **The centre and the radius are rounded to whole pixels** (`SDL_lroundf`). A
|
|
||||||
circle at `x = 10.5` is a circle at `x = 11`. There is no sub-pixel placement.
|
|
||||||
- **The outline is one pixel wide and is not anti-aliased.** There is no
|
|
||||||
thickness parameter and no smoothing. If you want a thick or smooth circle, draw
|
|
||||||
concentric ones or blit a texture.
|
|
||||||
- **There is no filled circle.** Only the outline. A filled disc is a flood fill of
|
|
||||||
the interior, or a texture.
|
|
||||||
|
|
||||||
A radius of exactly 0 draws the centre pixel and nothing else. A negative radius is
|
|
||||||
`AKERR_OUTOFBOUNDS` and the message reports it.
|
|
||||||
|
|
||||||
A failure inside the plotting loop is recorded in a flag and reported once at the
|
|
||||||
end rather than aborting mid-circle — a `CATCH` there would `break` the loop rather
|
|
||||||
than leave the function, the hazard `AGENTS.md` describes. **So an
|
|
||||||
`AKGL_ERR_SDL` from `akgl_draw_circle` may leave a partial arc already on the
|
|
||||||
target.** The same is true of `akgl_draw_background`.
|
|
||||||
|
|
||||||
## Flood fill
|
|
||||||
|
|
||||||
`akgl_draw_flood_fill` is the odd one out, and it is worth understanding why before
|
|
||||||
using it. Every other primitive here is a command queued to the GPU. This one
|
|
||||||
cannot be: **the region it fills is defined by what is already on the screen**, so
|
|
||||||
it has to read the render target back, walk the region on the CPU, and blit the
|
|
||||||
result over the area it touched.
|
|
||||||
|
|
||||||
```text
|
|
||||||
SDL_RenderReadPixels(target) the whole target comes back
|
|
||||||
|
|
|
||||||
SDL_ConvertSurface(RGBA32) fixed 32-bit layout, so the walk
|
|
||||||
| can compare and write whole words
|
|
||||||
flood_region() scanline fill over a bounded span
|
|
||||||
| stack; records a dirty bounding box
|
|
||||||
SDL_RenderTexture(dirty, dirty) only what changed goes back
|
|
||||||
```
|
|
||||||
|
|
||||||
### What counts as the region
|
|
||||||
|
|
||||||
**Four-connected**: the fill spreads up, down, left and right, never diagonally. A
|
|
||||||
region joined only at a corner is two regions.
|
|
||||||
|
|
||||||
**The boundary is an exact colour comparison.** Any pixel whose packed RGBA value
|
|
||||||
differs from the seed's, by any amount, is a boundary. There is no tolerance
|
|
||||||
parameter.
|
|
||||||
|
|
||||||
That second one is the practical trap. **An anti-aliased edge stops the fill at its
|
|
||||||
first blended pixel and leaves a fringe** — you get the flat interior and a halo of
|
|
||||||
untouched blend pixels around it. Text rendered through SDL3_ttf, a scaled sprite,
|
|
||||||
and anything drawn with smoothing all have such edges. Flood fill wants hard-edged
|
|
||||||
art.
|
|
||||||
|
|
||||||
Filling a region that is already the requested colour is a **no-op that succeeds**,
|
|
||||||
not an error: walking it would compare filled pixels against themselves and find
|
|
||||||
nothing, so the function says so up front. A seed outside the render target is
|
|
||||||
`AKERR_OUTOFBOUNDS`, and the message reports both the seed and the target size.
|
|
||||||
|
|
||||||
### The span stack, and what it costs you
|
|
||||||
|
|
||||||
The fill keeps a fixed stack of horizontal runs still to be examined rather than
|
|
||||||
recursing per pixel:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/draw.h
|
|
||||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
|
||||||
```
|
|
||||||
|
|
||||||
At 4096 spans that array is 48 KB, which does not belong on the stack of a function
|
|
||||||
a game may call every frame — so it is **file scope**. Two consequences, and both
|
|
||||||
are hard rules rather than cautions:
|
|
||||||
|
|
||||||
- **`akgl_draw_flood_fill` is not reentrant.** Do not call it from inside a
|
|
||||||
callback that a flood fill can reach. There is one span stack for the process.
|
|
||||||
- **It is not thread-safe.** Nothing that touches an `SDL_Renderer` is, so this is
|
|
||||||
not a new restriction — but the span stack means it stays unsafe even against a
|
|
||||||
second renderer on a second thread.
|
|
||||||
|
|
||||||
An ordinary convex or moderately concave shape needs a few dozen pending spans. A
|
|
||||||
region complicated enough to need more than 4096 at once reports
|
|
||||||
`AKERR_OUTOFBOUNDS` — and **leaves the region partially filled**. There is no way
|
|
||||||
to unwind a partial fill short of keeping a copy of the whole surface, and you
|
|
||||||
asked for a bounded operation. Treat that status as "redraw the area", not as
|
|
||||||
"retry".
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/draw.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *paint_region(int x, int y, SDL_Color color)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_draw_flood_fill(akgl_renderer, x, y, color));
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
|
|
||||||
/*
|
|
||||||
* Either the seed was off-target, or the region needed more than
|
|
||||||
* AKGL_DRAW_MAX_FLOOD_SPANS pending spans -- in which case it is
|
|
||||||
* already PARTIALLY filled. Redraw the area; do not retry the fill.
|
|
||||||
*/
|
|
||||||
LOG_ERROR(errctx);
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The colour is **written over** the region rather than blended, since the pixels
|
|
||||||
going back are the ones just read out of it.
|
|
||||||
|
|
||||||
## Saving and restoring a region
|
|
||||||
|
|
||||||
`akgl_draw_copy_region` and `akgl_draw_paste_region` are `SSHAPE` and `GSHAPE`: read
|
|
||||||
a rectangle of the render target into an `SDL_Surface`, and draw one back.
|
|
||||||
|
|
||||||
### copy_region allocates only when you ask it to
|
|
||||||
|
|
||||||
`dest` is an `SDL_Surface **`, and **`*dest` must be initialized before the call**.
|
|
||||||
Its value selects between two behaviours:
|
|
||||||
|
|
||||||
| `*dest` on entry | What happens | Who owns the surface afterwards |
|
|
||||||
|---|---|---|
|
|
||||||
| `NULL` | a surface is allocated and written back through `dest` | **you do** — release it with `SDL_DestroySurface` |
|
|
||||||
| a surface of *exactly* `src`'s dimensions | the pixels are copied into it | you already did |
|
|
||||||
| a surface of any other size | `AKERR_OUTOFBOUNDS`, both sets of dimensions in the message | unchanged |
|
|
||||||
|
|
||||||
The reuse path exists so a caller saving the same region every frame does not churn
|
|
||||||
allocations. Note that `akgl_draw_copy_region` is one of the few places in libakgl
|
|
||||||
that hands you memory you have to free yourself — everything else comes from the
|
|
||||||
pools in [Chapter 05](05-the-heap.md).
|
|
||||||
|
|
||||||
**`src` must fit entirely inside the render target.** This is checked and refused
|
|
||||||
with `AKERR_OUTOFBOUNDS` rather than clipped, deliberately: SDL would clip the read
|
|
||||||
and hand back a *smaller* surface than was asked for, and a caller pasting it back
|
|
||||||
would not notice until the art was visibly wrong. A rectangle with no area is
|
|
||||||
refused too.
|
|
||||||
|
|
||||||
### paste_region replaces, it does not blend
|
|
||||||
|
|
||||||
`akgl_draw_paste_region` sets `SDL_BLENDMODE_NONE` on the texture it uploads, so
|
|
||||||
the saved pixels **overwrite** what is on the target. That is `GSHAPE`'s default
|
|
||||||
behaviour and it is the one that makes a save/restore pair actually restore: a
|
|
||||||
saved region's transparent pixels come back as transparent, rather than letting
|
|
||||||
whatever is underneath show through them.
|
|
||||||
|
|
||||||
There is no scaling — the surface is drawn at its own size — and the surface is not
|
|
||||||
consumed, so it may be pasted as many times as you like. Parts falling outside the
|
|
||||||
target are clipped by SDL and not reported.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/draw.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/* SSHAPE then GSHAPE: save a 32x32 patch, draw over it, put it back. */
|
|
||||||
akerr_ErrorContext *blink_cursor(int x, int y)
|
|
||||||
{
|
|
||||||
SDL_Surface *saved = NULL;
|
|
||||||
SDL_Rect box;
|
|
||||||
SDL_FRect cursor;
|
|
||||||
SDL_Color white = { 0xff, 0xff, 0xff, SDL_ALPHA_OPAQUE };
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
box.x = x;
|
|
||||||
box.y = y;
|
|
||||||
box.w = 32;
|
|
||||||
box.h = 32;
|
|
||||||
cursor.x = (float32_t)x;
|
|
||||||
cursor.y = (float32_t)y;
|
|
||||||
cursor.w = 32.0f;
|
|
||||||
cursor.h = 32.0f;
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
/* saved is NULL, so copy_region allocates it and we own the result. */
|
|
||||||
CATCH(errctx, akgl_draw_copy_region(akgl_renderer, &box, &saved));
|
|
||||||
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &cursor, white));
|
|
||||||
CATCH(errctx, akgl_draw_paste_region(akgl_renderer, saved, cursor.x, cursor.y));
|
|
||||||
} CLEANUP {
|
|
||||||
if ( saved != NULL ) {
|
|
||||||
SDL_DestroySurface(saved);
|
|
||||||
saved = NULL;
|
|
||||||
}
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## The checkerboard backdrop
|
|
||||||
|
|
||||||
`akgl_draw_background` paints an 8x8 grey checkerboard — the pattern an image
|
|
||||||
editor uses to show transparency. It is a **diagnostic backdrop rather than a
|
|
||||||
general primitive**: `charviewer` uses it so a sprite's transparent pixels are
|
|
||||||
visible instead of blending into black.
|
|
||||||
|
|
||||||
It always starts at the origin. `w` and `h` are the extent to cover, not a
|
|
||||||
rectangle, and both round *up* to whole 8-pixel cells — a height of 12 paints 16. A
|
|
||||||
zero or negative extent paints nothing and is not reported.
|
|
||||||
|
|
||||||
Until 0.5.0 this was the one function in the library outside the error protocol: it
|
|
||||||
returned `void`, drew through the global `akgl_renderer` without checking it, and
|
|
||||||
left the renderer's draw colour changed. It takes a backend now and restores the
|
|
||||||
colour it found, which is also what makes it testable without a world.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 08](08-rendering.md) — the backend these all take, and the frame the
|
|
||||||
calls sit inside.
|
|
||||||
- [Chapter 17](17-text-and-fonts.md) — text, which is textures rather than
|
|
||||||
primitives.
|
|
||||||
- [Chapter 05](05-the-heap.md) — the pools, and why `copy_region` returning owned
|
|
||||||
memory is the exception rather than the rule.
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
# 10. Spritesheets and sprites
|
|
||||||
|
|
||||||
Two objects, and the split between them is the whole design:
|
|
||||||
|
|
||||||
- An **`akgl_SpriteSheet`** owns one `SDL_Texture` — an image file uploaded to the
|
|
||||||
GPU — and nothing else.
|
|
||||||
- An **`akgl_Sprite`** is one animation: a frame size, a playlist of frame numbers,
|
|
||||||
a dwell time, two loop flags, and a **borrowed** pointer to the sheet those
|
|
||||||
frames are cut from.
|
|
||||||
|
|
||||||
So a walk cycle, an idle pose and a death animation drawn on the same PNG are three
|
|
||||||
`akgl_Sprite` objects pointing at one `akgl_SpriteSheet`, and the image is uploaded
|
|
||||||
once. That is not an optimization you have to ask for; it is what the loader does.
|
|
||||||
|
|
||||||
Both are pool objects — `akgl_heap_next_sprite`, `akgl_heap_next_spritesheet`, see
|
|
||||||
[Chapter 05](05-the-heap.md) — and both publish themselves in a registry under
|
|
||||||
their name, which is how [Chapter 11](11-characters.md) and the tilemap loader find
|
|
||||||
them. See [Chapter 06](06-the-registry.md).
|
|
||||||
|
|
||||||
**The renderer has to exist before any of this.** Loading a sheet uploads a
|
|
||||||
texture, so `akgl_render_2d_init` (or `akgl_render_2d_bind` with a live
|
|
||||||
`SDL_Renderer` on it — see [Chapter 08](08-rendering.md)) comes first.
|
|
||||||
|
|
||||||
## One image, one texture: how sharing actually works
|
|
||||||
|
|
||||||
`akgl_sprite_load_json` does not load an image and hope. It:
|
|
||||||
|
|
||||||
1. resolves the sheet's `filename` to an **absolute, canonical path**;
|
|
||||||
2. looks that path up in `AKGL_REGISTRY_SPRITESHEET`;
|
|
||||||
3. reuses the sheet it finds, or claims one from the pool and loads it.
|
|
||||||
|
|
||||||
**The resolved path is the registry key.** That is what makes ten sprites cut from
|
|
||||||
one image cost one texture, and it is why the resolution rule below matters more
|
|
||||||
than it looks.
|
|
||||||
|
|
||||||
On the reuse path the sheet's reference count is **not** incremented — the sprite
|
|
||||||
borrows it. `akgl_sprite_initialize` does not take a reference either. So:
|
|
||||||
|
|
||||||
> **Releasing a spritesheet out from under a live sprite leaves a dangling
|
|
||||||
> pointer.** Nothing detects it. In practice, release sprites and sheets together
|
|
||||||
> at the same lifecycle boundary (a level change), not individually.
|
|
||||||
|
|
||||||
### Where a sheet filename resolves from
|
|
||||||
|
|
||||||
This is a two-step rule and the first step surprises people:
|
|
||||||
|
|
||||||
```text
|
|
||||||
"spritesheet": { "filename": "hero.png" } in docs/assets/hero_walk.json
|
|
||||||
|
|
||||||
step 1 realpath("hero.png")
|
|
||||||
-> relative to the PROCESS'S CURRENT WORKING DIRECTORY
|
|
||||||
-> if that file exists, that is the sheet. Done.
|
|
||||||
|
|
||||||
step 2 only if step 1 raised ENOENT:
|
|
||||||
realpath(dirname("docs/assets/hero_walk.json") + "/" + "hero.png")
|
|
||||||
-> relative to the SPRITE JSON'S OWN DIRECTORY
|
|
||||||
```
|
|
||||||
|
|
||||||
**A sprite definition can sit next to its image and move with it** — that is step 2,
|
|
||||||
and it is the behaviour the format is designed around. But step 1 runs first, so a
|
|
||||||
file of the same name in the working directory shadows the one beside the JSON. If
|
|
||||||
a sprite loads the wrong art, check the working directory before checking the path.
|
|
||||||
|
|
||||||
Both steps end in `realpath(3)`, so symlinks and `..` are folded out and the key is
|
|
||||||
canonical. Two different spellings of the same file through `akgl_sprite_load_json`
|
|
||||||
therefore land on **one** sheet. (Calling `akgl_spritesheet_initialize` directly
|
|
||||||
skips all of this — it uses whatever string you hand it as the key verbatim, so
|
|
||||||
there two spellings really are two sheets.)
|
|
||||||
|
|
||||||
`AKERR_KEY` from a sheet load usually means the registry is not up. `ENOENT` means
|
|
||||||
neither step found the file.
|
|
||||||
|
|
||||||
## Frames are counted left to right, wrapping down
|
|
||||||
|
|
||||||
A sheet has no grid metadata that the library uses. The grid comes from the
|
|
||||||
*sprite*: `width` and `height` are both the drawn frame size **and** the stride
|
|
||||||
used to find a frame on the sheet.
|
|
||||||
|
|
||||||
```text
|
|
||||||
A 192x96 sheet, with a sprite whose width and height are 48:
|
|
||||||
|
|
||||||
+----+----+----+----+
|
|
||||||
| 0 | 1 | 2 | 3 | frame numbers count left to right from the
|
|
||||||
+----+----+----+----+ top-left, then wrap to the next row
|
|
||||||
| 4 | 5 | 6 | 7 |
|
|
||||||
+----+----+----+----+
|
|
||||||
|
|
||||||
"frames": [ 4, 5, 6, 5 ] an animation is a playlist of frame numbers.
|
|
||||||
index: 0 1 2 3 A frame may repeat; the order is yours.
|
|
||||||
```
|
|
||||||
|
|
||||||
Two indices, and confusing them is the most common mistake here:
|
|
||||||
|
|
||||||
| Name | What it indexes | Where you see it |
|
|
||||||
|---|---|---|
|
|
||||||
| **frame number** | a cell on the sheet | the values inside `"frames"` |
|
|
||||||
| **frame id** | a slot in the `"frames"` playlist | `akgl_Actor::curSpriteFrameId`, and the `frameid` argument to `akgl_spritesheet_coords_for_frame` |
|
|
||||||
|
|
||||||
`akgl_spritesheet_coords_for_frame` takes a **frame id**, looks up
|
|
||||||
`frameids[frameid]`, multiplies by the sprite width, and wraps whole rows off the
|
|
||||||
right-hand edge of the texture, stepping down one sprite height per row. The result
|
|
||||||
is the source rectangle for the blit.
|
|
||||||
|
|
||||||
> **It does not bounds-check `frameid`.** An index past `frames` — or past
|
|
||||||
> `AKGL_SPRITE_MAX_FRAMES` — reads a neighbouring struct member and yields a
|
|
||||||
> nonsense rectangle rather than an error. It also dereferences `sheet->texture`
|
|
||||||
> without checking it, so a registered-but-unloaded sheet is a crash.
|
|
||||||
|
|
||||||
`akgl_SpriteSheet` carries `sprite_w` and `sprite_h` fields. **They are vestigial.**
|
|
||||||
Nothing in the library writes or reads them, and the two arguments
|
|
||||||
`akgl_spritesheet_initialize` takes for them are accepted and discarded. The grid
|
|
||||||
that is actually used is the sprite's `width`/`height`. They are still in the
|
|
||||||
struct and in the signature because removing them is an ABI change.
|
|
||||||
|
|
||||||
## The sprite JSON format
|
|
||||||
|
|
||||||
```json kind=sprite setup=spritesheet
|
|
||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "spritesheet.png",
|
|
||||||
"frame_width": 48,
|
|
||||||
"frame_height": 48
|
|
||||||
},
|
|
||||||
"name": "hero walking left",
|
|
||||||
"width": 48,
|
|
||||||
"height": 48,
|
|
||||||
"speed": 100,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": true,
|
|
||||||
"frames": [
|
|
||||||
12,
|
|
||||||
13,
|
|
||||||
14
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Type | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `spritesheet.filename` | string | Image path. Resolved as above. Required. |
|
|
||||||
| `spritesheet.frame_width` | integer | Read **only when the sheet is not already loaded**, then discarded. |
|
|
||||||
| `spritesheet.frame_height` | integer | Same. |
|
|
||||||
| `name` | string | Registry key in `AKGL_REGISTRY_SPRITE`. Required. |
|
|
||||||
| `width` | integer | Frame width in pixels, and the horizontal stride. Must be >= 1. |
|
|
||||||
| `height` | integer | Frame height in pixels, and the vertical stride. Must be >= 1. |
|
|
||||||
| `speed` | integer | **Milliseconds** one frame is held. 0 to 4294. |
|
|
||||||
| `loop` | boolean | Restart at the end rather than holding the last frame. |
|
|
||||||
| `loopReverse` | boolean | With `loop`, walk back down instead of jumping to 0 — a ping-pong. |
|
|
||||||
| `frames` | array of integers | Frame numbers, in playback order. At most 16, each 0..255. |
|
|
||||||
|
|
||||||
**Every one of those keys is required.** These are not optional-with-a-default
|
|
||||||
lookups: an absent key is `AKERR_KEY` naming it, and a key of the wrong JSON type is
|
|
||||||
`AKERR_TYPE`. `speed` as a string fails; `frames` as an object fails.
|
|
||||||
|
|
||||||
### speed is milliseconds in the file and nanoseconds in the struct
|
|
||||||
|
|
||||||
`akgl_Sprite::speed` is compared against `SDL_GetCurrentTime`, which counts
|
|
||||||
nanoseconds. Nobody wants to type nanoseconds into an asset file, so the loader
|
|
||||||
reads milliseconds and multiplies by `AKGL_TIME_ONEMS_NS` (1000000).
|
|
||||||
|
|
||||||
**If you set `speed` by hand rather than through the loader, scale it yourself.**
|
|
||||||
Writing `spr->speed = 100` means 100 nanoseconds, and the animation advances every
|
|
||||||
frame.
|
|
||||||
|
|
||||||
The field is `uint32_t`, so the multiply is what bounds the range: values above
|
|
||||||
`UINT32_MAX / AKGL_TIME_ONEMS_NS` — **4294 ms** — would overflow, and are refused
|
|
||||||
with `AKERR_OUTOFBOUNDS` rather than wrapping. A negative `speed` is refused too.
|
|
||||||
|
|
||||||
### What the loader actually validates
|
|
||||||
|
|
||||||
Read against `src/sprite.c`, not against the header prose:
|
|
||||||
|
|
||||||
| Check | Status | Message says |
|
|
||||||
|---|---|---|
|
|
||||||
| `strlen(filename) >= AKGL_MAX_STRING_LENGTH` | `AKERR_OUTOFBOUNDS` | the JSON path is too long for a pooled string |
|
|
||||||
| `width <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel wide" |
|
|
||||||
| `height <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel high" |
|
|
||||||
| `speed < 0` or `speed > 4294` | `AKERR_OUTOFBOUNDS` | the value and the permitted range |
|
|
||||||
| `frames` longer than `AKGL_SPRITE_MAX_FRAMES` (16) | `AKERR_OUTOFBOUNDS` | the declared count and the maximum |
|
|
||||||
| a frame number outside 0..255 | `AKERR_OUTOFBOUNDS` | which index, its value, and the range |
|
|
||||||
|
|
||||||
The last two are recent and are worth knowing about because `sprite.h` still says
|
|
||||||
otherwise. Its `@note` on `akgl_sprite_load_json` reads *"The `frames` array is not
|
|
||||||
bounded against `AKGL_SPRITE_MAX_FRAMES`. A definition with more than 16 frames
|
|
||||||
writes past `frameids` into the rest of the struct."* **That is no longer true** —
|
|
||||||
the count is bounded before anything is written, and `tests/assets/` carries
|
|
||||||
fixtures for both the boundary and the overflow. The same `@brief` describes
|
|
||||||
`speed` as *"seconds, scaled to milliseconds"*, which is wrong in both units.
|
|
||||||
Correcting those comments is a separate commit; this chapter documents the code.
|
|
||||||
|
|
||||||
On any failure the pooled sprite **and any sheet loaded for it** are released
|
|
||||||
again, so a bad definition does not strand pool slots.
|
|
||||||
|
|
||||||
## Loading, and building one by hand
|
|
||||||
|
|
||||||
The ordinary path is one call per definition file:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/sprite.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *load_hero_sprites(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
/* Both cut from one image: the second call reuses the first's texture. */
|
|
||||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_left.json"));
|
|
||||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_right.json"));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also assemble one from the pools directly, which is what the loader does
|
|
||||||
underneath. `akgl_sprite_initialize` sets the name and the sheet and takes the
|
|
||||||
first reference; **everything else is left at zero for you to fill in**:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/sprite.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Two animations cut from one image, built without a JSON file. One sheet,
|
|
||||||
* one texture, two sprites borrowing it.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext *build_hero_sprites(char *imagepath)
|
|
||||||
{
|
|
||||||
akgl_SpriteSheet *sheet = NULL;
|
|
||||||
akgl_Sprite *walk = NULL;
|
|
||||||
akgl_Sprite *idle = NULL;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, imagepath, AKERR_NULLPOINTER, "imagepath");
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
|
|
||||||
/* The two size arguments are discarded; the sprite's width/height win. */
|
|
||||||
CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, imagepath));
|
|
||||||
|
|
||||||
CATCH(errctx, akgl_heap_next_sprite(&walk));
|
|
||||||
CATCH(errctx, akgl_sprite_initialize(walk, "hero walking left", sheet));
|
|
||||||
walk->width = 48;
|
|
||||||
walk->height = 48;
|
|
||||||
walk->frames = 3;
|
|
||||||
walk->frameids[0] = 12;
|
|
||||||
walk->frameids[1] = 13;
|
|
||||||
walk->frameids[2] = 14;
|
|
||||||
walk->speed = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
|
||||||
walk->loop = true;
|
|
||||||
walk->loopReverse = true;
|
|
||||||
|
|
||||||
CATCH(errctx, akgl_heap_next_sprite(&idle));
|
|
||||||
CATCH(errctx, akgl_sprite_initialize(idle, "hero standing left", sheet));
|
|
||||||
idle->width = 48;
|
|
||||||
idle->height = 48;
|
|
||||||
idle->frames = 1;
|
|
||||||
idle->frameids[0] = 13;
|
|
||||||
idle->speed = 1000 * AKGL_TIME_ONEMS_NS;
|
|
||||||
idle->loop = false;
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A string literal is a safe `name` argument. `sprite.h` warns that the name is
|
|
||||||
*"copied at a fixed `AKGL_SPRITE_MAX_NAME_LENGTH` bytes, so a shorter string reads
|
|
||||||
past its end"* — that was a `memcpy` of the full field width and it is gone. The
|
|
||||||
copy is `aksl_strncpy` now, which reads only what is there and always terminates.
|
|
||||||
|
|
||||||
## Animation is the actor's job, not the sprite's
|
|
||||||
|
|
||||||
An `akgl_Sprite` is a description. Nothing in it moves. The advancing is done by
|
|
||||||
the actor holding it — `curSpriteFrameId`, `curSpriteFrameTimer` and
|
|
||||||
`curSpriteReversing` are actor fields, and `changeframefunc` is an actor hook. See
|
|
||||||
[Chapter 12](12-actors.md).
|
|
||||||
|
|
||||||
That is why the same sprite can be shared by a hundred actors that are all on
|
|
||||||
different frames.
|
|
||||||
|
|
||||||
The three flag combinations, as the default `changeframefunc` reads them:
|
|
||||||
|
|
||||||
| `loop` | `loopReverse` | At the last frame |
|
|
||||||
|---|---|---|
|
|
||||||
| true | false | wrap to frame id 0 |
|
|
||||||
| true | true | turn round and walk back down, turning again at 0 |
|
|
||||||
| false | either | **wrap to frame id 0 anyway** |
|
|
||||||
|
|
||||||
The last row is not a typo. A sprite with `loop` clear still wraps rather than
|
|
||||||
holding the final frame; there is no play-once behaviour in the default hook. A
|
|
||||||
game that wants one binds its own `changeframefunc`.
|
|
||||||
|
|
||||||
## Known defects worth knowing here
|
|
||||||
|
|
||||||
- **Truncated names are registry keys.** `akgl_Sprite::name` is 128 bytes and
|
|
||||||
`akgl_SpriteSheet::name` is 512. A longer name truncates *silently*, and two names
|
|
||||||
that truncate the same collide — the second registration replaces the first with no
|
|
||||||
error. Recorded in `TODO.md`, "Truncated registry keys can collide"; the fix is a
|
|
||||||
contract change, since the headers currently promise truncation.
|
|
||||||
- **The heap acquire functions are asymmetric.** `akgl_heap_next_string` increments
|
|
||||||
`refcount`; `next_sprite` and `next_spritesheet` do not. `TODO.md` item 8. See
|
|
||||||
[Chapter 05](05-the-heap.md).
|
|
||||||
- **No test asserts a clean sprite or spritesheet load/release cycle.** The tilemap
|
|
||||||
cycle is asserted over 64 iterations; the sprite, spritesheet and character ones
|
|
||||||
are not. `TODO.md`, "Targets", row 16.
|
|
||||||
- **`akgl_spritesheet_coords_for_frame` bounds-checks nothing.** See above.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 11](11-characters.md) — binding sprites to actor states.
|
|
||||||
- [Chapter 12](12-actors.md) — what actually advances a frame.
|
|
||||||
- [Chapter 05](05-the-heap.md) — the pools these come out of.
|
|
||||||
- [Chapter 06](06-the-registry.md) — how a name becomes a lookup.
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
# 11. Characters
|
|
||||||
|
|
||||||
**A character is the reusable template. An actor is the instance.**
|
|
||||||
|
|
||||||
Everything that is the same for every goblin on the map — top speed, acceleration,
|
|
||||||
how long an animation frame is held, and which sprite to draw for which combination
|
|
||||||
of state bits — lives in one `akgl_Character` and is shared by pointer. A hundred
|
|
||||||
goblins cost one character. Everything that is unique to a particular goblin — where
|
|
||||||
it is, which way it is going, which frame it is on — lives in its `akgl_Actor`. See
|
|
||||||
[Chapter 12](12-actors.md).
|
|
||||||
|
|
||||||
Characters are pool objects (`akgl_heap_next_character`, 256 slots) published in
|
|
||||||
`AKGL_REGISTRY_CHARACTER` under their name, which is how `akgl_actor_set_character`
|
|
||||||
finds them. `akgl_registry_init` has to have run first.
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_Character "hero" akgl_Actor "player"
|
|
||||||
+--------------------------------+ +---------------------+
|
|
||||||
| sx, sy, sz top speeds |<---------| basechar |
|
|
||||||
| ax, ay, az accelerations | borrowed| x, y, z |
|
|
||||||
| speedtime frame dwell (ns) | | state (bitmask) |
|
|
||||||
| state_sprites -----------------+---+ | curSpriteFrameId |
|
|
||||||
+--------------------------------+ | +---------------------+
|
|
||||||
|
|
|
||||||
SDL_PropertiesID keyed by the DECIMAL state bitmask
|
|
||||||
"18" -> akgl_Sprite *hero standing left
|
|
||||||
"146" -> akgl_Sprite *hero walking left
|
|
||||||
```
|
|
||||||
|
|
||||||
## The state-to-sprite map is an exact-match lookup
|
|
||||||
|
|
||||||
`state_sprites` is an `SDL_PropertiesID`. The key is **the decimal spelling of the
|
|
||||||
whole state value** — `SDL_itoa(state, buf, 10)` — and the value is an
|
|
||||||
`akgl_Sprite *`.
|
|
||||||
|
|
||||||
Two things follow, and both of them shape how you build a character:
|
|
||||||
|
|
||||||
**1. The key is the entire state word, not a set of bits to match against.** A
|
|
||||||
sprite added for `FACE_LEFT | MOVING_LEFT` is *not* found by a lookup for
|
|
||||||
`FACE_LEFT`. There is no subset fallback, no wildcard, no priority order, and no
|
|
||||||
default sprite. **You need a mapping for every state combination the character can
|
|
||||||
actually be in.**
|
|
||||||
|
|
||||||
**2. A missing mapping is an error, not a quiet miss.** `akgl_character_sprite_get`
|
|
||||||
raises `AKERR_KEY` — this is the one lookup in the library that treats absence as a
|
|
||||||
failure rather than as a successful answer of "nothing here", on the grounds that an
|
|
||||||
actor with no sprite for its current state cannot be drawn. `*dest` is set to `NULL`
|
|
||||||
alongside the error.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/character.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* sprite_get treats "no mapping" as AKERR_KEY. Handle the status; do not test
|
|
||||||
* the return value for NULL and hope.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext *sprite_or_none(akgl_Character *basechar, int state, akgl_Sprite **dest)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
|
|
||||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
|
||||||
*dest = NULL;
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, basechar->sprite_get(basechar, state, dest));
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} HANDLE(errctx, AKERR_KEY) {
|
|
||||||
/* No sprite for this exact combination. *dest is already NULL. */
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_actor_update` and `akgl_actor_render` both swallow that `AKERR_KEY` themselves
|
|
||||||
— states change faster than art gets drawn, and a missing sprite should not stop the
|
|
||||||
frame — so an under-mapped character shows up as an actor that silently does not
|
|
||||||
draw. That is the symptom to recognize.
|
|
||||||
|
|
||||||
### The combinations you will actually need
|
|
||||||
|
|
||||||
Four facings, moving and standing, alive: eight mappings for the simplest walking
|
|
||||||
character. Written out, with the decimal key each one lands on:
|
|
||||||
|
|
||||||
| Combination | Decimal key | What it draws |
|
|
||||||
|---|---|---|
|
|
||||||
| `ALIVE` | 16 | standing still, **no facing** — see below |
|
|
||||||
| `ALIVE\|FACE_DOWN` | 17 | standing, facing the camera |
|
|
||||||
| `ALIVE\|FACE_LEFT` | 18 | standing, facing left |
|
|
||||||
| `ALIVE\|FACE_RIGHT` | 20 | standing, facing right |
|
|
||||||
| `ALIVE\|FACE_UP` | 24 | standing, facing away |
|
|
||||||
| `ALIVE\|FACE_DOWN\|MOVING_DOWN` | 1041 | walking towards the camera |
|
|
||||||
| `ALIVE\|FACE_LEFT\|MOVING_LEFT` | 146 | walking left |
|
|
||||||
| `ALIVE\|FACE_RIGHT\|MOVING_RIGHT` | 276 | walking right |
|
|
||||||
| `ALIVE\|FACE_UP\|MOVING_UP` | 536 | walking away |
|
|
||||||
|
|
||||||
**Do not skip the first row.** The default `facefunc`
|
|
||||||
(`akgl_actor_automatic_face`) clears every facing bit and then sets one only if a
|
|
||||||
movement bit is set — so an actor that stops moving is left with *no* facing bit at
|
|
||||||
all, and its state drops to bare `ALIVE`. Without a mapping for 16 it vanishes the
|
|
||||||
moment it stops. This is a known rough edge; the implementation carries a `TODO`
|
|
||||||
saying as much, and [Chapter 12](12-actors.md) covers the workaround.
|
|
||||||
|
|
||||||
The decimals are shown to make the mechanism concrete. **Write the constants**, or
|
|
||||||
the state-name strings in JSON — never the numbers.
|
|
||||||
|
|
||||||
## Adding a mapping
|
|
||||||
|
|
||||||
`akgl_character_sprite_add` takes a reference on the sprite, so the character keeps
|
|
||||||
it alive. The reference is taken *before* the property write, so a failed write
|
|
||||||
cannot leave the sprite bound with no reference behind it.
|
|
||||||
|
|
||||||
Rebinding a state that is already mapped releases the sprite it displaced. **The
|
|
||||||
header says otherwise** — `character.h`'s `@note` on `akgl_character_sprite_add`
|
|
||||||
claims the displaced sprite's reference "is never given back". That was true and is
|
|
||||||
not; `src/character.c` reads the old binding out first and releases it, with a
|
|
||||||
comment explaining that without it a character rebinding a state while alive leaks
|
|
||||||
one sprite slot per rebind.
|
|
||||||
|
|
||||||
> **A defect the release path does have.** The release is guarded by
|
|
||||||
> `(displaced != NULL) && (displaced != ref)`. Re-adding the *same* sprite for the
|
|
||||||
> *same* state therefore increments its reference count and releases nothing — one
|
|
||||||
> leaked reference per redundant call. It is idempotent-looking and is not.
|
|
||||||
> Harmless in a loader that runs once; not harmless in a loop.
|
|
||||||
|
|
||||||
State `0` is accepted and is a usable key.
|
|
||||||
|
|
||||||
## Building a character in code
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/character.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *build_hero(akgl_Sprite *idle_left, akgl_Sprite *walk_left)
|
|
||||||
{
|
|
||||||
akgl_Character *hero = NULL;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, idle_left, AKERR_NULLPOINTER, "idle_left");
|
|
||||||
FAIL_ZERO_RETURN(errctx, walk_left, AKERR_NULLPOINTER, "walk_left");
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_character(&hero));
|
|
||||||
/* Zeroes the struct, creates the map, binds the two hooks, registers. */
|
|
||||||
CATCH(errctx, akgl_character_initialize(hero, "hero"));
|
|
||||||
|
|
||||||
/* Everything numeric is left at zero for us. */
|
|
||||||
hero->speedtime = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
|
||||||
hero->sx = 0.20f;
|
|
||||||
hero->sy = 0.20f;
|
|
||||||
hero->ax = 0.20f;
|
|
||||||
hero->ay = 0.20f;
|
|
||||||
|
|
||||||
/* One mapping per exact combination we intend to draw. */
|
|
||||||
CATCH(errctx,
|
|
||||||
hero->sprite_add(
|
|
||||||
hero,
|
|
||||||
idle_left,
|
|
||||||
AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT)
|
|
||||||
);
|
|
||||||
CATCH(errctx,
|
|
||||||
hero->sprite_add(
|
|
||||||
hero,
|
|
||||||
walk_left,
|
|
||||||
AKGL_ACTOR_STATE_ALIVE
|
|
||||||
| AKGL_ACTOR_STATE_FACE_LEFT
|
|
||||||
| AKGL_ACTOR_STATE_MOVING_LEFT)
|
|
||||||
);
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`sprite_add` and `sprite_get` are function pointers on the struct, bound to
|
|
||||||
`akgl_character_sprite_add` and `akgl_character_sprite_get` by
|
|
||||||
`akgl_character_initialize`. Call through the pointers, as with the render backend
|
|
||||||
in [Chapter 08](08-rendering.md) — that is what lets a game substitute its own
|
|
||||||
lookup, a state machine with fallbacks for instance, on one character.
|
|
||||||
|
|
||||||
`az` and `sz` are **not** read from JSON. They stay 0 unless you set them by hand.
|
|
||||||
|
|
||||||
## The character JSON format
|
|
||||||
|
|
||||||
```json kind=character setup=character
|
|
||||||
{
|
|
||||||
"name": "hero",
|
|
||||||
"speedtime": 100,
|
|
||||||
"speed_x": 0.20,
|
|
||||||
"speed_y": 0.20,
|
|
||||||
"acceleration_x": 0.20,
|
|
||||||
"acceleration_y": 0.20,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "hero standing left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "hero walking left"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Type | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `name` | string | Registry key in `AKGL_REGISTRY_CHARACTER`. |
|
|
||||||
| `speedtime` | integer | **Milliseconds** a frame is held, scaled to nanoseconds by `AKGL_TIME_ONEMS_NS`. |
|
|
||||||
| `speed_x`, `speed_y` | number | Top speed per axis. Copied onto an actor as `sx`/`sy`. |
|
|
||||||
| `acceleration_x`, `acceleration_y` | number | Acceleration per axis, signed by direction each step. |
|
|
||||||
| `sprite_mappings` | array of objects | One entry per state combination. |
|
|
||||||
| `…[].sprite` | string | A name already in `AKGL_REGISTRY_SPRITE`. |
|
|
||||||
| `…[].state` | array of strings | State names, **OR-ed together** into one key. |
|
|
||||||
|
|
||||||
All of them are required. An absent key is `AKERR_KEY` naming it; a wrong type is
|
|
||||||
`AKERR_TYPE`.
|
|
||||||
|
|
||||||
`character.h`'s `@brief` describes `speedtime` as "in seconds". It is milliseconds
|
|
||||||
— the struct field's own documentation says so and `src/character.c` multiplies by
|
|
||||||
`AKGL_TIME_ONEMS_NS`.
|
|
||||||
|
|
||||||
**Sprites must be loaded first.** This loads characters, not sprites: every name in
|
|
||||||
a `sprite` field has to already be in `AKGL_REGISTRY_SPRITE`, so
|
|
||||||
`akgl_sprite_load_json` runs before `akgl_character_load_json`. A mapping naming an
|
|
||||||
unregistered sprite fails with `AKERR_NULLPOINTER` (not `AKERR_KEY` — it is a
|
|
||||||
lookup miss reported with a pointer status), and the message names the character,
|
|
||||||
the state and the sprite.
|
|
||||||
|
|
||||||
Two keys you may see in existing files and should not copy: `movementspeed` and
|
|
||||||
`velocity_x`. **Neither is read by anything.** They are ignored, silently.
|
|
||||||
|
|
||||||
### State names are prefixed
|
|
||||||
|
|
||||||
The strings come from `AKGL_ACTOR_STATE_STRING_NAMES` in
|
|
||||||
`src/actor_state_string_names.c`, which `akgl_registry_init_actor_state_strings`
|
|
||||||
turns into a registry. Entry `i` names the bit `1 << i`, and **every name carries
|
|
||||||
the `AKGL_ACTOR_STATE_` prefix**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
AKGL_ACTOR_STATE_FACE_DOWN AKGL_ACTOR_STATE_MOVING_LEFT
|
|
||||||
AKGL_ACTOR_STATE_FACE_LEFT AKGL_ACTOR_STATE_MOVING_RIGHT
|
|
||||||
AKGL_ACTOR_STATE_FACE_RIGHT AKGL_ACTOR_STATE_MOVING_UP
|
|
||||||
AKGL_ACTOR_STATE_FACE_UP AKGL_ACTOR_STATE_MOVING_DOWN
|
|
||||||
AKGL_ACTOR_STATE_ALIVE AKGL_ACTOR_STATE_MOVING_IN
|
|
||||||
AKGL_ACTOR_STATE_DYING AKGL_ACTOR_STATE_MOVING_OUT
|
|
||||||
AKGL_ACTOR_STATE_DEAD AKGL_ACTOR_STATE_UNDEFINED_13 .. _31
|
|
||||||
```
|
|
||||||
|
|
||||||
A name that is not in that list is `AKERR_KEY`, "Unknown actor state", quoting it —
|
|
||||||
a typo is refused rather than skipped, because a skipped typo would bind a sprite to
|
|
||||||
the wrong combination and show up as art that never appears. An empty `state` array
|
|
||||||
is legal and gives you the key `0`.
|
|
||||||
|
|
||||||
Adding a new state means adding a bit in `include/akgl/actor.h` **and** its name at
|
|
||||||
the matching index in `src/actor_state_string_names.c`. A bit whose name is missing
|
|
||||||
cannot be referred to from JSON at all — which is exactly what happened to
|
|
||||||
`MOVING_IN` and `MOVING_OUT` until 0.5.0.
|
|
||||||
|
|
||||||
> ### `util/assets/littleguy.json` is invalid against the current loader
|
|
||||||
>
|
|
||||||
> The sample data for the one shipped demo does not load, and it is worth naming
|
|
||||||
> the three separate reasons because each is a different class of drift:
|
|
||||||
>
|
|
||||||
> 1. **The state names are unprefixed** — `"ACTOR_STATE_ALIVE"` rather than
|
|
||||||
> `"AKGL_ACTOR_STATE_ALIVE"`. Every one fails with `AKERR_KEY`, "Unknown actor
|
|
||||||
> state".
|
|
||||||
> 2. **It has `velocity_x` and `velocity_y`**, which nothing reads, and lacks
|
|
||||||
> `speed_x` and `speed_y`, which are required.
|
|
||||||
> 3. **It has no `speedtime`, `acceleration_x` or `acceleration_y`** either — all
|
|
||||||
> three required, all three `AKERR_KEY`.
|
|
||||||
>
|
|
||||||
> `tests/assets/testcharacter.json` is the file to copy from. Fixing the sample is
|
|
||||||
> out of scope here (`plan.md`, "Out of scope, deliberately"); the finding is
|
|
||||||
> recorded so nobody quotes it forward.
|
|
||||||
|
|
||||||
## Teardown
|
|
||||||
|
|
||||||
`akgl_heap_release_character` decrements the reference count, and at zero it walks
|
|
||||||
`state_sprites` with `akgl_character_state_sprites_iterate`, releasing every sprite
|
|
||||||
reference `sprite_add` took, destroys the property set, clears the registry entry
|
|
||||||
and zeroes the slot.
|
|
||||||
|
|
||||||
That walk is an `SDL_EnumerateProperties` callback, so it returns `void` and ends in
|
|
||||||
`FINISH_NORETURN`. **An error inside it exits the process** via libakerror's default
|
|
||||||
unhandled-error handler. The same hazard, in its more common form, is in
|
|
||||||
[Chapter 12](12-actors.md) under `akgl_registry_iterate_actor`; install your own
|
|
||||||
`akerr_handler_unhandled_error` if a game should survive it.
|
|
||||||
|
|
||||||
An actor **borrows** its character and takes no reference on it. Releasing a
|
|
||||||
character out from under a live actor leaves a dangling `basechar`.
|
|
||||||
|
|
||||||
## Known defects worth knowing here
|
|
||||||
|
|
||||||
- **Re-adding the same sprite for the same state leaks a reference.** See above.
|
|
||||||
- **Long names register and unregister under different keys.**
|
|
||||||
`akgl_character_initialize` writes the registry entry under the *caller's* `name`
|
|
||||||
pointer, while `akgl_heap_release_character` clears it under the character's own
|
|
||||||
truncated 128-byte copy. For a name over 127 bytes those differ, and the registry
|
|
||||||
entry survives the release — pointing at a zeroed slot. Latent for ordinary names;
|
|
||||||
related to `TODO.md`, "Truncated registry keys can collide", which covers the
|
|
||||||
collision half.
|
|
||||||
- **`speedtime` is written through an `int *` cast of a `uint64_t` field.**
|
|
||||||
`src/character.c` passes `(int *)&obj->speedtime` to
|
|
||||||
`akgl_get_json_integer_value`. It works because the struct was zeroed and the
|
|
||||||
platform is little-endian; it is the same class of cast-over-a-signature-mismatch
|
|
||||||
that `AGENTS.md` argues against under "Never silence a warning with a cast".
|
|
||||||
- **`akgl_character_initialize` silently replaces a same-named character**, and the
|
|
||||||
one it displaced becomes unreachable rather than being released.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 10](10-spritesheets-and-sprites.md) — the sprites these map to.
|
|
||||||
- [Chapter 12](12-actors.md) — the state bitmask itself, and what sets it.
|
|
||||||
- [Chapter 14](14-physics.md) — what `sx`/`sy` and `ax`/`ay` mean once an actor is
|
|
||||||
simulated.
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
# 12. Actors
|
|
||||||
|
|
||||||
An actor is a live thing in the world. It carries only what is unique to it — where
|
|
||||||
it is, which way it is moving, which animation frame it is on — and borrows speed,
|
|
||||||
acceleration and the whole state-to-sprite map from the `akgl_Character` it points
|
|
||||||
at. That is what makes a hundred of one kind of thing cheap. See
|
|
||||||
[Chapter 11](11-characters.md) for the other half.
|
|
||||||
|
|
||||||
Actors are pool objects (`akgl_heap_next_actor`, 64 slots by default) published in
|
|
||||||
`AKGL_REGISTRY_ACTOR` under their name.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
|
|
||||||
akerr_ErrorContext *spawn_player(void)
|
|
||||||
{
|
|
||||||
akgl_Actor *player = NULL;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_actor(&player));
|
|
||||||
/* Zeroes it, sets scale 1.0 and movement_controls_face, installs the
|
|
||||||
six default hooks, registers it, takes the first reference. */
|
|
||||||
CATCH(errctx, akgl_actor_initialize(player, "player"));
|
|
||||||
/* Until this runs the actor cannot update, render or simulate. */
|
|
||||||
CATCH(errctx, akgl_actor_set_character(player, "hero"));
|
|
||||||
|
|
||||||
player->state = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN;
|
|
||||||
player->visible = true;
|
|
||||||
player->layer = 1;
|
|
||||||
player->x = 64.0f;
|
|
||||||
player->y = 64.0f;
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_actor_initialize` does **not** set a character. `akgl_actor_set_character`
|
|
||||||
looks one up by name, copies its `sx`/`sy` onto the actor and zeroes `ax`/`ay` so it
|
|
||||||
starts from rest. The character is borrowed, not referenced. A name that is not
|
|
||||||
registered comes back as `AKERR_NULLPOINTER` — a lookup miss reported with a pointer
|
|
||||||
status rather than `AKERR_KEY`.
|
|
||||||
|
|
||||||
> `sz` is not copied and `az` is not zeroed, so an actor's depth speed keeps
|
|
||||||
> whatever it had. The default movement logic re-copies all three every step, which
|
|
||||||
> papers over it for anything that simulates.
|
|
||||||
|
|
||||||
## State is a 32-bit bitmask
|
|
||||||
|
|
||||||
An actor is facing left *and* moving left *and* alive at the same time, and **the
|
|
||||||
whole combination is the key that selects a sprite**. It is not an enum and there is
|
|
||||||
no current-state field.
|
|
||||||
|
|
||||||
Thirteen bits are defined:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/actor.h
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
|
||||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
|
||||||
#define AKGL_ACTOR_STATE_DYING (1 << 5) // 32 0000 0000 0010 0000
|
|
||||||
#define AKGL_ACTOR_STATE_DEAD (1 << 6) // 64 0000 0000 0100 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_LEFT (1 << 7) // 128 0000 0000 1000 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_RIGHT (1 << 8) // 256 0000 0001 0000 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_UP (1 << 9) // 512 0000 0010 0000 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_DOWN (1 << 10) // 1024 0000 0100 0000 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_IN (1 << 11) // 2048 0000 1000 0000 0000
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_OUT (1 << 12) // 4096 0001 0000 0000 0000
|
|
||||||
```
|
|
||||||
|
|
||||||
The trailing bit pattern in that table is the position **within its own 16-bit
|
|
||||||
half**, not the full 32-bit value. `actor.h` says so at the top of the block; read
|
|
||||||
as a whole word it looks like bit 16 restarts at bit 0, which it does not.
|
|
||||||
|
|
||||||
**Bits 13 through 31 are undefined and are yours.** They are declared as
|
|
||||||
`AKGL_ACTOR_STATE_UNDEFINED_13` .. `_31` and — this is the useful part — they are
|
|
||||||
*already named* at the matching indices in `src/actor_state_string_names.c`, so a
|
|
||||||
character JSON can bind a sprite to one without any code change. Renaming a bit means
|
|
||||||
editing both files at the same index; see [Chapter 11](11-characters.md).
|
|
||||||
|
|
||||||
`state` is an `int32_t`, so bit 31 makes it negative and its decimal property key is
|
|
||||||
`-2147483648`. It works. Prefer the low undefined bits.
|
|
||||||
|
|
||||||
Two composite masks are provided:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/actor.h
|
|
||||||
#define AKGL_ACTOR_STATE_FACE_ALL (AKGL_ACTOR_STATE_FACE_DOWN | AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_FACE_UP)
|
|
||||||
#define AKGL_ACTOR_STATE_MOVING_ALL (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP | AKGL_ACTOR_STATE_MOVING_DOWN)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`MOVING_ALL` does not include `MOVING_IN` or `MOVING_OUT`.** It is the four
|
|
||||||
planar directions only. Clearing it leaves the two depth bits set.
|
|
||||||
|
|
||||||
### AKGL_BITMASK_HAS means all of them, not any of them
|
|
||||||
|
|
||||||
The four manipulation macros live in `game.h`:
|
|
||||||
|
|
||||||
| Macro | Means |
|
|
||||||
|---|---|
|
|
||||||
| `AKGL_BITMASK_HAS(x, y)` | **every** bit of `y` is set in `x` — `((x) & (y)) == (y)` |
|
|
||||||
| `AKGL_BITMASK_HASNOT(x, y)` | at least one bit of `y` is missing from `x` |
|
|
||||||
| `AKGL_BITMASK_ADD(x, y)` | set every bit of `y` in `x`; modifies `x` |
|
|
||||||
| `AKGL_BITMASK_DEL(x, y)` | clear every bit of `y` in `x`; modifies `x` |
|
|
||||||
|
|
||||||
The "all, not any" reading is the one that catches people. `AKGL_BITMASK_HAS(state,
|
|
||||||
AKGL_ACTOR_STATE_FACE_ALL)` asks whether the actor is facing **all four ways at
|
|
||||||
once**, which is never. To ask "is it facing at all", test
|
|
||||||
`(state & AKGL_ACTOR_STATE_FACE_ALL)` directly, or `AKGL_BITMASK_HASNOT` against a
|
|
||||||
single bit.
|
|
||||||
|
|
||||||
All five macros are fully parenthesized, so `!AKGL_BITMASK_HAS(a, b)` means what it
|
|
||||||
reads as. Until 0.5.0 it expanded to a bare `(x & y) == y` and a negation bound to
|
|
||||||
the `&`, parsing as `!(a & b) == b`. Nothing in the tree negated it, which is the
|
|
||||||
only reason it was latent rather than live — and the test that distinguishes the
|
|
||||||
two parses is a bit that is *not* set whose value is *not* 1, not the obvious one.
|
|
||||||
|
|
||||||
## The seven behaviour hooks
|
|
||||||
|
|
||||||
**Behaviour attaches as function pointers, not by inheritance.** There is no actor
|
|
||||||
subclass. `akgl_actor_initialize` installs the library's defaults on every actor,
|
|
||||||
and a game that wants a different flavour of anything replaces the pointer **on that
|
|
||||||
one actor**.
|
|
||||||
|
|
||||||
| Hook | Default | Called by | Job |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `updatefunc` | `akgl_actor_update` | `akgl_game_update`'s sweep | per-frame logic: `facefunc`, then advance the animation if due |
|
|
||||||
| `renderfunc` | `akgl_actor_render` | `akgl_render_2d_draw_world` | per-frame draw |
|
|
||||||
| `facefunc` | `akgl_actor_automatic_face` | `akgl_actor_update` | choose the facing bits |
|
|
||||||
| `movementlogicfunc` | `akgl_actor_logic_movement` | `akgl_physics_simulate`, before gravity | turn movement bits into signed acceleration |
|
|
||||||
| `changeframefunc` | `akgl_actor_logic_changeframe` | `akgl_actor_update`, when the frame is due | step to the next animation frame |
|
|
||||||
| `addchild` | `akgl_actor_add_child` | you | attach a child actor |
|
|
||||||
| `collidefunc` | `akgl_actor_collide_block` | `akgl_collision_resolve`, after the move | answer a contact — see [Chapter 15](15-collision.md) |
|
|
||||||
|
|
||||||
Replace them after `akgl_actor_initialize`, never before — it overwrites all seven.
|
|
||||||
|
|
||||||
The `movementlogicfunc` slot is the interesting one, because it is where
|
|
||||||
`AKGL_ERR_LOGICINTERRUPT` stops being a table row in [Chapter 04](04-errors.md) and
|
|
||||||
becomes something you write. It is **not a failure**; it is a control signal meaning
|
|
||||||
"skip the rest of this tick for this actor", and `akgl_physics_simulate` swallows it:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A movementlogicfunc that freezes a dying actor. AKGL_ERR_LOGICINTERRUPT is a
|
|
||||||
* control signal, not a failure -- akgl_physics_simulate swallows it and skips
|
|
||||||
* the rest of this actor's step. Only a movementlogicfunc may raise it; from a
|
|
||||||
* backend's gravity or move it aborts the whole step.
|
|
||||||
*/
|
|
||||||
static akerr_ErrorContext *frozen_movement(akgl_Actor *obj, float32_t dt)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
if ( AKGL_BITMASK_HAS(obj->state, AKGL_ACTOR_STATE_DYING) ) {
|
|
||||||
FAIL_RETURN(
|
|
||||||
errctx,
|
|
||||||
AKGL_ERR_LOGICINTERRUPT,
|
|
||||||
"%s is dying; skip its step",
|
|
||||||
(char *)obj->name);
|
|
||||||
}
|
|
||||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
akerr_ErrorContext *freeze_the_dying(akgl_Actor *obj)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
obj->movementlogicfunc = &frozen_movement;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The default `movementlogicfunc` re-copies the character's speed limits onto the
|
|
||||||
actor each step — so editing a character takes effect next step — and sets `ax`/`ay`
|
|
||||||
to plus or minus the character's acceleration according to which movement bits are
|
|
||||||
set. It integrates nothing; the physics backend does that
|
|
||||||
([Chapter 14](14-physics.md)). Opposing bits do not cancel: `MOVING_LEFT` wins over
|
|
||||||
`MOVING_RIGHT` because it is tested first. **An actor with no movement bits keeps
|
|
||||||
its previous acceleration** rather than being zeroed, which is why the input
|
|
||||||
handlers clear it themselves on release.
|
|
||||||
|
|
||||||
The default `facefunc` clears every facing bit and sets the one matching the first
|
|
||||||
movement bit it finds, in the order left, right, up, down — so a diagonal faces its
|
|
||||||
horizontal component. An actor with `movement_controls_face` clear is left alone
|
|
||||||
entirely, which is the hook for something that aims independently of the way it
|
|
||||||
walks.
|
|
||||||
|
|
||||||
> **An actor that stops moving is left with no facing bit at all**, rather than
|
|
||||||
> keeping the way it was last facing. Its state drops to bare `ALIVE` and no longer
|
|
||||||
> matches a "standing still facing left" sprite. The implementation carries a `TODO`
|
|
||||||
> saying as much. Two workarounds: map bare `ALIVE` to something, or clear
|
|
||||||
> `movement_controls_face` and set the facing bits yourself.
|
|
||||||
|
|
||||||
## layer
|
|
||||||
|
|
||||||
`layer` is which tilemap layer the actor is drawn on, and it is set from the object
|
|
||||||
layer it was placed in. `akgl_render_2d_draw_world` interleaves the map's layers
|
|
||||||
with the actors standing on them, in ascending order — see
|
|
||||||
[Chapter 08](08-rendering.md).
|
|
||||||
|
|
||||||
**Only layers 0 through 15 are drawn.** The field is a `uint32_t` and nothing
|
|
||||||
range-checks it, but `draw_world`'s loop stops at `AKGL_TILEMAP_MAX_LAYERS` (16). An
|
|
||||||
actor on layer 16 is never drawn and never reports why.
|
|
||||||
|
|
||||||
## Parents and children
|
|
||||||
|
|
||||||
`addchild` attaches one actor to another so it moves with it — a carried lantern, a
|
|
||||||
turret on a tank, a party member walking behind the hero. Up to
|
|
||||||
`AKGL_ACTOR_MAX_CHILDREN` (8) per parent.
|
|
||||||
|
|
||||||
**A child is not simulated.** The physics step reaches it, sees a parent, snaps it
|
|
||||||
to the parent's position plus its own offset, and moves on: no thrust, no gravity,
|
|
||||||
no drag, no acceleration. That is the whole behaviour, and games that use it depend
|
|
||||||
on exactly that.
|
|
||||||
|
|
||||||
The parent takes a reference on the child, and **releasing a parent releases every
|
|
||||||
child with it, recursively**. There is no detach function; `AKERR_RELATIONSHIP` is
|
|
||||||
what you get for attaching an actor that already has a parent, and the only way out
|
|
||||||
is to release and rebuild. Running out of slots is `AKERR_OUTOFBOUNDS`.
|
|
||||||
|
|
||||||
> **Nothing checks for a cycle.** Making an actor its own ancestor makes
|
|
||||||
> `akgl_heap_release_actor` recurse until the stack runs out. This builds a tree,
|
|
||||||
> not a graph, and the tree-ness is your responsibility.
|
|
||||||
|
|
||||||
### The offset lives in two places, and they disagree
|
|
||||||
|
|
||||||
This is a real defect and it will bite anyone using children, so it gets stated
|
|
||||||
plainly rather than buried:
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_physics_simulate: actor->x = parent->x + actor->vx <- offset is vx
|
|
||||||
akgl_actor_render: dest.x = parent->x + actor->x <- offset is x
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/physics.c` treats `vx`/`vy`/`vz` as the offset and writes the result into
|
|
||||||
`x`/`y`/`z`, turning the child's `x` into an absolute world position.
|
|
||||||
`src/actor.c` then treats `x`/`y` as an offset and adds the parent's position **a
|
|
||||||
second time**. In `akgl_game_update` the physics step runs before the draw, so a
|
|
||||||
child is drawn one full parent-offset away from where it should be.
|
|
||||||
|
|
||||||
`actor.h` documents both readings, in two different places, without noticing they
|
|
||||||
contradict — `akgl_Actor::x` says "For a child, an offset from the parent" and
|
|
||||||
`akgl_Actor::vy` says "On a child actor this is read as an offset from the parent
|
|
||||||
instead."
|
|
||||||
|
|
||||||
It is invisible when the parent sits at the origin, which is why it survived. Until
|
|
||||||
it is fixed, the guard is: **do not call `akgl_actor_render` for a child through
|
|
||||||
`draw_world` if you also run the physics step.** Bind a `renderfunc` on the child
|
|
||||||
that draws at `obj->x`/`obj->y` without re-adding the parent, or keep children
|
|
||||||
purely decorative and position them yourself.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
|
|
||||||
/* A lantern carried 8 px right of and 8 px above the player. */
|
|
||||||
akerr_ErrorContext *attach_lantern(akgl_Actor *player)
|
|
||||||
{
|
|
||||||
akgl_Actor *lantern = NULL;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_actor(&lantern));
|
|
||||||
CATCH(errctx, akgl_actor_initialize(lantern, "player lantern"));
|
|
||||||
CATCH(errctx, akgl_actor_set_character(lantern, "lantern"));
|
|
||||||
lantern->state = AKGL_ACTOR_STATE_ALIVE;
|
|
||||||
lantern->visible = true;
|
|
||||||
lantern->layer = player->layer;
|
|
||||||
/* The physics step reads the offset out of vx/vy/vz. */
|
|
||||||
lantern->vx = 8.0f;
|
|
||||||
lantern->vy = -8.0f;
|
|
||||||
CATCH(errctx, player->addchild(player, lantern));
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## The eight control handlers
|
|
||||||
|
|
||||||
The `akgl_actor_cmhf_*` functions ("control map handler function") are what
|
|
||||||
`akgl_controller_default` binds the arrow keys and the D-pad to, and what a game's
|
|
||||||
own `akgl_Control` bindings can point at. They take the control map's **target
|
|
||||||
actor**, not a global "player", so they work for any number of locally controlled
|
|
||||||
actors. See [Chapter 16](16-input.md).
|
|
||||||
|
|
||||||
| Handler | Sets | Clears first | Writes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `akgl_actor_cmhf_left_on` | `MOVING_LEFT \| FACE_LEFT` | `FACE_ALL \| MOVING_ALL` | `ax = -basechar->ax` |
|
|
||||||
| `akgl_actor_cmhf_left_off` | — | `MOVING_LEFT` | `ax = 0`, `tx = 0` |
|
|
||||||
| `akgl_actor_cmhf_right_on` | `MOVING_RIGHT \| FACE_RIGHT` | `FACE_ALL \| MOVING_ALL` | `ax = basechar->ax` |
|
|
||||||
| `akgl_actor_cmhf_right_off` | — | `MOVING_RIGHT` | `ax = 0`, `tx = 0` |
|
|
||||||
| `akgl_actor_cmhf_up_on` | `MOVING_UP \| FACE_UP` | `FACE_ALL \| MOVING_ALL` | `ay = -basechar->ay` |
|
|
||||||
| `akgl_actor_cmhf_up_off` | — | `MOVING_UP` | `ay = 0`, `ty = 0` |
|
|
||||||
| `akgl_actor_cmhf_down_on` | `MOVING_DOWN \| FACE_DOWN` | `FACE_ALL \| MOVING_ALL` | `ay = basechar->ay` |
|
|
||||||
| `akgl_actor_cmhf_down_off` | — | `MOVING_DOWN` | `ay = 0`, `ty = 0` |
|
|
||||||
|
|
||||||
`ay` is negated for *up* because y grows downward.
|
|
||||||
|
|
||||||
**Two directions is not a diagonal.** Every `_on` handler clears the whole of
|
|
||||||
`FACE_ALL | MOVING_ALL` before setting its own two bits, so holding left and up at
|
|
||||||
once moves in whichever was pressed **last**. That is deliberate — it keeps the
|
|
||||||
state word to one facing and one direction, which keeps the sprite mapping in
|
|
||||||
[Chapter 11](11-characters.md) from needing a mapping per diagonal. A game that
|
|
||||||
wants diagonals binds its own handlers that `AKGL_BITMASK_ADD` without clearing, and
|
|
||||||
maps the extra combinations.
|
|
||||||
|
|
||||||
**The `_off` handlers clear exactly two fields.** `ax` and `tx` for the horizontal
|
|
||||||
pair, `ay` and `ty` for the vertical. They do **not** touch `ex`/`ey` and they do
|
|
||||||
not touch `vx`/`vy`.
|
|
||||||
|
|
||||||
`actor.h` still says otherwise — the block comment above them describes the `_off`
|
|
||||||
handlers as "zeroing acceleration, thrust, environmental velocity and velocity on
|
|
||||||
that axis", and `akgl_actor_cmhf_up_off` and `_down_off` each carry a `@note`
|
|
||||||
saying they zero `ey`. **They did, and that was a defect**: `ey` is where the arcade
|
|
||||||
backend accumulates gravity, so tapping down mid-jump stopped the character in the
|
|
||||||
air. Velocity was never theirs to clear either — `simulate` recomputes `v` as
|
|
||||||
`e + t` every step. Those notes predate the fix.
|
|
||||||
|
|
||||||
> **Known defect (`TODO.md`, "Arcade physics feel").** There is no friction and no
|
|
||||||
> deceleration: zeroing `tx` on release **stops the actor dead**. Correct for Zelda,
|
|
||||||
> wrong for Mario. Bind your own `_off` handler that decays `tx` over time if you
|
|
||||||
> want momentum.
|
|
||||||
|
|
||||||
Releasing left while holding right also stops the rightward movement, since
|
|
||||||
`left_off` clears the whole x axis whichever way the actor was going.
|
|
||||||
|
|
||||||
## Warning: an error inside `akgl_registry_iterate_actor` exits the process
|
|
||||||
|
|
||||||
`akgl_registry_iterate_actor` is an `SDL_EnumerateProperties` callback. SDL
|
|
||||||
callbacks return `void`, so it has nowhere to propagate an error to — it ends in
|
|
||||||
`FINISH_NORETURN`, which logs the stack trace and then calls libakerror's
|
|
||||||
unhandled-error handler, **whose default implementation calls `akerr_exit()` and
|
|
||||||
terminates the process**.
|
|
||||||
|
|
||||||
Everything it can go wrong on is ordinary content:
|
|
||||||
|
|
||||||
- a registry key with no pointer behind it — `AKERR_KEY`;
|
|
||||||
- an actor whose `updatefunc` fails;
|
|
||||||
- `akgl_tilemap_scale_actor` failing;
|
|
||||||
- an actor whose `renderfunc` fails;
|
|
||||||
- a `NULL` `userdata` — the iterator is required despite the `void *`.
|
|
||||||
|
|
||||||
**A wrong name in an asset file therefore kills the game rather than skipping an
|
|
||||||
actor.** This is not theoretical: it is the first thing a reader hits when a sprite
|
|
||||||
or character name is misspelled.
|
|
||||||
|
|
||||||
`akgl_game_update` does not use this callback — it sweeps the actor pool directly —
|
|
||||||
so you reach it by driving a registry sweep yourself, which is what
|
|
||||||
`util/charviewer.c` does:
|
|
||||||
|
|
||||||
```c norun
|
|
||||||
SDL_EnumerateProperties(AKGL_REGISTRY_ACTOR, &akgl_registry_iterate_actor, (void *)&opflags);
|
|
||||||
```
|
|
||||||
|
|
||||||
(`norun` because it needs a live registry and a populated `opflags`; it is quoted to
|
|
||||||
show the shape, and `util/charviewer.c` is the compiled instance of it.)
|
|
||||||
|
|
||||||
The same hazard is in `akgl_character_state_sprites_iterate`, which
|
|
||||||
**`akgl_heap_release_character` calls on every character release** — so that one is
|
|
||||||
on an ordinary path, not an unusual one. See [Chapter 11](11-characters.md).
|
|
||||||
|
|
||||||
If a game should survive these, install your own handler:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
|
|
||||||
static akerr_ErrorContext *last_callback_error = NULL;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The default akerr_handler_unhandled_error calls akerr_exit(). Replacing it
|
|
||||||
* is the only way an error raised inside an SDL callback does not take the
|
|
||||||
* process with it. Take a reference: the context is released after this
|
|
||||||
* returns.
|
|
||||||
*/
|
|
||||||
static void survive_unhandled_error(akerr_ErrorContext *errctx)
|
|
||||||
{
|
|
||||||
if ( errctx == NULL ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LOG_ERROR(errctx);
|
|
||||||
errctx->refcount += 1;
|
|
||||||
last_callback_error = errctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
void install_survivable_handler(void)
|
|
||||||
{
|
|
||||||
akerr_handler_unhandled_error = &survive_unhandled_error;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note what surviving costs you: the sweep carries on with the *next* entry, and the
|
|
||||||
actor that failed simply did not update or draw this frame. Check
|
|
||||||
`last_callback_error` somewhere, or you have traded a loud crash for a silent one.
|
|
||||||
|
|
||||||
## The rest of the struct
|
|
||||||
|
|
||||||
A few fields worth calling out; the full annotated list is in the generated Doxygen
|
|
||||||
for `akgl_Actor`.
|
|
||||||
|
|
||||||
| Field | Note |
|
|
||||||
|---|---|
|
|
||||||
| `visible` | Deliberate hiding. Off-camera actors are skipped separately. |
|
|
||||||
| `scale` | Draw scale. Written by `akgl_tilemap_scale_actor`, or forced to 1.0 when tilemap scaling is off — **it is overwritten every frame**, so setting it by hand does not stick. |
|
|
||||||
| `actorData` | Your per-actor data. The library never reads or frees it. |
|
|
||||||
| `curSpriteFrameId` | Index into the sprite's `frameids` playlist, **not** a frame number on the sheet. See [Chapter 10](10-spritesheets-and-sprites.md). |
|
|
||||||
| `movetimer` | Unused. Nothing in the library reads or writes it. |
|
|
||||||
| `vx`/`vy`/`vz` | Recomputed each step as `e + t`. Writing them directly is overwritten — except on a child, where they are the offset. |
|
|
||||||
|
|
||||||
## Known defects worth knowing here
|
|
||||||
|
|
||||||
- **The parent/child offset is double-counted at draw time.** See above.
|
|
||||||
- **`akgl_heap_release_actor` recurses with no cycle check.**
|
|
||||||
- **Long names register and unregister under different keys.**
|
|
||||||
`akgl_actor_initialize` writes the registry entry under the *caller's* `name`
|
|
||||||
pointer while `akgl_heap_release_actor` clears it under the actor's truncated
|
|
||||||
128-byte copy. Identical for ordinary names; divergent past 127 bytes, and then
|
|
||||||
the registry keeps an entry pointing at a zeroed slot. Related to `TODO.md`,
|
|
||||||
"Truncated registry keys can collide".
|
|
||||||
- **`akgl_actor_initialize` silently replaces a same-named actor**, and the one it
|
|
||||||
displaced becomes unreachable rather than being released.
|
|
||||||
- **No friction on release**, and **no terminal velocity** under gravity —
|
|
||||||
`TODO.md`, "Arcade physics feel". [Chapter 14](14-physics.md) covers both.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 11](11-characters.md) — the template, and the state-to-sprite map.
|
|
||||||
- [Chapter 14](14-physics.md) — what happens to `t`, `e`, `v` and `a` each step.
|
|
||||||
- [Chapter 16](16-input.md) — control maps, and binding the handlers above.
|
|
||||||
- [Chapter 08](08-rendering.md) — where `renderfunc` gets called from.
|
|
||||||
- [Chapter 04](04-errors.md) — `AKGL_ERR_LOGICINTERRUPT` and the rest of the
|
|
||||||
status tables.
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
# 13. Tilemaps
|
|
||||||
|
|
||||||
libakgl does not have a map format. It has a *loader* for somebody else's, and the
|
|
||||||
somebody else is the [Tiled map editor](https://www.mapeditor.org/). Levels are authored in
|
|
||||||
Tiled, saved as JSON (`.tmj`), and read by `akgl_tilemap_load`.
|
|
||||||
|
|
||||||
That division is deliberate, and it means this chapter owes you almost nothing about the
|
|
||||||
format itself. What a layer is, what a tileset is, how `firstgid` works, how custom
|
|
||||||
properties are stored — all of that is
|
|
||||||
[the Tiled JSON map format reference](https://doc.mapeditor.org/en/stable/reference/json-map-format/),
|
|
||||||
maintained by the people who write the editor. Read it there.
|
|
||||||
|
|
||||||
What this chapter owes you is the part Tiled cannot tell you: **which subset of the format
|
|
||||||
libakgl actually reads, what it refuses, the three things it adds on top, and how big the
|
|
||||||
resulting object is.** All four have bitten somebody.
|
|
||||||
|
|
||||||
## The size of an `akgl_Tilemap`
|
|
||||||
|
|
||||||
Start here, because it is the one that produces a crash rather than an error message.
|
|
||||||
|
|
||||||
```text
|
|
||||||
sizeof(akgl_Tilemap) = 26,388,008 bytes = 25.2 MiB
|
|
||||||
|
|
||||||
layers[16] 16 x 1,120,296 = 17.1 MiB 512x512 ints of tile data, plus
|
|
||||||
128 x 560-byte objects, per layer
|
|
||||||
tilesets[16] 16 x 528,944 = 8.1 MiB 65,536 x 2 ints of tile offsets,
|
|
||||||
plus a PATH_MAX image path, each
|
|
||||||
everything else = ~0.0 MiB geometry, perspective, physics
|
|
||||||
```
|
|
||||||
|
|
||||||
**An `akgl_Tilemap` belongs in static storage and nowhere else.** A default thread stack is
|
|
||||||
8 MiB on Linux; this object is three times that, so a local one blows the stack before the
|
|
||||||
loader writes its first byte, and it does so as a segfault with no error context. That is
|
|
||||||
why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
|
||||||
`src/game.c`, and why `akgl_gamemap` is a pointer to it rather than a copy.
|
|
||||||
|
|
||||||
The bounds are fixed at compile time, so the size is fixed too. Every field is present
|
|
||||||
whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same
|
|
||||||
25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and
|
|
||||||
changes the ABI — see [Chapter 23](23-appendix-limits.md).
|
|
||||||
|
|
||||||
## Limits
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/tilemap.h
|
|
||||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
|
||||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
|
||||||
/** @brief Tallest map, in tiles. */
|
|
||||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
|
||||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
|
||||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
|
||||||
/** @brief Tilesets per map. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
|
||||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
|
||||||
/** @brief Longest tileset name. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
|
||||||
/** @brief Longest resolved tileset image path. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
|
||||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
|
||||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
|
||||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
|
||||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
|
||||||
```
|
|
||||||
|
|
||||||
Two of these carry a wrinkle worth knowing before you lay out a level.
|
|
||||||
|
|
||||||
**The width and height bound is on the product, and it is exclusive.** `akgl_tilemap_load`
|
|
||||||
refuses when `width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, so
|
|
||||||
262,144 cells is one too many: **a literal 512x512 map is rejected with
|
|
||||||
`AKERR_OUTOFBOUNDS`**, and 512x511 is the largest rectangle that loads. The same
|
|
||||||
off-by-one applies per tile layer in `akgl_tilemap_load_layer_tile`. Nothing is wrong with
|
|
||||||
either check other than the `>=`; it is recorded here because "the maximum is 512x512" is
|
|
||||||
what the constant names say and is not what the code does.
|
|
||||||
|
|
||||||
**The doc comment on `AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` is stale.** It says the loader
|
|
||||||
does not enforce it. The loader does — `akgl_tilemap_load_layer_objects` checks `j` at the
|
|
||||||
top of the loop body and raises `AKERR_OUTOFBOUNDS` naming the layer, and
|
|
||||||
`akgl_tilemap_load_tilesets` does the same against `AKGL_TILEMAP_MAX_TILESETS`. Both were
|
|
||||||
genuinely unbounded and both were fixed in 0.5.0 (`TODO.md`, "Found while rewriting the
|
|
||||||
Doxygen comments" item 17), with fixtures in `tests/tilemap.c` at exactly 128, 129 and 17.
|
|
||||||
Trust the code.
|
|
||||||
|
|
||||||
## What the loader accepts
|
|
||||||
|
|
||||||
The loader reads a strict subset of the TMJ format. Everything in this section is a
|
|
||||||
requirement, not a suggestion: a key that is absent raises `AKERR_KEY` and a key with the
|
|
||||||
wrong JSON type raises `AKERR_TYPE`, both naming what was wanted. See
|
|
||||||
[Chapter 4](04-errors.md) for what those statuses mean in libakgl.
|
|
||||||
|
|
||||||
| Level | Members `akgl_tilemap_load` requires | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Map root | `tilewidth`, `tileheight`, `width`, `height`, `layers`, `tilesets` | `orientation` is read from nowhere and forced to 0; isometric is not implemented |
|
|
||||||
| Every layer | `id`, `opacity`, `visible`, `x`, `y`, `type` | A `type` other than the three below keeps these fields and is otherwise skipped |
|
|
||||||
| `tilelayer` | `width`, `height`, `data` | `data` must be a plain array of integers |
|
|
||||||
| `imagelayer` | `image` | Layer `width`/`height` are taken from the loaded texture, not the JSON |
|
|
||||||
| `objectgroup` | `objects` | See below — every object has requirements of its own |
|
|
||||||
| Every object | `name`, `x`, `y`, `visible`, `type` | **Including plain rectangles.** An object with no `type` member fails the whole load |
|
|
||||||
| Every tileset | `columns`, `firstgid`, `imagewidth`, `imageheight`, `margin`, `spacing`, `tilecount`, `tilewidth`, `tileheight`, `name`, `image` | The **embedded** form; see below |
|
|
||||||
|
|
||||||
Three of these deserve their own paragraph.
|
|
||||||
|
|
||||||
**Tilesets must be embedded, not external.** Tiled writes a tileset into the map's
|
|
||||||
`tilesets` array in one of two shapes: the whole tileset inline, or a two-member stub
|
|
||||||
`{"firstgid": N, "source": "tiles.tsj"}` pointing at a separate file.
|
|
||||||
`akgl_tilemap_load_tilesets_each` reads `columns`, `image` and the rest directly out of the
|
|
||||||
array element and there is no code anywhere in `src/` that opens a `source` file, so **an
|
|
||||||
external tileset reference fails with `AKERR_KEY` on the missing `columns`.** Turn
|
|
||||||
*Embed tileset* on when you save, or use Tiled's *Map → Embed Tilesets* command. The
|
|
||||||
shipped fixture `tests/assets/testmap.tmj` is embedded, which is what makes it load.
|
|
||||||
|
|
||||||
**Every object needs a `type` string, and `type` is where the extensions live.** The loader
|
|
||||||
reads it on every object in an object group and compares it against `"actor"` and
|
|
||||||
`"perspective"`; anything else is recorded in the layer's object array and otherwise
|
|
||||||
ignored. An object with the member missing entirely does not reach that comparison — it
|
|
||||||
fails the load. If your editor writes the object's class under a different member name,
|
|
||||||
that is the drift to check first; Tiled's own field naming has changed across releases, and
|
|
||||||
the fixture in this repository is a Tiled 1.8.2 export.
|
|
||||||
|
|
||||||
**The map file's directory is the root every path resolves against**, so a map and its art
|
|
||||||
move together. The two resolvers are not the same, though, and the difference matters:
|
|
||||||
|
|
||||||
| Path | Resolved by | Consequence |
|
|
||||||
|---|---|---|
|
|
||||||
| A tileset's `image` | `akgl_path_relative` | Canonicalized against the map's directory; an absolute path works |
|
|
||||||
| An image layer's `image` | `aksl_snprintf("%s/%s", ...)` | A plain join. **Not** canonicalized, so an absolute path becomes `/maps//art/sky.png` and does not open |
|
|
||||||
|
|
||||||
Both are bounded at `AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE` (`PATH_MAX`). The image-layer
|
|
||||||
join raises `AKERR_OUTOFBOUNDS` naming both lengths when it does not fit; it used to
|
|
||||||
truncate silently, and an over-long path reported itself as a missing file with a name the
|
|
||||||
caller never wrote.
|
|
||||||
|
|
||||||
## libakgl's three extensions to Tiled
|
|
||||||
|
|
||||||
None of these are part of the TMJ format. They are conventions the loader imposes on
|
|
||||||
custom properties and object types, and Tiled will happily author all three without
|
|
||||||
knowing what they mean.
|
|
||||||
|
|
||||||
### 1. Object type `actor` spawns a real actor
|
|
||||||
|
|
||||||
An object whose `type` is the string `"actor"` does not stay a rectangle. The loader
|
|
||||||
claims an actor from the pool, initializes it, binds a character, publishes it in
|
|
||||||
`AKGL_REGISTRY_ACTOR`, and copies the object's position, visibility and layer index onto
|
|
||||||
it. The object's `name` is the registry key. See [Chapter 12](12-actors.md) for what an
|
|
||||||
actor is and [Chapter 11](11-characters.md) for what a character supplies.
|
|
||||||
|
|
||||||
Two custom properties are required on the object:
|
|
||||||
|
|
||||||
| Property | Tiled type | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `character` | `string` | Name of a character already in `AKGL_REGISTRY_CHARACTER` |
|
|
||||||
| `state` | `int` | The actor's initial `AKGL_ACTOR_STATE_*` bitmask, **as a number** |
|
|
||||||
|
|
||||||
**`state` is an integer here, and only here.** Character JSON accepts the string-array form
|
|
||||||
— `["AKGL_ACTOR_STATE_ALIVE", "AKGL_ACTOR_STATE_FACE_DOWN"]` — because
|
|
||||||
`AKGL_ACTOR_STATE_STRING_NAMES` exists to decode it. `akgl_tilemap_load_layer_object_actor`
|
|
||||||
calls `akgl_get_json_properties_integer`, which demands a property Tiled declared as
|
|
||||||
`"int"`, so a map that spells the state as names raises `AKERR_TYPE`. Add up the bit values
|
|
||||||
from [Chapter 12](12-actors.md) and write the sum.
|
|
||||||
|
|
||||||
Two consequences of the registry being the identity:
|
|
||||||
|
|
||||||
- **The characters have to be loaded before the map.** An unregistered `character` name
|
|
||||||
comes back as `AKERR_KEY` from `akgl_actor_set_character`, in the middle of a partly
|
|
||||||
loaded map.
|
|
||||||
- **Two objects naming one actor do not make two actors.** The second placement takes
|
|
||||||
another reference on the first and overwrites its position, so the actor ends up wherever
|
|
||||||
the last object put it. That is the mechanism for placing one actor from two layers; it
|
|
||||||
is not a way to spawn a crowd.
|
|
||||||
|
|
||||||
An `actor` object with an empty `name` is refused with `AKERR_KEY` — an actor has to be
|
|
||||||
addressable.
|
|
||||||
|
|
||||||
### 2. A `physics.model` property gives the map its own physics
|
|
||||||
|
|
||||||
A custom property named `physics.model` on the **map root** hands the map's name to
|
|
||||||
`akgl_physics_factory` and stores the resulting backend in `map->physics`, then sets
|
|
||||||
`map->use_own_physics`. Six more properties configure it, each defaulting to `0.0` when
|
|
||||||
absent:
|
|
||||||
|
|
||||||
| Property | Tiled type | Sets |
|
|
||||||
|---|---|---|
|
|
||||||
| `physics.model` | `string` | Which backend — `null` or `arcade`. See [Chapter 14](14-physics.md) |
|
|
||||||
| `physics.gravity.x` / `.y` / `.z` | `float` | The backend's gravity constants |
|
|
||||||
| `physics.drag.x` / `.y` / `.z` | `float` | The backend's drag coefficients |
|
|
||||||
|
|
||||||
A swimming level and a walking level then differ by data rather than by code.
|
|
||||||
|
|
||||||
**Nothing in the library acts on `use_own_physics`.** `akgl_game_update` steps the global
|
|
||||||
`akgl_physics` and never looks at the map's. The flag is a signal to *you*: read it after
|
|
||||||
loading and point `akgl_physics` at `map->physics` if you want it honoured. The example
|
|
||||||
below does exactly that.
|
|
||||||
|
|
||||||
A map with no `properties` array at all, or with properties but no `physics.model`, is the
|
|
||||||
normal case: `akgl_tilemap_load_physics` returns success and the map uses the game's global
|
|
||||||
backend. The `AKERR_KEY` handlers in that function are the "map did not ask" path, not
|
|
||||||
error paths. A `physics.model` naming a backend that does not exist *is* an error.
|
|
||||||
|
|
||||||
### 3. `p_foreground` and `p_vanishing` define the pseudo-3D band
|
|
||||||
|
|
||||||
Two objects in an object layer, each with `"type": "perspective"` and a `scale` custom
|
|
||||||
property declared `float`, define a band down the screen over which actors are scaled. That
|
|
||||||
is what makes a character look further away as they walk up a top-down map.
|
|
||||||
|
|
||||||
```text
|
|
||||||
y = 0 ----------------------------------------------------- top of the map
|
|
||||||
.
|
|
||||||
. actors above p_vanishing_y stay at
|
|
||||||
. p_vanishing_scale
|
|
||||||
p_vanishing_y ----------- [tiny actor] scale = p_vanishing_scale
|
|
||||||
/ \
|
|
||||||
/ \ linear interpolation between the
|
|
||||||
/ \ two rows, at p_rate per pixel of y
|
|
||||||
/ \
|
|
||||||
p_foreground_y --------- [ big actor ] scale = p_foreground_scale
|
|
||||||
actors below here stay at
|
|
||||||
p_foreground_scale
|
|
||||||
```
|
|
||||||
|
|
||||||
The names are matched literally: `p_foreground` sets the row where actors are at full size,
|
|
||||||
`p_vanishing` the row where they are smallest. **Both the type and the name have to be
|
|
||||||
right.** The loader checks `type == "perspective"` first and only then compares the name,
|
|
||||||
so an object named `p_vanishing` with type `"object"` is silently just an object — no error,
|
|
||||||
no perspective, and a very confusing afternoon.
|
|
||||||
|
|
||||||
Each marker contributes its `y` and its `scale`; `p_rate` is derived at load as the scale
|
|
||||||
change per pixel between the two. **A marker at `y = 0` disables perspective**, because the
|
|
||||||
rate is only computed when both `p_foreground_y` and `p_vanishing_y` are non-zero — and a
|
|
||||||
map with no markers has both scales at 1.0 and a rate of 0, so the whole feature is a no-op
|
|
||||||
rather than a special case. Perspective markers are forced invisible: they are geometry,
|
|
||||||
not scenery.
|
|
||||||
|
|
||||||
`akgl_tilemap_scale_actor` applies the band, writing only the actor's `scale`. It is called
|
|
||||||
per actor by `akgl_game_update` against the **global** `akgl_gamemap` when the frame's
|
|
||||||
iterator carries `AKGL_ITERATOR_OP_TILEMAPSCALE`; see [Chapter 7](07-the-game-and-the-frame.md).
|
|
||||||
The marker heights (`p_foreground_h`, `p_vanishing_h`) are recorded and not used by the
|
|
||||||
current rate calculation.
|
|
||||||
|
|
||||||
## A minimal map
|
|
||||||
|
|
||||||
Two 16-pixel tiles square, one tile layer, one embedded tileset, and one actor object. The
|
|
||||||
state value `17` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN` — 16 plus 1, from
|
|
||||||
the bit table in [Chapter 12](12-actors.md). Add the values you want; there is no symbolic
|
|
||||||
form on this side.
|
|
||||||
|
|
||||||
```json kind=tilemap setup=tilemap
|
|
||||||
{
|
|
||||||
"compressionlevel": -1,
|
|
||||||
"height": 2,
|
|
||||||
"infinite": false,
|
|
||||||
"nextlayerid": 3,
|
|
||||||
"nextobjectid": 2,
|
|
||||||
"orientation": "orthogonal",
|
|
||||||
"renderorder": "right-down",
|
|
||||||
"tiledversion": "1.8.2",
|
|
||||||
"tileheight": 16,
|
|
||||||
"tilewidth": 16,
|
|
||||||
"type": "map",
|
|
||||||
"version": "1.8",
|
|
||||||
"width": 2,
|
|
||||||
"layers": [
|
|
||||||
{
|
|
||||||
"data": [1, 2, 3, 4],
|
|
||||||
"height": 2,
|
|
||||||
"id": 1,
|
|
||||||
"name": "Tile Layer 1",
|
|
||||||
"opacity": 1,
|
|
||||||
"type": "tilelayer",
|
|
||||||
"visible": true,
|
|
||||||
"width": 2,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"draworder": "topdown",
|
|
||||||
"id": 2,
|
|
||||||
"name": "Object Layer 1",
|
|
||||||
"opacity": 1,
|
|
||||||
"type": "objectgroup",
|
|
||||||
"visible": true,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"gid": 195,
|
|
||||||
"height": 16,
|
|
||||||
"id": 1,
|
|
||||||
"name": "testactor",
|
|
||||||
"rotation": 0,
|
|
||||||
"type": "actor",
|
|
||||||
"visible": true,
|
|
||||||
"width": 16,
|
|
||||||
"x": 16,
|
|
||||||
"y": 16,
|
|
||||||
"properties": [
|
|
||||||
{ "name": "character", "type": "string", "value": "testcharacter" },
|
|
||||||
{ "name": "state", "type": "int", "value": 17 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tilesets": [
|
|
||||||
{
|
|
||||||
"columns": 48,
|
|
||||||
"firstgid": 1,
|
|
||||||
"image": "assets/tileset.png",
|
|
||||||
"imageheight": 576,
|
|
||||||
"imagewidth": 768,
|
|
||||||
"margin": 0,
|
|
||||||
"name": "World_A1",
|
|
||||||
"spacing": 0,
|
|
||||||
"tilecount": 1728,
|
|
||||||
"tileheight": 16,
|
|
||||||
"tilewidth": 16
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Loading one
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/tilemap.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 25 MiB of tilemap. Static storage, never a local: a stack frame this size
|
|
||||||
* overruns the default 8 MiB thread stack before the loader writes a byte.
|
|
||||||
*/
|
|
||||||
static akgl_Tilemap townmap;
|
|
||||||
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *load_town(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
/* Reusing a map struct leaks the previous map's textures unless this runs
|
|
||||||
* first -- akgl_tilemap_load zeroes rather than releases. */
|
|
||||||
PASS(errctx, akgl_tilemap_release(&townmap));
|
|
||||||
PASS(errctx, akgl_tilemap_load("assets/town.tmj", &townmap));
|
|
||||||
|
|
||||||
/* The map may have brought its own physics with it. */
|
|
||||||
if ( townmap.use_own_physics == true ) {
|
|
||||||
akgl_physics = &townmap.physics;
|
|
||||||
}
|
|
||||||
akgl_gamemap = &townmap;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Loading a map has side effects well beyond the destination struct: textures are uploaded,
|
|
||||||
actors are created in the pool, and names are published in the registry. So **the renderer,
|
|
||||||
the pools, the registries and every character the map names have to be in place first.**
|
|
||||||
The startup order that produces that is in [Chapter 7](07-the-game-and-the-frame.md).
|
|
||||||
|
|
||||||
Drawing is per layer. `akgl_tilemap_draw(map, viewport, layeridx)` draws one layer clipped
|
|
||||||
to a viewport in map pixels — usually `akgl_camera` — and draws the tiles at the viewport's
|
|
||||||
edges partially, so scrolling is smooth rather than snapping to the grid. An image layer
|
|
||||||
ignores the viewport and is drawn once at the origin. `akgl_render_2d_draw_world` makes one
|
|
||||||
pass per layer; see [Chapter 8](08-rendering.md).
|
|
||||||
|
|
||||||
## Known defects
|
|
||||||
|
|
||||||
Documented here because this is where a reader hits them. Each is cross-referenced to
|
|
||||||
`TODO.md`.
|
|
||||||
|
|
||||||
**A failed load releases nothing.** `akgl_tilemap_load` zeroes the destination up front and
|
|
||||||
then loads physics, geometry, layers and tilesets in order. If any of that fails, the
|
|
||||||
textures already uploaded and the actors already created stay exactly where they are, and
|
|
||||||
the destination holds a half-built map. `CLEANUP` gives back the JSON document and one pool
|
|
||||||
string and nothing else. **Treat a failed load as needing `akgl_tilemap_release` and a
|
|
||||||
fresh actor registry** before you try again — otherwise the retry inherits the corpse, and
|
|
||||||
`akgl_tilemap_load` will zero the struct over the previous attempt's texture pointers and
|
|
||||||
leak them.
|
|
||||||
|
|
||||||
**`akgl_tilemap_release` does not release the actors an object layer created.** It destroys
|
|
||||||
tileset and image-layer textures and clears each pointer as it goes — that half is correct
|
|
||||||
as of 0.5.0, and a second release is safe rather than a use-after-free (`TODO.md`, "Known
|
|
||||||
and still open" item 2). The actors, and their references on characters and sprites, are
|
|
||||||
yours to release. The header's `@warning` describing a tileset double-free is stale; the
|
|
||||||
loop it describes was fixed and `tests/tilemap.c` releases the fixture three times and
|
|
||||||
asserts every texture pointer is `NULL`.
|
|
||||||
|
|
||||||
**`akgl_tilemap_draw` does not bounds-check `layeridx`.** An index at or past `numlayers`
|
|
||||||
reads a neighbouring layer, and one past 16 reads past the array. `akgl_tilemap_draw_tileset`
|
|
||||||
checks its upper bound but not for a negative index.
|
|
||||||
|
|
||||||
**A tileset's `margin` is recorded and never applied.** `akgl_tilemap_compute_tileset_offsets`
|
|
||||||
steps by tile size plus `spacing` and ignores `margin` entirely, so a tileset image with a
|
|
||||||
border produces offsets shifted by it — every tile drawn from slightly the wrong place. The
|
|
||||||
FIXME is at `src/tilemap.c:187`. Author tilesets with `margin: 0` until it is fixed.
|
|
||||||
|
|
||||||
**A second tileset wastes its whole offset table.** The offset table is indexed by local
|
|
||||||
tile id from 0, so tileset B's entries sit at the front of B's own 65,536-entry table while
|
|
||||||
`akgl_tilemap_draw` indexes it by `tilenum - firstgid` — correct, but it means every
|
|
||||||
tileset pays 512 KiB regardless of how many tiles it has. The FIXME on
|
|
||||||
`akgl_Tileset::tile_offsets` explains it. Consequence for you: prefer one large tileset per
|
|
||||||
map to five small ones.
|
|
||||||
|
|
||||||
**Tileset images do not go through the spritesheet registry**, so an image shared between
|
|
||||||
two maps is loaded twice, into two textures.
|
|
||||||
|
|
||||||
**`numlayers` is set before the layer bound is checked.** `akgl_tilemap_load_layers` assigns
|
|
||||||
the JSON array's length and *then* refuses a seventeenth layer, so on that failure path the
|
|
||||||
struct briefly claims more layers than it has. It is only reachable on a path that has
|
|
||||||
already failed, but do not read `numlayers` off a map whose load raised.
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# 14. Physics
|
|
||||||
|
|
||||||
The physics subsystem has the same shape as the renderer: a record of function pointers
|
|
||||||
plus an initializer that fills it in. Two backends ship. `null` accepts every call and
|
|
||||||
changes nothing, which is what a menu screen or a test harness wants. `arcade` applies
|
|
||||||
gravity, drag, thrust and a speed cap, which is what a 2D game wants.
|
|
||||||
|
|
||||||
**There is no "physics body" type.** The body *is* the actor plus the character it
|
|
||||||
instantiates. An actor carries the position and the three velocity terms; its character
|
|
||||||
supplies the accelerations and the top speeds. Nothing else participates. If you have read
|
|
||||||
[Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) you have already met every
|
|
||||||
field this chapter uses.
|
|
||||||
|
|
||||||
## The model
|
|
||||||
|
|
||||||
```text
|
|
||||||
thrust (tx, ty, tz) the actor's own effort, while it is moving
|
|
||||||
+
|
|
||||||
environ (ex, ey, ez) gravity, less drag -- accumulates across steps
|
|
||||||
=
|
|
||||||
velocity(vx, vy, vz) recomputed every step; writing it directly is overwritten
|
|
||||||
(a CHILD actor is the exception -- see below)
|
|
||||||
|
|
||||||
caps (sx, sy, sz) from the character. Caps the THRUST VECTOR, not velocity
|
|
||||||
accel (ax, ay, az) from the character, signed by the direction of travel
|
|
||||||
```
|
|
||||||
|
|
||||||
Two properties of that arrangement carry all the consequences.
|
|
||||||
|
|
||||||
**The cap is on thrust, not on velocity.** `sx`/`sy`/`sz` are the character's own top
|
|
||||||
speed, not a speed limit on the world, and `akgl_physics_arcade_gravity` touches only the
|
|
||||||
environmental term. So a falling actor accelerates straight past its stated top speed —
|
|
||||||
which is the point, for a falling one. It also means there is no terminal velocity; see
|
|
||||||
below.
|
|
||||||
|
|
||||||
**The cap is on the thrust *vector*, scaled to an ellipse, not on each axis separately.**
|
|
||||||
|
|
||||||
```text
|
|
||||||
ty
|
|
||||||
| Capping each axis against its own limit
|
|
||||||
......|...... admits the corner of the box: an actor
|
|
||||||
..' | '.. holding two directions got both caps
|
|
||||||
.' +---+---+ '. at once and travelled their
|
|
||||||
: | | | : diagonal -- 41% faster than either
|
|
||||||
: ----+---+---+---- : alone. Scaling the vector to the
|
|
||||||
----:------+---+---+------:---- tx ellipse keeps a character
|
|
||||||
: | | | : whose horizontal and vertical
|
|
||||||
:. +---+---+ .: speeds differ moving at the ratio
|
|
||||||
'.. | sx ..' it asked for, and makes them equal
|
|
||||||
''|.......''' where the speeds are.
|
|
||||||
|
|
|
||||||
dotted ellipse: the cap solid box: the old per-axis clamp
|
|
||||||
```
|
|
||||||
|
|
||||||
```c excerpt=src/physics.c
|
|
||||||
overshoot = 0.0f;
|
|
||||||
if ( actor->sx != 0.0f ) {
|
|
||||||
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
|
||||||
} else {
|
|
||||||
actor->tx = 0.0f;
|
|
||||||
}
|
|
||||||
if ( actor->sy != 0.0f ) {
|
|
||||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
|
||||||
} else {
|
|
||||||
actor->ty = 0.0f;
|
|
||||||
}
|
|
||||||
if ( actor->sz != 0.0f ) {
|
|
||||||
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
|
||||||
} else {
|
|
||||||
actor->tz = 0.0f;
|
|
||||||
}
|
|
||||||
if ( overshoot > 1.0f ) {
|
|
||||||
thrustscale = 1.0f / sqrtf(overshoot);
|
|
||||||
actor->tx *= thrustscale;
|
|
||||||
actor->ty *= thrustscale;
|
|
||||||
actor->tz *= thrustscale;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
An axis whose top speed is zero cannot be thrust along at all — that is what the old
|
|
||||||
per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing
|
|
||||||
by it would not end well.
|
|
||||||
|
|
||||||
**Only x and y ever accumulate thrust.** `akgl_physics_simulate` reads the
|
|
||||||
`AKGL_ACTOR_STATE_MOVING_LEFT`/`RIGHT` bits into `tx` and `MOVING_UP`/`DOWN` into `ty`.
|
|
||||||
`AKGL_ACTOR_STATE_MOVING_IN` and `MOVING_OUT` exist and nothing reads them, so `tz` is only
|
|
||||||
ever zeroed or scaled — the z axis has gravity, drag and movement but **no thrust an actor
|
|
||||||
can apply**. Set `tz` yourself from a `movementlogicfunc` if you need depth motion.
|
|
||||||
|
|
||||||
## One step, in order
|
|
||||||
|
|
||||||
`akgl_physics_simulate` is shared by both backends; what differs is the `gravity` and
|
|
||||||
`move` they point at. It walks the whole actor pool in index order, and per actor:
|
|
||||||
|
|
||||||
```text
|
|
||||||
refcount == 0 ? ------------------------------> skip (free pool slot)
|
|
||||||
parent != NULL ? -> x,y,z = parent's + own v, -> skip (never simulates)
|
|
||||||
basechar == NULL ? ------------------------------> skip (no speeds)
|
|
||||||
LAYERMASK set and actor->layer != layerid ? -------> skip
|
|
||||||
|
|
||||||
tx += ax * dt if MOVING_LEFT or MOVING_RIGHT
|
|
||||||
ty += ay * dt if MOVING_UP or MOVING_DOWN
|
|
||||||
scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside
|
|
||||||
|
|
||||||
+- ATTEMPT ----------------------------------------------------------+
|
|
||||||
| actor->movementlogicfunc(actor, dt) |
|
|
||||||
| AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
|
|
||||||
| is skipped for the rest of the tick and the loop continues |
|
|
||||||
| self->gravity(self, actor, dt) |
|
|
||||||
| ex -= ex * drag_x * dt (and y, z, each only if drag is nonzero) |
|
|
||||||
| vx = ex + tx (and y, z) |
|
|
||||||
| self->move(self, actor, dt) |
|
|
||||||
+--------------------------------------------------------------------+
|
|
||||||
|
|
||||||
after the loop: self->gravity_time = curtime
|
|
||||||
```
|
|
||||||
|
|
||||||
**A child actor is snapped and skipped.** An actor with a `parent` has its position set to
|
|
||||||
the parent's plus its own `vx`/`vy`/`vz`, read as a fixed offset rather than as a velocity,
|
|
||||||
and then it is skipped entirely. Children do not simulate. That is the mechanism a party
|
|
||||||
member or a held item is built on — see [Chapter 12](12-actors.md).
|
|
||||||
|
|
||||||
**The thrust accumulation happens *before* `movementlogicfunc`, which is what sets `ax` and
|
|
||||||
`sx`.** So the `ax` and `sx` a step uses are the ones the *previous* step's movement logic
|
|
||||||
installed, and an actor's very first step sees `sx == 0` and has its thrust zeroed. The
|
|
||||||
header's summary — "movement logic, then gravity, then drag, then velocity, then move" —
|
|
||||||
leaves that out. It is one frame of lag at the start of a movement and no player will see
|
|
||||||
it, but it is the reason a unit test that calls `simulate` once and checks `tx` gets zero.
|
|
||||||
|
|
||||||
**`gravity_time` is stamped after the loop, not before it.** A `gravity` or `move` that
|
|
||||||
raises returns straight out of `akgl_physics_simulate` without stamping, so the *next*
|
|
||||||
step measures `dt` from the older epoch and takes a double-length step — bounded by
|
|
||||||
`max_timestep`, but still a visible jump. Treat a raised status out of `simulate` as
|
|
||||||
needing a re-stamp of `gravity_time` before you continue.
|
|
||||||
|
|
||||||
## `dt` is not yours to supply
|
|
||||||
|
|
||||||
`akgl_physics_simulate` takes no timestep. It calls `SDL_GetTicksNS()` itself and measures
|
|
||||||
against `self->gravity_time`, then bounds the result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
|
|
||||||
if ( max_timestep > 0 and dt > max_timestep ) dt = max_timestep
|
|
||||||
if ( dt < 0 ) dt = 0
|
|
||||||
```
|
|
||||||
|
|
||||||
`max_timestep` comes from the `physics.max_timestep` property and defaults to
|
|
||||||
`AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` — a twentieth of a second, three frames at 60 Hz, so an
|
|
||||||
ordinary dropped frame passes through untouched and a level load does not.
|
|
||||||
|
|
||||||
**That bound is not a nicety.** Everything below it multiplies by `dt`, so one enormous
|
|
||||||
`dt` moves every actor by however far its velocity carries it over the whole stall. A
|
|
||||||
quarter-second load under platformer gravity is a hundred pixels of fall between the first
|
|
||||||
frame and the second, straight through whatever was underneath. That was a live defect
|
|
||||||
until 0.6.0: `gravity_time` was never seeded, so the first step measured the entire time
|
|
||||||
since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing
|
|
||||||
the world in slow motion through a hitch is the trade every engine makes here.
|
|
||||||
|
|
||||||
A zero or negative `max_timestep` disables the bound, for a caller who would rather have
|
|
||||||
the real elapsed time and handle it themselves.
|
|
||||||
|
|
||||||
### Stepping deterministically in a test
|
|
||||||
|
|
||||||
Because `dt` is measured rather than passed, **there is no way to step the engine by an
|
|
||||||
exact interval — but there is a way to step it by an exact bound.** `tests/physics_sim.c`
|
|
||||||
documents the only technique that holds up under load:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sim_physics.max_timestep = dt; /* the step you want */
|
|
||||||
sim_physics.gravity_time = 0; /* measured interval = whole process uptime */
|
|
||||||
sim_physics.simulate(&sim_physics, NULL);
|
|
||||||
```
|
|
||||||
|
|
||||||
Zeroing `gravity_time` makes the measured interval the process's whole uptime, which is
|
|
||||||
always past the bound, so the step taken is `max_timestep` and nothing else. The scheduler
|
|
||||||
cannot get in the way of that. The first version of that helper set `gravity_time` to
|
|
||||||
`now - dt` and let the real clock supply the step; a machine busy enough to deschedule the
|
|
||||||
process between that store and the `SDL_GetTicksNS()` inside `simulate` produced a longer
|
|
||||||
step and a red suite, exactly once, under a parallel `ctest`. **That the engine cannot be
|
|
||||||
stepped exactly is itself a finding**, recorded in `TODO.md` under "Arcade physics feel".
|
|
||||||
|
|
||||||
Two more rules from that suite, both learned the hard way. `main` must call `SDL_Init` —
|
|
||||||
without it SDL sets its clock epoch on first use, `SDL_GetTicksNS()` returns something
|
|
||||||
around 130 ns, and every `dt`-dependent assertion passes for a reason unrelated to the
|
|
||||||
code. And read the numbers each simulation prints even when it is green: a change that
|
|
||||||
keeps every assertion passing while moving the apex of a jump by 30 px is a change worth
|
|
||||||
noticing.
|
|
||||||
|
|
||||||
## Choosing a backend
|
|
||||||
|
|
||||||
```c excerpt=src/physics.c
|
|
||||||
if ( strncmp(type->data, "null", 4) == 0) {
|
|
||||||
PASS(errctx, akgl_physics_init_null(self));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
if ( strncmp(type->data, "arcade", 6) == 0) {
|
|
||||||
PASS(errctx, akgl_physics_init_arcade(self));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
|
||||||
```
|
|
||||||
|
|
||||||
**The match is on leading characters.** `"nullify"` selects `null` and `"arcadia"` selects
|
|
||||||
`arcade`; anything else raises `AKERR_KEY` quoting what was asked for. Adding a backend
|
|
||||||
means adding a name here, not a branch in the simulation.
|
|
||||||
|
|
||||||
**`akgl_game_init` does not call the factory, and there is no `physics.engine` property.**
|
|
||||||
The `@file` block and the `akgl_physics_factory` doc comment in `include/akgl/physics.h`
|
|
||||||
both say otherwise. They are wrong: `akgl_game_init` points `akgl_physics` at
|
|
||||||
`akgl_default_physics` — zeroed storage whose four method pointers are all `NULL` — and
|
|
||||||
never initializes it, and the string `physics.engine` appears nowhere in `src/`. The
|
|
||||||
startup sequence in `include/akgl/game.h` has it right: **the application calls an
|
|
||||||
initializer or the factory itself**, at step 4, after the configuration properties are set
|
|
||||||
and before the first frame. Skip that and the first `akgl_game_update` calls through a
|
|
||||||
`NULL` `simulate`.
|
|
||||||
|
|
||||||
The only caller of `akgl_physics_factory` inside the library is
|
|
||||||
`akgl_tilemap_load_physics`, driven by a map's `physics.model` custom property — see
|
|
||||||
[Chapter 13](13-tilemaps.md). `util/charviewer.c` shows the direct form,
|
|
||||||
`akgl_physics_init_null(akgl_physics)`. Correcting the header comments is filed separately;
|
|
||||||
until then, believe `game.h` and this chapter.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
#include <akgl/registry.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Bring up the arcade backend. akgl_game_init does NOT do this: it points
|
|
||||||
* akgl_physics at akgl_default_physics, which is zeroed storage whose method
|
|
||||||
* pointers are all NULL. Call an initializer, or the first akgl_game_update
|
|
||||||
* calls through a NULL simulate.
|
|
||||||
*
|
|
||||||
* Properties first, because akgl_physics_init_arcade reads them.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
|
|
||||||
/* There is no terminal-velocity setting. Drag is the mechanism: ey
|
|
||||||
* approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
|
|
||||||
PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));
|
|
||||||
|
|
||||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_physics_init_arcade` reads seven properties — `physics.gravity.x`/`.y`/`.z`,
|
|
||||||
`physics.drag.x`/`.y`/`.z` and `physics.max_timestep` — all with defaults, so an arcade
|
|
||||||
backend with no configuration behaves like the null one until something is set. **Set the
|
|
||||||
properties first.** With `AKGL_REGISTRY_PROPERTIES` uninitialized every read comes back as
|
|
||||||
its default and this silently configures zero gravity and zero drag rather than reporting
|
|
||||||
anything; see [Chapter 6](06-the-registry.md).
|
|
||||||
|
|
||||||
The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down
|
|
||||||
because y grows downward, z pulls away from the camera.
|
|
||||||
|
|
||||||
## `AKGL_ERR_LOGICINTERRUPT`
|
|
||||||
|
|
||||||
The one status in libakgl that is not a failure. Raised from an actor's
|
|
||||||
`movementlogicfunc`, it means **"skip the rest of this tick for me"** — no gravity, no drag,
|
|
||||||
no move for that actor this step — and `akgl_physics_simulate` handles it and carries on to
|
|
||||||
the next actor. It is a control signal wearing an error's clothes.
|
|
||||||
|
|
||||||
**Only `movementlogicfunc` gets that treatment.** The `gravity` and `move` calls return
|
|
||||||
straight out of the loop on any status, `AKGL_ERR_LOGICINTERRUPT` included, so a backend
|
|
||||||
must never use it to opt an actor out from there — there it aborts the whole step, leaving
|
|
||||||
the actors already processed advanced and the rest not.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A movement logic function that pins an actor to a floor at y = 400.
|
|
||||||
*
|
|
||||||
* With no collision world attached the backend consults nothing, so standing
|
|
||||||
* on something is the caller's job. Doing it here rather than after the step is
|
|
||||||
* what keeps the actor from being drawn inside the floor for one frame. A game
|
|
||||||
* that attaches a world (Chapter 15) deletes this function instead of writing
|
|
||||||
* it.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
|
|
||||||
/* The default logic still has to run: it is what copies the character's
|
|
||||||
* speeds and accelerations onto the actor. */
|
|
||||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
|
||||||
|
|
||||||
if ( obj->y >= 400.0f ) {
|
|
||||||
obj->y = 400.0f;
|
|
||||||
/* ey is where gravity accumulates. Zero it, or the actor keeps
|
|
||||||
* "falling" into the floor and cannot jump out of it. */
|
|
||||||
obj->ey = 0.0f;
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Raising this from movement logic means "skip the rest of this tick for me".
|
|
||||||
* akgl_physics_simulate handles it and moves on to the next actor; the actor
|
|
||||||
* gets no gravity, no drag and no move this step. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
(void)dt;
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## What is not implemented
|
|
||||||
|
|
||||||
Stated plainly, because all of it is reachable by an ordinary-looking call and none of it
|
|
||||||
announces itself at compile time.
|
|
||||||
|
|
||||||
**Collision is [Chapter 15](15-collision.md), and it is opt-in.** A backend with no
|
|
||||||
collision world attached does exactly what it did before collision existed: `move` is
|
|
||||||
`position += velocity * dt` and consults nothing. Attach a world and the step resolves after
|
|
||||||
every sub-move.
|
|
||||||
|
|
||||||
What remains missing here is the *feel*, below, and that is a different list from the one
|
|
||||||
this section used to carry.
|
|
||||||
|
|
||||||
### Four gaps in the feel
|
|
||||||
|
|
||||||
Found by `tests/physics_sim.c`, which runs the arcade backend the way a game does — a
|
|
||||||
Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed —
|
|
||||||
and prints what the actor actually did. Three other defects that suite found *are* fixed in
|
|
||||||
0.6.0. These four are not. All are in `TODO.md`, "Arcade physics feel".
|
|
||||||
|
|
||||||
| Gap | What you see | The workaround today |
|
|
||||||
|---|---|---|
|
|
||||||
| **No terminal velocity** | Gravity adds `gravity_y * dt` to `ey` every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going | `physics.drag.y`. It makes `ey` approach `gravity_y / drag_y` instead of diverging. That is the only brake, and it is not documented as one anywhere else |
|
|
||||||
| **Releasing a direction stops the actor dead** | `akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release | Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; `ax` is the only rate and it applies only while a direction is held. Decay `tx` yourself in `movementlogicfunc` |
|
|
||||||
| **Explicit Euler, so trajectories are frame-rate dependent** | The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step | Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade `max_timestep` makes |
|
|
||||||
| **A large drag coefficient inverts velocity** | `ex -= ex * drag_x * dt` is a first-order decay, so it only decays for `drag * dt < 1`. Past that it overshoots zero; past `2` it diverges. **Nothing rejects it** | With `dt` bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But `max_timestep` is caller-settable, so keep `drag * max_timestep` below 1 |
|
|
||||||
|
|
||||||
The `_off` handlers used to be worse: they zeroed `ay`, `ey`, `ty` and `vy` together, and
|
|
||||||
`ey` is where gravity accumulates, so tapping down mid-jump stopped the character in the
|
|
||||||
air. They clear `ax`/`tx` (or `ay`/`ty`) and nothing else now. Velocity was never theirs to
|
|
||||||
clear either — `simulate` recomputes `v` as `e + t` every step.
|
|
||||||
|
|
||||||
## Statuses
|
|
||||||
|
|
||||||
`akgl_physics_simulate` propagates whatever an actor's `movementlogicfunc`, or the
|
|
||||||
backend's `gravity`, `move` or `collide`, raises, and **the first failure aborts the whole
|
|
||||||
step** —
|
|
||||||
the actors already processed are advanced and the rest are not. The status meanings are in
|
|
||||||
[Chapter 4](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL`
|
|
||||||
`self` or `self->move`), `AKERR_VALUE` and `ERANGE` (a
|
|
||||||
`physics.*` property that is not a number, or does not fit a `double`), and
|
|
||||||
`AKGL_ERR_LOGICINTERRUPT`, which is not a failure.
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
# 15. Collision
|
|
||||||
|
|
||||||
Collision is **opt-in**. A physics backend with no collision world attached behaves exactly
|
|
||||||
as one did before any of this existed and costs one comparison per actor per step, so a game
|
|
||||||
that does not want it pays nothing and a game written before it existed keeps working.
|
|
||||||
|
|
||||||
Turning it on is three things: a world, a shape on anything that should collide, and — for
|
|
||||||
map geometry — a `collidable` property on the tile layers that are solid.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/collision.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/physics.h>
|
|
||||||
|
|
||||||
static akgl_CollisionWorld world;
|
|
||||||
|
|
||||||
/* After the map is loaded and the physics backend is initialized. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *turn_collision_on(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
|
|
||||||
PASS(errctx, akgl_collision_bind_tilemap(&world, akgl_gamemap));
|
|
||||||
akgl_physics->collision = &world;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`NULL` for the partitioner name means the default, which is the one to use. Binding a map
|
|
||||||
takes the cell size from its tiles, so the two arguments above are overwritten immediately
|
|
||||||
— they matter only for a world with no map.
|
|
||||||
|
|
||||||
## Where it runs in the step
|
|
||||||
|
|
||||||
Collision resolves **after** the move, not before it.
|
|
||||||
|
|
||||||
```text
|
|
||||||
movementlogicfunc
|
|
||||||
v
|
|
||||||
gravity
|
|
||||||
v
|
|
||||||
drag
|
|
||||||
v
|
|
||||||
v = e + t
|
|
||||||
v
|
|
||||||
+--------------------------------+
|
|
||||||
| move(subdt) | x substeps
|
|
||||||
| resolve(subdt) |
|
|
||||||
+--------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
That ordering is the whole reason a game can stop duplicating the integrator. A resolver
|
|
||||||
called from `movementlogicfunc` runs before gravity, drag and the move, so it has to
|
|
||||||
*predict* where the actor will end up — which means re-implementing the arithmetic above, in
|
|
||||||
the game, and being wrong whenever that arithmetic changes.
|
|
||||||
|
|
||||||
**Only `move` is subdivided.** Gravity, drag, the thrust integration and the speed ellipse
|
|
||||||
all still run once against the whole `dt`, and the sub-steps sum to `dt`. An actor with
|
|
||||||
nothing to collide with takes exactly one sub-step of exactly `dt`.
|
|
||||||
|
|
||||||
## Shapes
|
|
||||||
|
|
||||||
A shape is a convex volume positioned relative to an actor's origin. It lives on the
|
|
||||||
[character](11-characters.md), so every goblin sharing a character shares one definition —
|
|
||||||
and an individual actor can override it.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/collision.h>
|
|
||||||
|
|
||||||
/* A hitbox inset into a 32x32 sprite frame, because art does not reach the
|
|
||||||
* edges of its cell and a full-frame box catches on doorways the character
|
|
||||||
* visibly clears. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *give_player_a_body(akgl_Actor *player)
|
|
||||||
{
|
|
||||||
SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
|
||||||
|
|
||||||
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
|
|
||||||
player->shape_override = true;
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`shape_override` is not redundant with an empty shape. "This actor deliberately has no
|
|
||||||
collider" and "this actor has not been configured yet" are different states, and without
|
|
||||||
somewhere to record the difference `akgl_actor_set_character` cannot tell whether it is
|
|
||||||
allowed to overwrite what it finds.
|
|
||||||
|
|
||||||
Three kinds: box, circle, and a capsule on either axis. A box against a box is answered in
|
|
||||||
closed form, which is both cheaper and *exact* — an actor resting on a floor gets a normal of
|
|
||||||
precisely `(0, -1, 0)` rather than one converged to a tolerance, and the difference between
|
|
||||||
those accumulates into a slow sideways creep.
|
|
||||||
|
|
||||||
### Why a 2D shape has a depth
|
|
||||||
|
|
||||||
The narrowphase is three-dimensional, which is what lets these shapes carry into a 3D game
|
|
||||||
later. It answers with the **minimum** translation that separates two volumes — and if a 2D
|
|
||||||
shape were extruded into a thin slab, the cheapest way to separate two of them would be
|
|
||||||
along z. The narrowphase would report a contact, the resolver would push the actor into the
|
|
||||||
screen, and **on screen nothing would happen at all while the actor sank through the floor.**
|
|
||||||
|
|
||||||
So the setters give every shape a depth of `AKGL_COLLISION_DEPTH_RATIO` times its largest
|
|
||||||
planar half-extent unless told otherwise. That is not a comfortable-looking number; it is the
|
|
||||||
smallest ratio for which the z overlap of any two shapes built this way provably exceeds any
|
|
||||||
planar penetration they can reach. Pass a depth by hand only if you know what that costs, and
|
|
||||||
leave `AKGL_COLLISION_TEST_PLANAR` on if you do.
|
|
||||||
|
|
||||||
## Masks: what collides with what
|
|
||||||
|
|
||||||
Every shape carries two words, and they are asymmetric on purpose.
|
|
||||||
|
|
||||||
| Field | Means |
|
|
||||||
|---|---|
|
|
||||||
| `layermask` | which layers this shape *is on* — what others test against |
|
|
||||||
| `collidemask` | which layers this shape *responds to* |
|
|
||||||
|
|
||||||
`a` is resolved against `b` when `b->layermask & a->collidemask`. The reverse is a separate
|
|
||||||
question with its own answer, which is how a player blocks against a pushable crate while the
|
|
||||||
crate ignores everything, and how a bullet collides with a wall while the wall ignores the
|
|
||||||
bullet.
|
|
||||||
|
|
||||||
**The defaults matter more than the mechanism.** A new shape is on
|
|
||||||
`AKGL_COLLISION_LAYER_ACTOR` and responds to `AKGL_COLLISION_LAYER_STATIC` — so **an actor
|
|
||||||
given a shape and nothing else collides with map geometry and with no other actor.**
|
|
||||||
"Everything with a hitbox shoves everything else" is a surprising default for a town full of
|
|
||||||
scenery and a painful one to discover after the fact. Opting in to actor-versus-actor is one
|
|
||||||
added bit:
|
|
||||||
|
|
||||||
```c norun
|
|
||||||
player->shape.collidemask |= AKGL_COLLISION_LAYER_ENEMY | AKGL_COLLISION_LAYER_PICKUP;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tiles
|
|
||||||
|
|
||||||
A tile layer is solid when it carries a `collidable` boolean custom property in Tiled. The
|
|
||||||
map declares what is solid; before this a game had to hard-code a layer index, and
|
|
||||||
`akgl_TilemapLayer` does not retain the name Tiled wrote, so that index broke the moment
|
|
||||||
somebody reordered layers in the editor.
|
|
||||||
|
|
||||||
Solid tiles are **not** given proxies. At the maximum map size that would be a quarter of a
|
|
||||||
million per layer — tens of megabytes of index describing data that is already a dense grid
|
|
||||||
sitting in the tilemap. The world keeps a borrowed pointer and reads `layers[i].data[]`
|
|
||||||
directly over whatever cell range a query covers: nine array reads for a 32-pixel actor on
|
|
||||||
16-pixel tiles, nothing built at level load, nothing maintained per frame.
|
|
||||||
|
|
||||||
Static geometry that is *not* tile-aligned — a slope, a Tiled object rectangle, a platform
|
|
||||||
that only moves between levels — is still an ordinary proxy carrying
|
|
||||||
`AKGL_COLLISION_FLAG_STATIC`. Both mechanisms exist; tiles use the free one because there are
|
|
||||||
a hundred thousand of them.
|
|
||||||
|
|
||||||
## Responding to a contact
|
|
||||||
|
|
||||||
Every actor gets a seventh behaviour hook, `collidefunc`, defaulting to
|
|
||||||
`akgl_actor_collide_block`. Contacts arrive one actor at a time, with the normal already
|
|
||||||
pointing the way *that* actor has to move — so a game overriding one actor's response never
|
|
||||||
has to reason about the other's.
|
|
||||||
|
|
||||||
The default pushes the actor out along the normal and removes the component of its motion
|
|
||||||
going into the surface. Three details of that are worth knowing, because each is a way the
|
|
||||||
same function is easy to write wrongly:
|
|
||||||
|
|
||||||
- **It writes `e` and `t`, never `v`.** The step recomputes `v = e + t` at the top of every
|
|
||||||
frame, so a write to `v` is discarded before anything reads it. An actor holding a
|
|
||||||
direction into a wall would otherwise accumulate thrust while standing still and leave at
|
|
||||||
speed the instant the wall ended.
|
|
||||||
- **It removes the component, not the axis.** Zeroing the whole thrust vector costs the
|
|
||||||
motion *along* the surface too. Sliding along a wall is the difference between a wall and
|
|
||||||
glue.
|
|
||||||
- **A separating contact is left alone.** An actor already moving out of a surface whose
|
|
||||||
velocity gets zeroed is an actor stuck to a wall it is walking away from, and it reads to a
|
|
||||||
player as the collision grabbing them.
|
|
||||||
|
|
||||||
A contact carries the global tile id, layer and cell when the other side was a tile, which is
|
|
||||||
what tells a spike from a floor without a second lookup.
|
|
||||||
|
|
||||||
### Sensors
|
|
||||||
|
|
||||||
A shape flagged `AKGL_COLLISION_FLAG_SENSOR` reports a contact and never pushes. Coins,
|
|
||||||
trigger volumes, damage zones. The default response returns having changed nothing — walking
|
|
||||||
through a coin is not being blocked by it.
|
|
||||||
|
|
||||||
## Asking without being pushed
|
|
||||||
|
|
||||||
Four queries answer a question and resolve nothing. They are what a game reaches for when it
|
|
||||||
wants to *know*: a ledge probe ahead of a walking enemy, a check that a doorway is clear, a
|
|
||||||
spawn point that needs validating.
|
|
||||||
|
|
||||||
| Function | Answers |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_collision_solid_at` | is the tile under this point solid |
|
|
||||||
| `akgl_collision_box_blocked` | would this rectangle overlap anything solid, tiles and proxies both |
|
|
||||||
| `akgl_collision_query_box` | visit every proxy that may overlap this rectangle |
|
|
||||||
| `akgl_collision_settle` | lift a shape out of geometry it was placed inside |
|
|
||||||
|
|
||||||
`akgl_collision_settle` deserves the explanation. Resolution stops a shape *entering*
|
|
||||||
geometry and has nothing to say about one that began inside it — what it does instead is
|
|
||||||
refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that
|
|
||||||
constantly, because an editor rounds an object onto a step. Settling walks the shape up a
|
|
||||||
tile at a time and refuses loudly rather than searching forever.
|
|
||||||
|
|
||||||
## Partitioners
|
|
||||||
|
|
||||||
The broad phase is pluggable, the same way the renderer and the physics backend are: a record
|
|
||||||
of function pointers plus an initializer.
|
|
||||||
|
|
||||||
**Use the grid.** It is the default, and `PERFORMANCE.md` measures it at 2.6 times faster
|
|
||||||
than the tree on the same population — before counting that the tree also rebuilds whenever
|
|
||||||
anything moves. A grid `move` for a proxy that has not left its cells is 11.5 ns and touches
|
|
||||||
nothing.
|
|
||||||
|
|
||||||
The BSP ships alongside it so that "pluggable" means something: a vtable with one
|
|
||||||
implementation behind it has never been asked to be a vtable, and the partitioner suite runs
|
|
||||||
its entire contract against every entry in a table. It would earn its place in a world with
|
|
||||||
wildly non-uniform object sizes, or one larger than the grid's fixed cell array covers.
|
|
||||||
|
|
||||||
```c norun
|
|
||||||
/* Only if you have measured your own scene and the tree wins. */
|
|
||||||
akgl_collision_world_init(&world, "bsp", 16.0f, 16.0f);
|
|
||||||
```
|
|
||||||
|
|
||||||
## What is not implemented
|
|
||||||
|
|
||||||
- **No rotation.** No actor carries an angle, `akgl_actor_render` hard-codes
|
|
||||||
`SDL_FLIP_NONE`, and every shape is axis-aligned. The support functions are written so that
|
|
||||||
adding it touches one static function in the narrowphase; see `TODO.md`.
|
|
||||||
- **No restitution and no friction.** The default response blocks. Anything bouncier is your
|
|
||||||
`collidefunc`.
|
|
||||||
- **No continuous collision.** Sub-stepping bounds how far an actor travels between tests, and
|
|
||||||
it is capped: above roughly `8 x 0.5 x cellsize` per step — about 1280 px/s on 16-pixel
|
|
||||||
tiles at the default `max_timestep` — an actor can still pass through a wall. That is a
|
|
||||||
projectile, not a walker. `AKGL_COLLISION_FLAG_BULLET` reserves the bit for the swept
|
|
||||||
narrowphase that would fix it, and does nothing today.
|
|
||||||
- **No concave shapes.** The narrowphase is convex-only, always. A concave collider is
|
|
||||||
several convex ones.
|
|
||||||
- **Resolution is pool-order dependent.** An actor low in the pool sees the world one
|
|
||||||
sub-step stale. This is the arcade bargain, and it is the same one Construct and Phaser
|
|
||||||
make.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [Chapter 14](14-physics.md) — the step this runs inside, and the four gaps in the feel that
|
|
||||||
collision does not close.
|
|
||||||
- [Chapter 12](12-actors.md) — the other six behaviour hooks.
|
|
||||||
- [Chapter 13](13-tilemaps.md) — the map, and what else its custom properties can say.
|
|
||||||
- [Chapter 20](20-tutorial-sidescroller.md) — a game that uses all of this.
|
|
||||||
372
docs/16-input.md
@@ -1,372 +0,0 @@
|
|||||||
# 16. Input
|
|
||||||
|
|
||||||
libakgl does not have an input system so much as a *binding* system. SDL3 owns events,
|
|
||||||
keycodes, keymods, gamepad enumeration and text composition, and it documents all of them
|
|
||||||
in the [SDL3 wiki](https://wiki.libsdl.org/SDL3/) — start with
|
|
||||||
[SDL_Event](https://wiki.libsdl.org/SDL3/SDL_Event),
|
|
||||||
[SDL_Keycode](https://wiki.libsdl.org/SDL3/SDL_Keycode) and
|
|
||||||
[CategoryGamepad](https://wiki.libsdl.org/SDL3/CategoryGamepad). Nothing here replaces any
|
|
||||||
of it.
|
|
||||||
|
|
||||||
What libakgl adds is two things: a table that says *"when event X arrives from device Y
|
|
||||||
carrying key or button Z, call this handler on this actor"*, and a small ring buffer of
|
|
||||||
keystrokes for a caller who wants to ask "is there a key waiting" without owning the event
|
|
||||||
loop.
|
|
||||||
|
|
||||||
## Input is pushed, not polled
|
|
||||||
|
|
||||||
**The host pumps every SDL event into `akgl_controller_handle_event`.** There is no
|
|
||||||
`akgl_input_update()` that reads the keyboard state for you, and there is no internal event
|
|
||||||
loop. Whatever drains the SDL event queue — your `while ( SDL_PollEvent(&e) )`, or SDL's
|
|
||||||
`SDL_AppEvent` callback — hands each event over, unconditionally.
|
|
||||||
|
|
||||||
Unconditionally is the point: **an event nothing binds is not an error.** It returns
|
|
||||||
success having done nothing, which is what makes the blanket call correct and keeps the
|
|
||||||
host from having to know which events matter.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akgl/actor.h>
|
|
||||||
#include <akgl/controller.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Bind WASD on top of control map 0, which akgl_controller_default has already
|
|
||||||
* filled with the four arrow keys and the four D-pad directions.
|
|
||||||
*
|
|
||||||
* Bindings are appended and scanned in push order, and the first match stops
|
|
||||||
* the scan -- so these fire only for keys the eight defaults do not claim.
|
|
||||||
* There is no way to remove one; zeroing akgl_controlmaps[id] is the only reset.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *bind_wasd(int mapid, SDL_KeyboardID kbid)
|
|
||||||
{
|
|
||||||
akgl_Control control;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_controller_default(mapid, "player", kbid, 0));
|
|
||||||
|
|
||||||
/* Copied in by value, so one stack local does for every push. */
|
|
||||||
SDL_memset(&control, 0x00, sizeof(akgl_Control));
|
|
||||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
|
||||||
control.event_off = SDL_EVENT_KEY_UP;
|
|
||||||
|
|
||||||
control.key = SDLK_A;
|
|
||||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
|
||||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
|
||||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
|
||||||
|
|
||||||
control.key = SDLK_D;
|
|
||||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
|
||||||
control.handler_off = &akgl_actor_cmhf_right_off;
|
|
||||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
|
||||||
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Every event, unconditionally. An event nothing binds is success, not a
|
|
||||||
* failure, which is what makes the unconditional call correct. */
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *pump(void *appstate, SDL_Event *event)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_controller_handle_event(appstate, event));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Two independent paths out of one call
|
|
||||||
|
|
||||||
`akgl_controller_handle_event` does two unrelated things with the event it is given, and
|
|
||||||
**neither one suppresses the other**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_controller_handle_event(appstate, event)
|
|
||||||
|
|
|
||||||
+----------------------+----------------------+
|
|
||||||
| |
|
|
||||||
(a) the keystroke ring, FIRST (b) the control maps, SECOND
|
|
||||||
| |
|
|
||||||
KEY_DOWN -> push {key, mod, ""} for map 0..7 (target != NULL):
|
|
||||||
TEXT_INPUT -> attach text to the for control 0..31, in push order:
|
|
||||||
newest press, or push event type == event_on/off ?
|
|
||||||
a text-only entry device id == kbid / jsid ?
|
|
||||||
| key/button == this binding ?
|
|
||||||
| -> call the handler and
|
|
||||||
akgl_controller_poll_key() STOP the entire scan
|
|
||||||
akgl_controller_poll_keystroke()
|
|
||||||
akgl_controller_flush_keys()
|
|
||||||
```
|
|
||||||
|
|
||||||
The ring is filled **first**, before the maps are consulted, and *whether or not* a map
|
|
||||||
claims the key. A key that drives an actor is still a key an embedded interpreter polling
|
|
||||||
with `akgl_controller_poll_key()` wants to see.
|
|
||||||
|
|
||||||
### The control-map scan
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/controller.h
|
|
||||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
|
||||||
#define AKGL_MAX_CONTROL_MAPS 8
|
|
||||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
|
||||||
#define AKGL_MAX_CONTROLS 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Eight maps means up to eight locally controlled players, each on its own keyboard and
|
|
||||||
gamepad. A map with a `NULL` `target` is skipped entirely — that is how an unused slot is
|
|
||||||
spelled.
|
|
||||||
|
|
||||||
**Maps are scanned in index order, bindings within a map in push order, and the first
|
|
||||||
match wins and stops the whole scan.** A key bound in two maps only ever fires in the
|
|
||||||
lower-numbered one. A key bound twice in one map only fires on the earlier push. This is
|
|
||||||
what makes `akgl_controller_default` followed by your own pushes behave sanely: the
|
|
||||||
defaults are already in slots 0–7 and your additions land after them.
|
|
||||||
|
|
||||||
A match requires **all three** of: the event type equals the binding's `event_on` or
|
|
||||||
`event_off`; the device id on the event equals the map's `kbid` (keyboard events) or `jsid`
|
|
||||||
(gamepad events); and the key or button equals the binding's. The device-id half is what
|
|
||||||
keeps two players on two keyboards apart — `akgl_controller_list_keyboards` logs every
|
|
||||||
attached keyboard and its id, which is how you find the number to pass.
|
|
||||||
|
|
||||||
### Bind device 0 unless you mean a specific device
|
|
||||||
|
|
||||||
**A `kbid` or `jsid` of `0` matches every device of that kind**, and it is what a
|
|
||||||
single-player game wants.
|
|
||||||
|
|
||||||
Do not reach for `SDL_GetKeyboards()` to fill it in. That call reports what is *attached*;
|
|
||||||
the id a key event actually *carries* is chosen by the video backend, and the two need not
|
|
||||||
agree. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is
|
|
||||||
`0`, while SDL registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is `1`.
|
|
||||||
With XInput2 the events carry the physical slave device's `sourceid` and
|
|
||||||
`SDL_GetKeyboards()[0]` may be the master. Binding `SDL_GetKeyboards()[0]` therefore gives
|
|
||||||
you a map that matches nothing at all, and controls that silently do nothing.
|
|
||||||
|
|
||||||
That is not hypothetical — the sidescroller in [Chapter 20](20-tutorial-sidescroller.md)
|
|
||||||
shipped exactly that bug, and its smoke test passed anyway because the test called the
|
|
||||||
handlers instead of dispatching events.
|
|
||||||
|
|
||||||
`0` is the right spelling for "any" because SDL documents `which` as `0` when the source is
|
|
||||||
unknown or virtual, and joystick ids start at 1 — so no real device is `0` and nothing is
|
|
||||||
given up by spending it.
|
|
||||||
|
|
||||||
There is no way to remove a binding. `akgl_controller_pushmap` appends and that is the
|
|
||||||
whole API; the only reset is zeroing `akgl_controlmaps[id]` yourself. **Calling
|
|
||||||
`akgl_controller_default` twice on one map leaves sixteen bindings**, and the first eight
|
|
||||||
are the ones that fire.
|
|
||||||
|
|
||||||
### The keystroke ring
|
|
||||||
|
|
||||||
```c excerpt=src/controller.c
|
|
||||||
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
|
|
||||||
{
|
|
||||||
int tail = 0;
|
|
||||||
|
|
||||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
|
||||||
keybuffer_awaiting_text = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
|
||||||
keybuffer[tail].key = key;
|
|
||||||
keybuffer[tail].mod = mod;
|
|
||||||
keybuffer[tail].text[0] = '\0';
|
|
||||||
keybuffer_count += 1;
|
|
||||||
keybuffer_awaiting_text = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Thirty-two entries (`AKGL_CONTROLLER_KEY_BUFFER`), one fixed array in the library's data
|
|
||||||
segment, **one ring for the whole process** — it is a file-scope static, not per map and
|
|
||||||
not per actor.
|
|
||||||
|
|
||||||
**A full buffer drops the newest keystroke, not the oldest.** A caller reading a line of
|
|
||||||
input wants the characters that were typed first; overwriting the head would hand it the
|
|
||||||
tail of what the user typed and silently lose the beginning.
|
|
||||||
|
|
||||||
Two pollers drain the same ring, and a keystroke taken by one is not waiting for the
|
|
||||||
other:
|
|
||||||
|
|
||||||
| Call | Hands back | Discards |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_controller_poll_key(&keycode, &available)` | The SDL keycode, as an `int` | Entries carrying only composed text and no keycode — there is no key to report |
|
|
||||||
| `akgl_controller_poll_keystroke(&dest, &available)` | `key`, `mod` and the composed `text` | Nothing |
|
|
||||||
| `akgl_controller_flush_keys()` | — | Everything waiting |
|
|
||||||
|
|
||||||
An empty ring is success, not a failure: `available` comes back `false` and the caller
|
|
||||||
polls again next frame. **Check `available`, not the keycode against 0** — a text-only
|
|
||||||
entry legitimately has a keycode of 0.
|
|
||||||
|
|
||||||
**The `text` field needs `SDL_StartTextInput()` on your window.** SDL reports one keystroke
|
|
||||||
as two events — the key going down, and then the character it composed to, if it composed
|
|
||||||
to anything — and it only sends the second while text input is started. Without that call
|
|
||||||
`key` and `mod` still arrive and `text` is always the empty string. That is not a libakgl
|
|
||||||
limitation to work around; **it is the only correct way to get a character out of SDL**, and
|
|
||||||
it is what makes a keyboard layout, a compose key, a dead key and an IME commit work. `"`,
|
|
||||||
`!`, `(`, `)`, `:` and `;` are all unreachable from a keycode alone, and so is every
|
|
||||||
lower-case letter. See
|
|
||||||
[SDL_StartTextInput](https://wiki.libsdl.org/SDL3/SDL_StartTextInput).
|
|
||||||
|
|
||||||
An entry with composed text and **no** keycode is not a degenerate case: an input method or
|
|
||||||
a dead key finishes a character with no key press of its own, and a line editor wants that
|
|
||||||
character. `akgl_controller_poll_keystroke` reports it; `akgl_controller_poll_key` cannot,
|
|
||||||
and drops it on the way past.
|
|
||||||
|
|
||||||
Text longer than `AKGL_CONTROLLER_KEYSTROKE_TEXT` (8 bytes) — an IME committing a whole
|
|
||||||
word at once — is truncated **on a code point boundary**, never through the middle of a
|
|
||||||
UTF-8 sequence.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akgl/controller.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Drain the keystroke ring into a line buffer, the way an embedded interpreter
|
|
||||||
* or a name-entry screen wants it.
|
|
||||||
*
|
|
||||||
* The `text` field is only populated while SDL text input is started on the
|
|
||||||
* window, so a caller that wants punctuation, lower case, dead keys or an IME
|
|
||||||
* commit calls SDL_StartTextInput() first. Without it `key` and `mod` still
|
|
||||||
* arrive and `text` is always empty.
|
|
||||||
*/
|
|
||||||
akerr_ErrorContext AKERR_NOIGNORE *read_line(char *dest, size_t destsize, bool *done)
|
|
||||||
{
|
|
||||||
akgl_Keystroke stroke;
|
|
||||||
bool available = false;
|
|
||||||
size_t used = 0;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
|
||||||
FAIL_ZERO_RETURN(errctx, done, AKERR_NULLPOINTER, "done");
|
|
||||||
|
|
||||||
used = SDL_strlen(dest);
|
|
||||||
*done = false;
|
|
||||||
|
|
||||||
/* The ATTEMPT block goes outside the loop: CATCH reports failure by
|
|
||||||
* break-ing, which would bind to the loop instead of the block. */
|
|
||||||
ATTEMPT {
|
|
||||||
for ( ;; ) {
|
|
||||||
CATCH(errctx, akgl_controller_poll_keystroke(&stroke, &available));
|
|
||||||
if ( available == false ) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if ( stroke.key == SDLK_RETURN ) {
|
|
||||||
*done = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if ( (stroke.text[0] != '\0') && ((used + 1) < destsize) ) {
|
|
||||||
dest[used] = stroke.text[0];
|
|
||||||
used += 1;
|
|
||||||
dest[used] = '\0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The ring is filled on whichever thread pumps events and **is not synchronized**. Poll it
|
|
||||||
from that thread.
|
|
||||||
|
|
||||||
## The default bindings
|
|
||||||
|
|
||||||
`akgl_controller_default(controlmapid, actorname, kbid, jsid)` points a map at the actor
|
|
||||||
registered under `actorname` and pushes **eight** bindings — the four arrow keys and the
|
|
||||||
four D-pad directions — each wired to the matching `akgl_actor_cmhf_*_on`/`_off` pair:
|
|
||||||
|
|
||||||
| Key | Gamepad button | `handler_on` / `handler_off` |
|
|
||||||
|---|---|---|
|
|
||||||
| `SDLK_DOWN` | `SDL_GAMEPAD_BUTTON_DPAD_DOWN` | `akgl_actor_cmhf_down_on` / `_down_off` |
|
|
||||||
| `SDLK_UP` | `SDL_GAMEPAD_BUTTON_DPAD_UP` | `akgl_actor_cmhf_up_on` / `_up_off` |
|
|
||||||
| `SDLK_LEFT` | `SDL_GAMEPAD_BUTTON_DPAD_LEFT` | `akgl_actor_cmhf_left_on` / `_left_off` |
|
|
||||||
| `SDLK_RIGHT` | `SDL_GAMEPAD_BUTTON_DPAD_RIGHT` | `akgl_actor_cmhf_right_on` / `_right_off` |
|
|
||||||
|
|
||||||
It is the "just give me something that works" path. A game wanting different keys builds
|
|
||||||
its own bindings with `akgl_controller_pushmap`, as in the WASD example above.
|
|
||||||
|
|
||||||
**The default bindings cannot produce a diagonal.** Every `akgl_actor_cmhf_*_on` handler
|
|
||||||
clears `AKGL_ACTOR_STATE_MOVING_ALL` before setting its own bit, so pressing Left while Up
|
|
||||||
is held cancels Up rather than adding to it. That is fine for a four-way JRPG and wrong for
|
|
||||||
anything that wants eight-way movement — and it means the thrust-vector ellipse cap in
|
|
||||||
[Chapter 14](14-physics.md) is unreachable through these handlers. Eight-way movement wants
|
|
||||||
your own handlers that `AKGL_BITMASK_ADD` the movement bit without clearing the others.
|
|
||||||
|
|
||||||
`akgl_controller_handle_button_down` / `_up` are a different thing again, and are **not**
|
|
||||||
control-map bindings: they look the actor up by the literal name `"player"` in
|
|
||||||
`AKGL_REGISTRY_ACTOR` rather than being told which actor to act on, so they only ever drive
|
|
||||||
one. The `akgl_actor_cmhf_*` handlers are the general form; see
|
|
||||||
[Chapter 12](12-actors.md).
|
|
||||||
|
|
||||||
## Gamepads
|
|
||||||
|
|
||||||
**SDL delivers no button events from a gamepad nobody has opened.** `akgl_game_init` calls
|
|
||||||
`akgl_controller_open_gamepads()` for the devices present at startup; a device plugged in
|
|
||||||
mid-session arrives as `SDL_EVENT_GAMEPAD_ADDED`, and `akgl_controller_handle_added` opens
|
|
||||||
it. Route that event (and `SDL_EVENT_GAMEPAD_REMOVED`, to `akgl_controller_handle_removed`)
|
|
||||||
or hot-plugged pads stay silent. No gamepads attached at all is success, not an error.
|
|
||||||
|
|
||||||
`akgl_controller_handle_added` logs the mapping it found, deliberately: **a controller with
|
|
||||||
no entry in the mapping database produces no button events at all**, which is otherwise
|
|
||||||
indistinguishable from a broken binding.
|
|
||||||
|
|
||||||
Which is why libakgl ships a database. `include/akgl/SDL_GameControllerDB.h` carries
|
|
||||||
`AKGL_SDL_GAMECONTROLLER_DB_LEN` (2255) mappings from the community
|
|
||||||
[SDL_GameControllerDB](https://github.com/mdqinc/SDL_GameControllerDB) project, and
|
|
||||||
`akgl_game_init` feeds every one to `SDL_AddGamepadMapping` before opening anything.
|
|
||||||
|
|
||||||
That header is **generated, and tracked on purpose**. It is the offline fallback that keeps
|
|
||||||
the library buildable when upstream is renamed, rate-limited or unreachable. Never
|
|
||||||
hand-edit it; regeneration is `cmake --build build --target controllerdb` and nothing else
|
|
||||||
runs the script. The full contract, including why a failed fetch used to destroy the very
|
|
||||||
file it exists to protect, is in `AGENTS.md` under "Generated and Vendored Sources".
|
|
||||||
|
|
||||||
## Known defects
|
|
||||||
|
|
||||||
**A matched binding's handler pointer is not checked.** `akgl_controller_handle_event`
|
|
||||||
calls `curcontrol->handler_on(...)` the moment the event type, device id and key all match,
|
|
||||||
with no `NULL` test. A control pushed with a `NULL` `handler_on` or `handler_off` crashes
|
|
||||||
when its event arrives — and since `akgl_controller_pushmap` copies whatever it is given,
|
|
||||||
a `memset`-zeroed `akgl_Control` with only `event_on` filled in is a loaded gun. **Set both
|
|
||||||
handlers on every binding you push**, even if one of them does nothing. There is no
|
|
||||||
diagnostic for this at any layer.
|
|
||||||
|
|
||||||
**`appstate` is required and never read.** Every entry point in this subsystem —
|
|
||||||
`akgl_controller_handle_event`, `handle_button_down`, `handle_button_up`, `handle_added`,
|
|
||||||
`handle_removed` — raises `AKERR_NULLPOINTER` on a `NULL` `appstate` and then never looks
|
|
||||||
at it. Pass any non-`NULL` pointer if your program has no app state.
|
|
||||||
|
|
||||||
**The analogue-axis, mouse and pen fields are declared and never consulted.**
|
|
||||||
`akgl_Control::axis`, `axis_range_min` and `axis_range_max`, and `akgl_ControlMap::mouseid`
|
|
||||||
and `penid`, are in the structs and `akgl_controller_handle_event` does not read any of
|
|
||||||
them. Setting them has no effect; analogue sticks, mice and pens do not reach the control
|
|
||||||
maps at all today. Handle those events yourself before calling
|
|
||||||
`akgl_controller_handle_event`.
|
|
||||||
|
|
||||||
**Key auto-repeat is not filtered.** The ring push tests only `event->type ==
|
|
||||||
SDL_EVENT_KEY_DOWN` and does not consult `event->key.repeat`, so a held key fills the
|
|
||||||
32-entry ring at the platform's repeat rate. Check `repeat` in your event pump if you are
|
|
||||||
polling for discrete presses.
|
|
||||||
|
|
||||||
Two defects this chapter used to carry are **fixed** and are recorded here so nobody
|
|
||||||
re-derives them from the header prose:
|
|
||||||
|
|
||||||
- **A negative `controlmapid` is rejected.** Both `akgl_controller_pushmap` and
|
|
||||||
`akgl_controller_default` check `controlmapid < 0` as well as the upper bound and raise
|
|
||||||
`AKERR_OUTOFBOUNDS`. `tests/controller.c` passes `-1` and `-4096` to each. The
|
|
||||||
`@warning` blocks in `include/akgl/controller.h` saying only the upper bound is checked
|
|
||||||
are stale; `TODO.md`, "Known and still open" item 11.
|
|
||||||
- **The `akgl_controller_handle_*` functions exist.** `controller.h` once declared four
|
|
||||||
names that were defined under different spellings, so they linked nowhere. Fixed in
|
|
||||||
0.5.0, and `scripts/check_api_surface.sh` runs as the `api_surface` test to stop that
|
|
||||||
class of drift coming back.
|
|
||||||
|
|
||||||
## Statuses
|
|
||||||
|
|
||||||
The ones this subsystem raises, with their libakgl meanings in
|
|
||||||
[Chapter 4](04-errors.md):
|
|
||||||
|
|
||||||
| Status | When |
|
|
||||||
|---|---|
|
|
||||||
| `AKERR_NULLPOINTER` | A `NULL` `appstate`, `event`, `control`, or poller destination; SDL cannot enumerate or open a device; no actor registered as `"player"` |
|
|
||||||
| `AKERR_OUTOFBOUNDS` | `controlmapid` outside `0..AKGL_MAX_CONTROL_MAPS - 1`, or the map already holds `AKGL_MAX_CONTROLS` bindings |
|
|
||||||
| `AKGL_ERR_REGISTRY` | `akgl_controller_default` could not find `actorname` — usually because the actor has not been created yet |
|
|
||||||
| anything | Whatever the matched binding's handler raises, propagated unchanged |
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# 17. Text and fonts
|
|
||||||
|
|
||||||
**SDL3_ttf owns fonts.** It opens the file, rasterizes the glyphs, and computes the metrics;
|
|
||||||
its API and its behaviour are documented in the
|
|
||||||
[SDL3_ttf wiki](https://wiki.libsdl.org/SDL3_ttf/). This chapter covers the three things
|
|
||||||
libakgl adds on top: a name-keyed font registry, a one-call rasterize-and-blit, and two
|
|
||||||
measure functions that need no window.
|
|
||||||
|
|
||||||
There is no akgl font type. A font is a `TTF_Font *`, and what libakgl gives you is a place
|
|
||||||
to keep it.
|
|
||||||
|
|
||||||
## Fonts live in a registry, keyed by a name you choose
|
|
||||||
|
|
||||||
`akgl_text_loadfont(name, filepath, size)` opens the file and publishes the handle in
|
|
||||||
`AKGL_REGISTRY_FONT` under `name`. The name is yours — it is not derived from the file — and
|
|
||||||
`filepath` is used verbatim, not resolved against `SDL_GetBasePath()`.
|
|
||||||
|
|
||||||
**The size is baked into the handle, so one file at two sizes is two fonts under two
|
|
||||||
names.** That is SDL_ttf's model, not libakgl's, and there is no way around it:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akstdlib.h>
|
|
||||||
#include <SDL3_ttf/SDL_ttf.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
#include <akgl/registry.h>
|
|
||||||
#include <akgl/text.h>
|
|
||||||
|
|
||||||
/* Two sizes of one file are two fonts under two names. */
|
|
||||||
akerr_ErrorContext *hud_load_fonts(char *path)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_text_loadfont("hud", path, 14));
|
|
||||||
PASS(errctx, akgl_text_loadfont("title", path, 32));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
akerr_ErrorContext *hud_draw_score(int score)
|
|
||||||
{
|
|
||||||
TTF_Font *font = NULL;
|
|
||||||
SDL_Color white = { 255, 255, 255, 255 };
|
|
||||||
char line[32];
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
/* The registry hands back a raw TTF_Font *. It is not reference counted:
|
|
||||||
nothing here takes a reference and nothing gives one back. */
|
|
||||||
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "hud", NULL);
|
|
||||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font registered as \"hud\"");
|
|
||||||
|
|
||||||
PASS(errctx, aksl_snprintf(&count, line, sizeof(line), "SCORE %d", score));
|
|
||||||
PASS(errctx, akgl_text_rendertextat(font, line, white, 0, 8, 8));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fonts are not reference counted.** The four asset pools in [Chapter 5](05-the-heap.md) all
|
|
||||||
count references; the font registry does not. It holds a bare pointer. Two consequences:
|
|
||||||
|
|
||||||
- `akgl_text_unloadfont(name)` closes the font whether or not anything is still using it.
|
|
||||||
Anything holding the `TTF_Font *` — a caller that fetched it earlier, a `hud_draw_score`
|
|
||||||
that fetched it last frame and cached it — is left with a dangling pointer.
|
|
||||||
- Loading over an existing name replaces the entry and closes the font it displaced. The new
|
|
||||||
font is opened **first**, so a failed load leaves you with the font you already had.
|
|
||||||
|
|
||||||
`akgl_text_unloadfont` on a name that is not registered raises `AKERR_KEY`, which makes a
|
|
||||||
double unload an error rather than a double close. `akgl_text_loadfont` raises `AKERR_KEY`
|
|
||||||
when it cannot write the registry — in practice, because `akgl_registry_init_font()` has not
|
|
||||||
run.
|
|
||||||
|
|
||||||
## The teardown ordering trap
|
|
||||||
|
|
||||||
**`akgl_text_unloadallfonts()` must run before `TTF_Quit()` and `SDL_Quit()`.** This is the
|
|
||||||
one ordering constraint in the subsystem and it is easy to get wrong, because nothing fails
|
|
||||||
loudly when you do.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <SDL3_ttf/SDL_ttf.h>
|
|
||||||
#include <akgl/text.h>
|
|
||||||
|
|
||||||
/* Shutdown order. Both of the other two orderings are wrong. */
|
|
||||||
void shutdown_text(void)
|
|
||||||
{
|
|
||||||
IGNORE(akgl_text_unloadallfonts());
|
|
||||||
TTF_Quit();
|
|
||||||
SDL_Quit();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A font is reachable only by name, through `AKGL_REGISTRY_FONT`, which is an SDL property set.
|
|
||||||
So:
|
|
||||||
|
|
||||||
| Order | What happens |
|
|
||||||
|---|---|
|
|
||||||
| `unloadallfonts`, `TTF_Quit`, `SDL_Quit` | Correct. Every font closed, then the registry destroyed, then SDL |
|
|
||||||
| `SDL_Quit` first | **SDL_Quit destroys the property registry, taking the last reference to every font with it.** They are never closed and are now unreachable — a leak bounded by how many fonts you loaded, with no way to do anything about it |
|
|
||||||
| `TTF_Quit` first | The `TTF_Font` handles are already invalid; `unloadallfonts` then closes freed pointers |
|
|
||||||
|
|
||||||
`akgl_text_unloadallfonts` enumerates the registry, closes every font, destroys the property
|
|
||||||
set and sets `AKGL_REGISTRY_FONT` to 0. It has no failure path: an uninitialized registry is
|
|
||||||
success rather than an error, because shutdown paths run after partial startups.
|
|
||||||
|
|
||||||
**Afterwards the registry is gone.** `akgl_text_loadfont` needs
|
|
||||||
`akgl_registry_init_font()` again before it can register anything, so this is a shutdown
|
|
||||||
call rather than a between-scenes one. To swap fonts mid-game, use `akgl_text_unloadfont`
|
|
||||||
per name.
|
|
||||||
|
|
||||||
## Drawing is rasterize, upload, blit, destroy — every call
|
|
||||||
|
|
||||||
`akgl_text_rendertextat(font, text, color, wraplength, x, y)` does the whole job in one
|
|
||||||
call: renders blended through SDL_ttf, uploads the surface as a texture, draws it through
|
|
||||||
`akgl_renderer->draw_texture`, and destroys both the texture and the surface before it
|
|
||||||
returns. **There is no cache.** Every call pays the full cost.
|
|
||||||
|
|
||||||
`PERFORMANCE.md` puts numbers on it, on a 640x480 software-rasterized frame:
|
|
||||||
|
|
||||||
| Operation | Cost |
|
|
||||||
|---|---:|
|
|
||||||
| `akgl_text_rendertextat`, 15 characters | 12,601.7 ns |
|
|
||||||
| `akgl_text_measure`, 15 characters | 37.3 ns |
|
|
||||||
| Six lines of HUD text | 0.076 ms — 0.5% of a 16.67 ms frame |
|
|
||||||
|
|
||||||
Measuring is 340 times cheaper than drawing, which tells you the whole cost is the rasterize
|
|
||||||
and the upload. **Six HUD readouts is 76 µs a frame. That is fine at 60 fps on a laptop and
|
|
||||||
it is 4% of a 2 ms GPU frame** — and it is being paid every frame for a score that changes
|
|
||||||
once a second. A one-line cache keyed on (font, string, colour) would take it to nothing;
|
|
||||||
`PERFORMANCE.md` calls it the single clearest optimisation in the library, and `TODO.md`
|
|
||||||
carries it as a target.
|
|
||||||
|
|
||||||
So: **fine for a HUD line, wrong for a static body of text redrawn every frame.** If you are
|
|
||||||
drawing a page of dialogue that does not change, rasterize it yourself once with SDL3_ttf,
|
|
||||||
keep the texture, and blit it through `akgl_renderer->draw_texture`.
|
|
||||||
|
|
||||||
Two more properties of the draw:
|
|
||||||
|
|
||||||
- **Coordinates are screen coordinates, not world ones.** This does not go through
|
|
||||||
`akgl_camera`, so a HUD stays put while the world scrolls under it. `x` and `y` are the
|
|
||||||
top-left corner of the text, not a centre.
|
|
||||||
- **`wraplength` greater than 0 wraps on word boundaries at that pixel width.** 0 or less
|
|
||||||
draws a single line and breaks only on newlines in `text`.
|
|
||||||
|
|
||||||
The renderer guards are worth knowing because of what they catch. `akgl_text_rendertextat`
|
|
||||||
raises `AKERR_NULLPOINTER` if `akgl_renderer`, its `sdl_renderer`, or its `draw_texture` is
|
|
||||||
`NULL` — and that last one is the state a backend is in between being allocated and being
|
|
||||||
run through `akgl_render_2d_bind()`. Reusing `AKERR_NULLPOINTER` for a failed rasterize or a
|
|
||||||
failed texture upload is the same status doing double duty; the message carries
|
|
||||||
`SDL_GetError()` and tells you which.
|
|
||||||
|
|
||||||
## Measuring needs no renderer and no window
|
|
||||||
|
|
||||||
`akgl_text_measure` and `akgl_text_measure_wrapped` touch nothing but the font. They work
|
|
||||||
before `akgl_render_2d_init`, and in a program that never creates a window at all. A caller
|
|
||||||
building a character grid measures one cell and derives the rest from it:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <SDL3_ttf/SDL_ttf.h>
|
|
||||||
#include <akgl/text.h>
|
|
||||||
|
|
||||||
/* Measuring touches no renderer and no window, so a grid can be derived
|
|
||||||
before anything is on screen. */
|
|
||||||
akerr_ErrorContext *cell_size(TTF_Font *font, int *cellw, int *cellh)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_text_measure(font, "M", cellw, cellh));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A negative wrap length is refused rather than passed through: SDL_ttf reads
|
|
||||||
it as a very large unsigned width and silently stops wrapping. */
|
|
||||||
akerr_ErrorContext *box_size(TTF_Font *font, char *body, int width, int *w, int *h)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_text_measure_wrapped(font, body, width, w, h));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The two differ in exactly the way `akgl_text_rendertextat`'s `wraplength` argument suggests:
|
|
||||||
|
|
||||||
| Function | Height reported | Width reported |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_text_measure` | One line, whatever the text contains | The whole string on one line |
|
|
||||||
| `akgl_text_measure_wrapped` | Every line the text wraps onto | The longest line, not `wraplength` |
|
|
||||||
|
|
||||||
**A negative `wraplength` is refused with `AKERR_OUTOFBOUNDS`**, and that is a deliberate
|
|
||||||
libakgl decision rather than a passthrough. SDL_ttf takes the wrap width as an `int` and
|
|
||||||
reads a negative one as a very large *unsigned* width, which silently disables wrapping —
|
|
||||||
returning a measurement that is wrong rather than an error. A `wraplength` of 0 is legal and
|
|
||||||
wraps on newlines only.
|
|
||||||
|
|
||||||
## The empty string
|
|
||||||
|
|
||||||
Both halves accept it. `akgl_text_measure("")` returns 0 wide by one line high, so a cursor
|
|
||||||
sitting on an empty line has somewhere to be, and `akgl_text_rendertextat` with `""` returns
|
|
||||||
success without rasterizing anything.
|
|
||||||
|
|
||||||
That is worth stating because it was not always true and the header still says otherwise.
|
|
||||||
SDL_ttf refuses the empty string from both rasterizers with "Text has zero width", and until
|
|
||||||
0.5.0 `akgl_text_rendertextat` passed that on as `AKERR_NULLPOINTER` — so the two halves of
|
|
||||||
one header disagreed about one string, and a caller drawing a line that might be empty had
|
|
||||||
to check for it. It is a `SUCCEED_RETURN` now, placed *after* the font, text and backend
|
|
||||||
guards, so drawing nothing still refuses everything drawing something refuses.
|
|
||||||
|
|
||||||
> **Stale header prose.** `include/akgl/text.h:123-127` still documents the empty string as
|
|
||||||
> refused, and `text.h:120-122` still warns that a failure after rasterizing leaks the
|
|
||||||
> surface and the texture. Both describe pre-0.5.0 behaviour. `src/text.c:110-112` returns
|
|
||||||
> success for `""`, and `src/text.c:140-146` destroys both objects in a `CLEANUP` block that
|
|
||||||
> runs on every path. `TODO.md` items 27 and 23 record both as fixed. The header comments
|
|
||||||
> want correcting in their own commit.
|
|
||||||
|
|
||||||
## Fonts, the pools, and what is not shared
|
|
||||||
|
|
||||||
Nothing about fonts goes through the akgl heap. A `TTF_Font` is about ten kilobytes once
|
|
||||||
FreeType's own structures are counted, it is allocated by SDL_ttf, and it is freed by
|
|
||||||
`TTF_CloseFont`. The registry stores a pointer and nothing else.
|
|
||||||
|
|
||||||
That is different from spritesheets, which *are* pooled and *are* shared by resolved path —
|
|
||||||
see [Chapter 10](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at
|
|
||||||
the same size are two open fonts, not one shared one.
|
|
||||||
337
docs/18-audio.md
@@ -1,337 +0,0 @@
|
|||||||
# 18. Audio
|
|
||||||
|
|
||||||
**There are two audio subsystems in libakgl and they have nothing to do with each other.**
|
|
||||||
Not two layers, not two APIs over one engine — two unrelated things that happen to make
|
|
||||||
sound. Knowing which one you are in is most of using either.
|
|
||||||
|
|
||||||
| | Three-voice synthesizer | The asset path |
|
|
||||||
|---|---|---|
|
|
||||||
| Header | `audio.h` | `assets.h` |
|
|
||||||
| Functions | 10 | 1 |
|
|
||||||
| Whose code | **libakgl's own, in full** | SDL3_mixer's, wrapped |
|
|
||||||
| Reads a file | Never | Always |
|
|
||||||
| Device | Its own `SDL_AudioStream`, opened by `akgl_audio_init` | The `MIX_Mixer` created by `akgl_game_init` |
|
|
||||||
| Globals | `akgl_audio_voices[3]` | `akgl_mixer`, `akgl_bgm`, `akgl_tracks[64]` |
|
|
||||||
| Vocabulary | Notes: waveform, frequency, envelope | Recordings: load, track, play |
|
|
||||||
|
|
||||||
The first half of this chapter documents the synthesizer completely, because it is ours. The
|
|
||||||
second half is one function and a link, because the decoding, the formats, the mixers and
|
|
||||||
the tracks all belong to
|
|
||||||
[SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) and are documented there.
|
|
||||||
|
|
||||||
## Part 1 — the three-voice synthesizer
|
|
||||||
|
|
||||||
### Where the design comes from
|
|
||||||
|
|
||||||
The vocabulary is not invented. Every entry point maps onto a Commodore 128 BASIC 7.0
|
|
||||||
statement, and **the waveform enum values are literally the numbers `SOUND` takes**:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/audio.h
|
|
||||||
typedef enum {
|
|
||||||
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
|
||||||
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
|
||||||
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
|
||||||
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
|
||||||
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
|
||||||
} akgl_AudioWaveform;
|
|
||||||
```
|
|
||||||
|
|
||||||
| BASIC 7.0 | libakgl | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `SOUND v, f, d` | `akgl_audio_tone(voice, hz, ms)` | One pitch, held for a gate length |
|
|
||||||
| `SOUND v, f, d, dir, min, step` | `akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)` | Direction comes from the two frequencies, not from a sign |
|
|
||||||
| `SOUND v, f, d, , , , wf` | `akgl_audio_waveform(voice, waveform)` | Same five shapes, same first four numbers |
|
|
||||||
| `ENVELOPE n, a, d, s, r` | `akgl_audio_envelope(voice, attack, decay, sustain, release)` | Set per voice rather than per numbered preset |
|
|
||||||
| `VOL n` | `akgl_audio_volume(level)` | Master level, 0.0 to 1.0 |
|
|
||||||
| — | `akgl_audio_stop(voice)` | Hard cut; skips the release |
|
|
||||||
| — | `akgl_audio_voice_active(voice, &active)` | Wait out a note without a clock |
|
|
||||||
|
|
||||||
Three voices, because the machine this comes from had three and its music is written for
|
|
||||||
three:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/audio.h
|
|
||||||
#define AKGL_AUDIO_MAX_VOICES 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Voices are addressed from **zero** here. A language whose own voices are numbered from one
|
|
||||||
maps them itself.
|
|
||||||
|
|
||||||
### The vocabulary in practice
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/audio.h>
|
|
||||||
|
|
||||||
/* A coin pickup: a short square blip on voice 0. */
|
|
||||||
akerr_ErrorContext *sfx_coin(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_SQUARE));
|
|
||||||
PASS(errctx, akgl_audio_envelope(0, 0, 20, 0.6f, 60));
|
|
||||||
PASS(errctx, akgl_audio_tone(0, 1046.5f, 80));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A laser: pitch walks down from 1200 Hz to 200 Hz, 40 Hz every 1/60 s. */
|
|
||||||
akerr_ErrorContext *sfx_laser(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_audio_waveform(1, AKGL_AUDIO_WAVE_SAWTOOTH));
|
|
||||||
PASS(errctx, akgl_audio_envelope(1, 0, 0, 1.0f, 30));
|
|
||||||
PASS(errctx, akgl_audio_sweep(1, 1200.0f, 200.0f, 40.0f, 400));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wait for a note to finish without keeping a clock. */
|
|
||||||
akerr_ErrorContext *sfx_still_sounding(int voice, bool *sounding)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_audio_voice_active(voice, sounding));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_audio_waveform` and `akgl_audio_envelope` are **settings**: they persist on the voice
|
|
||||||
across notes and take effect on the next `akgl_audio_tone`. They do not reshape a note that
|
|
||||||
is already sounding. `akgl_audio_volume` is the exception — it is applied after the voices
|
|
||||||
are summed, so it does take effect on notes already playing.
|
|
||||||
|
|
||||||
**A voice you never described still makes a noise.** The table's defaults are a square wave,
|
|
||||||
sustain 1.0, and no attack, decay or release — held at full level for as long as the gate is
|
|
||||||
open. A *zeroed* voice would have a sustain of 0.0, which is silence, so a caller who sounded
|
|
||||||
a note without first setting an envelope would get nothing and no error saying why. The first
|
|
||||||
entry point to touch the table installs those defaults, whether or not a device is open.
|
|
||||||
|
|
||||||
### The envelope and the gate
|
|
||||||
|
|
||||||
`ms` is the length of the **gate**, not of the sound. The release runs after it, so a voice
|
|
||||||
with a release stays audible slightly longer than `ms`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
level
|
|
||||||
1.0 | /\
|
|
||||||
| / \____________
|
|
||||||
| / : \sustain release
|
|
||||||
sus |___/ : \________________.
|
|
||||||
| / : :\
|
|
||||||
0.0 |_/______:______________________________:_\______ time
|
|
||||||
|<-A-><-D->|<------- gate (ms) -------->|<-R->|
|
|
||||||
| |
|
|
||||||
gate opens gate closes
|
|
||||||
```
|
|
||||||
|
|
||||||
Zero-length stages are skipped rather than divided by. A zero attack starts at full level; a
|
|
||||||
zero decay drops to `sustain` at once; a zero release cuts off at once. `sustain` of 0.0 is
|
|
||||||
legal and gives a plucked sound — audible only through its attack and decay. An attack plus
|
|
||||||
decay longer than the gate is not an error either: the release simply starts partway up the
|
|
||||||
ramp.
|
|
||||||
|
|
||||||
**A voice goes quiet on its own** once gate and release are both spent. The mixer clears
|
|
||||||
`active`, which is why `akgl_audio_voice_active` is how you wait out a note.
|
|
||||||
|
|
||||||
### Sweeps
|
|
||||||
|
|
||||||
`akgl_audio_sweep` steps the pitch by `step_hz` every 1/60 of a second toward `to_hz`,
|
|
||||||
stopping when it arrives and holding there for whatever is left of the gate:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/audio.h
|
|
||||||
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
|
|
||||||
```
|
|
||||||
|
|
||||||
Sixty, because the machine whose `SOUND` statement this serves advanced its sweep on a 60 Hz
|
|
||||||
interrupt and its tunes are written for that rate. It divides the 44100 Hz sample rate
|
|
||||||
exactly, so a step boundary always lands on a whole frame.
|
|
||||||
|
|
||||||
**Direction comes from the two frequencies, not from the sign of `step_hz`.** A downward
|
|
||||||
sweep is `to_hz` below `from_hz` with `step_hz` still positive; a negative or zero `step_hz`
|
|
||||||
raises `AKERR_OUTOFBOUNDS`. Equal frequencies are legal and are simply a held tone, so a
|
|
||||||
caller translating a statement that computes its own limits does not have to special-case
|
|
||||||
them.
|
|
||||||
|
|
||||||
**The step is taken on the mixer's own frame counter, and that is the whole reason this
|
|
||||||
function exists.** A caller re-issuing tones ties audible pitch to how often it happens to
|
|
||||||
run, so the same siren changes shape with the frame rate. Nothing outside the library can
|
|
||||||
see that counter.
|
|
||||||
|
|
||||||
`akgl_audio_tone` clears any sweep left on the voice — a step of 0 is what says "one pitch,
|
|
||||||
held" — so you never have to undo one.
|
|
||||||
|
|
||||||
### The voice table exists without a device
|
|
||||||
|
|
||||||
`akgl_audio_voices[3]` is process-wide static storage. It is reachable, settable and
|
|
||||||
readable before `akgl_audio_init` has ever run, and every entry point works. What you cannot
|
|
||||||
do is set up voices with no device open and expect to *hear* them.
|
|
||||||
|
|
||||||
That leaves two ways to get sound out, and **you must pick one**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Path A -- libakgl owns the device
|
|
||||||
---------------------------------
|
|
||||||
akgl_audio_init()
|
|
||||||
| opens an SDL_AudioStream, F32, mono, 44100 Hz, and resumes it
|
|
||||||
| (SDL opens devices paused; this one does not, because a caller
|
|
||||||
| who set up a voice and heard nothing would have no error to
|
|
||||||
| explain it)
|
|
||||||
v
|
|
||||||
SDL's callback thread -> akgl_audio_mix(internal buffer, <= 512 frames)
|
|
||||||
|
|
||||||
|
|
||||||
Path B -- the host owns the device
|
|
||||||
----------------------------------
|
|
||||||
your own audio pipeline -> akgl_audio_mix(your buffer, frames)
|
|
||||||
| no device is ever opened here
|
|
||||||
```
|
|
||||||
|
|
||||||
**Do not use both.** `akgl_audio_mix` mutates the voice table — it advances each voice's
|
|
||||||
frame counter and clears `active` on voices that have finished — and **it does not take the
|
|
||||||
stream lock.** Every other entry point does, when there is a stream to lock: the internal
|
|
||||||
`lock_voices` is a no-op with no device open, which is exactly right for path B and exactly
|
|
||||||
wrong if you mix the two. Calling `akgl_audio_mix` yourself while a device opened by
|
|
||||||
`akgl_audio_init` is running is two threads advancing the same voices.
|
|
||||||
|
|
||||||
Path B is what a host with its own audio pipeline wants, and it is how `tests/audio.c`
|
|
||||||
exercises the synthesis without depending on a sound card's timing:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/audio.h>
|
|
||||||
|
|
||||||
#define HOST_FRAMES 512
|
|
||||||
|
|
||||||
/* A host that owns its own audio pipeline pulls samples itself and never
|
|
||||||
calls akgl_audio_init. Do not do both: this path does not take the
|
|
||||||
stream lock. */
|
|
||||||
akerr_ErrorContext *host_fill(float32_t *dest)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_audio_mix(dest, HOST_FRAMES));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Samples are single-precision, one channel, in the range -1.0 to 1.0. `dest` is **overwritten,
|
|
||||||
not accumulated into**, and the frame count is trusted rather than checked against anything —
|
|
||||||
`akgl_audio_mix` raises `AKERR_NULLPOINTER` for a `NULL` destination and `AKERR_OUTOFBOUNDS`
|
|
||||||
for a negative count, and that is the extent of the validation. A count of 0 is a no-op.
|
|
||||||
|
|
||||||
Three voices at full level can sum past full scale. The mix is **clamped** rather than scaled
|
|
||||||
by the voice count, which keeps a single voice at the level it was asked for instead of a
|
|
||||||
third of it.
|
|
||||||
|
|
||||||
### Sample rate, mix size, and the numbers
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/audio.h
|
|
||||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
|
||||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
|
||||||
|
|
||||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
|
||||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
|
||||||
```
|
|
||||||
|
|
||||||
Durations are milliseconds everywhere in the API and frames everywhere inside it. The
|
|
||||||
conversion truncates, so a duration under about 0.023 ms rounds to nothing. There is **no
|
|
||||||
upper bound on frequency**: a frequency above half the sample rate aliases rather than being
|
|
||||||
refused.
|
|
||||||
|
|
||||||
`akgl_audio_shutdown` closes the device and resets the voice table to its defaults —
|
|
||||||
including the master volume, back to 1.0 — so a later `akgl_audio_init` starts from a known
|
|
||||||
state. It is safe with no device open and safe to call twice. `akgl_audio_init` is a no-op
|
|
||||||
when a device is already open, and resets the voice table **only on the first call**, so it
|
|
||||||
does not silence a voice that is already sounding.
|
|
||||||
|
|
||||||
### What raises what
|
|
||||||
|
|
||||||
| Status | When |
|
|
||||||
|---|---|
|
|
||||||
| `AKERR_OUTOFBOUNDS` | A voice index outside 0–2; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.0–1.0; a waveform outside the five; a negative frame count |
|
|
||||||
| `AKERR_NULLPOINTER` | `NULL` passed to `akgl_audio_voice_active`'s `active` or `akgl_audio_mix`'s `dest` |
|
|
||||||
| `AKGL_ERR_SDL` | `akgl_audio_init` cannot open or resume a playback device. The message carries `SDL_GetError()` |
|
|
||||||
|
|
||||||
A zero-length tone is **refused** rather than treated as "stop", because `akgl_audio_stop`
|
|
||||||
already means that.
|
|
||||||
|
|
||||||
## Part 2 — the asset path
|
|
||||||
|
|
||||||
One function:
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/assets.h>
|
|
||||||
#include <akgl/game.h>
|
|
||||||
|
|
||||||
/* akgl_game_init creates akgl_mixer; this needs it. Nothing else in the
|
|
||||||
library calls akgl_load_start_bgm, and there is no music registry entry. */
|
|
||||||
akerr_ErrorContext *start_music(char *path)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_load_start_bgm(path));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
It loads `fname` through `akgl_mixer`, creates a `MIX_Track` for it, stores that track in
|
|
||||||
`akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]`, publishes the audio as `akgl_bgm`, and starts
|
|
||||||
playback.
|
|
||||||
|
|
||||||
**Which formats work is SDL3_mixer's question, not libakgl's** — see the
|
|
||||||
[SDL3_mixer documentation](https://wiki.libsdl.org/SDL3_mixer/). libakgl passes the path
|
|
||||||
through verbatim; it is not resolved against `SDL_GetBasePath()`, so a relative path is
|
|
||||||
relative to the process working directory. Loading is not deferred: `MIX_LoadAudio(..., true)`
|
|
||||||
predecodes, so a large file costs its decode time here rather than at first play.
|
|
||||||
|
|
||||||
`akgl_game.h` reserves slot 1 of a 64-slot track table for the music and leaves the rest to
|
|
||||||
you:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/game.h
|
|
||||||
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
|
|
||||||
#define AKGL_GAME_AUDIO_TRACK_BGM 1
|
|
||||||
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
|
|
||||||
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
|
|
||||||
```
|
|
||||||
|
|
||||||
### Four honest statements about this half
|
|
||||||
|
|
||||||
**It requires `akgl_game_init`, not `akgl_audio_init`.** `akgl_mixer` is created by
|
|
||||||
`akgl_game_init` at `src/game.c:250`, and it is the only place in the tree that creates it.
|
|
||||||
`assets.h:17-18` says "`akgl_game_init` (or a bare `akgl_audio_init`) has to have run first";
|
|
||||||
that is wrong. `akgl_audio_init` opens the *synthesizer's* `SDL_AudioStream` and never
|
|
||||||
touches `akgl_mixer`, so calling only that leaves `akgl_load_start_bgm` handing `NULL` to
|
|
||||||
`MIX_LoadAudio`.
|
|
||||||
|
|
||||||
**The music does not loop.** `src/assets.c:20` initialises `bgmprops` to 0,
|
|
||||||
`src/assets.c:43` sets `MIX_PROP_PLAY_LOOPS_NUMBER` on it, and `src/assets.c:45` plays the
|
|
||||||
track with it. **0 is SDL's "no property set" sentinel**, not a set this function owns, so the
|
|
||||||
write is rejected — unchecked — and the play call is given no options. The music plays once
|
|
||||||
and stops, and `akgl_load_start_bgm` reports success either way. `TODO.md` item 19; the fix
|
|
||||||
is `SDL_CreateProperties()` into `bgmprops`, checked, and destroyed in `CLEANUP`.
|
|
||||||
|
|
||||||
**`AKGL_REGISTRY_MUSIC` exists and nothing populates it.** `akgl_registry_init_music()`
|
|
||||||
creates the property set and `akgl_game_init` calls it, but no function in the library ever
|
|
||||||
writes an entry. If you want music by name, that registry is yours to fill and yours to read
|
|
||||||
— through `SDL_SetPointerProperty` and `SDL_GetPointerProperty` directly, as
|
|
||||||
[Chapter 6](06-the-registry.md) describes. There is no `akgl_music_*` API.
|
|
||||||
|
|
||||||
**There are no audio fixtures anywhere in this repository.** Not one `.wav`, `.ogg`, `.mp3`,
|
|
||||||
`.flac` or tracker module, in `tests/assets/` or `util/assets/` or anywhere else. Nothing in
|
|
||||||
the tree calls `akgl_load_start_bgm` either. **This function has no test coverage at all**,
|
|
||||||
which is worth knowing before you build on it: the loop defect above was found by reading
|
|
||||||
the code, and a test would have caught it.
|
|
||||||
|
|
||||||
### The overwrite and the leak on the way out
|
|
||||||
|
|
||||||
`akgl_load_start_bgm` overwrites `akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]` without releasing
|
|
||||||
whatever was there, so calling it twice leaks a `MIX_Track`. On a failure *after* the audio
|
|
||||||
loads, the audio is destroyed again before the error is returned — but the track, if one was
|
|
||||||
created, is not. Both are startup-helper behaviour and both are visible in the twelve lines
|
|
||||||
of `src/assets.c`; treat this as a "call it once at startup" function, which is what its
|
|
||||||
`@brief` says it is.
|
|
||||||
|
|
||||||
## Which one do I want?
|
|
||||||
|
|
||||||
**Sound effects for a game you are writing from scratch: the synthesizer.** No files, no
|
|
||||||
formats, no decode cost, no licensing question about the samples, and a laser is four lines.
|
|
||||||
|
|
||||||
**Music, or anything recorded: SDL3_mixer.** Either through `akgl_load_start_bgm` for the one
|
|
||||||
background track, or by using `akgl_mixer` directly with SDL3_mixer's own API — `akgl_mixer`
|
|
||||||
and `akgl_tracks` are public globals for exactly that reason, and there is nothing libakgl
|
|
||||||
adds that you would be going around.
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
# 19. Utilities
|
|
||||||
|
|
||||||
`util.h`, `json_helpers.h` and `staticstring.h` hold the pieces that belong to no subsystem:
|
|
||||||
rectangle overlap, asset path resolution, typed JSON accessors, and the pooled string type
|
|
||||||
everything else passes around. The JSON accessors are the valuable part of this chapter —
|
|
||||||
their status semantics are what every asset loader in the library is written against, and
|
|
||||||
they are documented nowhere else.
|
|
||||||
|
|
||||||
## The rectangle overlap test
|
|
||||||
|
|
||||||
One function, in `util.h`:
|
|
||||||
|
|
||||||
| Function | Question it answers |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_collide_rectangles(r1, r2, collide)` | Do these two rectangles overlap? |
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <SDL3/SDL.h>
|
|
||||||
#include <akgl/util.h>
|
|
||||||
|
|
||||||
/* Do two actor boxes touch? Edges count as touching. */
|
|
||||||
akerr_ErrorContext *boxes_touch(SDL_FRect *a, SDL_FRect *b, bool *hit)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_collide_rectangles(a, b, hit));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Three properties, all deliberate:
|
|
||||||
|
|
||||||
**Axis-aligned only.** There is no rotation support and no separating-axis test here. If you
|
|
||||||
want either, you want a collision shape — [Chapter 15](15-collision.md).
|
|
||||||
|
|
||||||
**Edges count as touching.** Two rectangles sharing an edge and no area overlap. Every
|
|
||||||
comparison is `<=`, and a version written with `<` would silently change what every pickup
|
|
||||||
test in a consumer answers.
|
|
||||||
|
|
||||||
**The comparison is in `float32_t`.** No intermediate form, no truncation, so an overlap
|
|
||||||
smaller than a pixel registers.
|
|
||||||
|
|
||||||
### The arrangement it used to miss
|
|
||||||
|
|
||||||
`akgl_collide_rectangles` compares the two rectangles' spans on both axes. Until 0.8.0 it
|
|
||||||
asked a different question — whether either rectangle enclosed one of the other's four
|
|
||||||
corners, eight tests, stopping at the first hit — and that question has the wrong answer for
|
|
||||||
one arrangement:
|
|
||||||
|
|
||||||
```text
|
|
||||||
+---+ +---+
|
|
||||||
| | | |
|
|
||||||
+---|---|---+ +---+---+---+ <- overlaps, but
|
|
||||||
| | | | was | | | | no corner of
|
|
||||||
+---|---|---+ reported +---+---+---+ either is inside
|
|
||||||
| | as | | the other
|
|
||||||
+---+ "no hit" +---+
|
|
||||||
```
|
|
||||||
|
|
||||||
A tall thin rectangle crossing a short wide one overlaps in a plus sign with no corner of
|
|
||||||
either inside the other, so all eight tests said no — and a long thin platform crossing a
|
|
||||||
tall thin character is exactly that shape. The header carried a `@note` describing it rather
|
|
||||||
than a fix.
|
|
||||||
|
|
||||||
It is four comparisons now, `r1.x <= r2.x + r2.w && r2.x <= r1.x + r1.w` on both axes.
|
|
||||||
|
|
||||||
**If you are upgrading from 0.7.x, three things change.** The cross now reports `true`, which
|
|
||||||
is the point. The comparison no longer round-trips through an `int` intermediate, so overlaps
|
|
||||||
and gaps smaller than a pixel are seen rather than truncated away — a pickup test that was
|
|
||||||
accidentally forgiving by up to a pixel is no longer forgiving. And **four symbols are
|
|
||||||
gone**:
|
|
||||||
|
|
||||||
| Removed in 0.8.0 | Why | What to write instead |
|
|
||||||
|---|---|---|
|
|
||||||
| `akgl_rectangle_points` | Built the corner form `akgl_collide_rectangles` no longer uses | Nothing. It had no other caller |
|
|
||||||
| `akgl_collide_point_rectangle` | Same | `p.x >= r.x && p.x <= r.x + r.w` on both axes — four comparisons, and no `SDL_FRect`-to-corners conversion in front of them |
|
|
||||||
| `akgl_Point` | Existed to carry an `int` position into the above, with a `z` nothing ever read | `SDL_FPoint`, if you want a point type |
|
|
||||||
| `akgl_RectanglePoints` | Existed to carry four of them | — |
|
|
||||||
|
|
||||||
Nothing outside `tests/` called either function. They were the intermediate form of an
|
|
||||||
implementation that changed.
|
|
||||||
|
|
||||||
### What stays, and why it is not collision
|
|
||||||
|
|
||||||
**The library runs its own collision** — [Chapter 15](15-collision.md) — and it does not use
|
|
||||||
this. `akgl_collision_test` works in centres and half-extents, answers with a normal and a
|
|
||||||
depth rather than a boolean, and handles circles and capsules as well as boxes.
|
|
||||||
|
|
||||||
`akgl_collide_rectangles` stays because a game-level overlap question is not always a physics
|
|
||||||
question: a coin, a minimap marker, a UI hit test, "is the cursor over this". Both tutorials
|
|
||||||
use it for pickups and hazards from an `updatefunc`, where a `bool` is the whole answer and a
|
|
||||||
proxy, a broad-phase insert and a narrowphase call would be computing a normal nothing reads.
|
|
||||||
For anything that should push or be pushed, use a collision shape.
|
|
||||||
|
|
||||||
## Path resolution
|
|
||||||
|
|
||||||
Asset files name their neighbours relatively: a sprite definition names its spritesheet, a
|
|
||||||
tilemap names its tilesets. "Relative" has to mean *relative to the file doing the naming*,
|
|
||||||
not to wherever the game was launched from — and `akgl_path_relative` is what makes that
|
|
||||||
true.
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
#include <akgl/util.h>
|
|
||||||
|
|
||||||
/* Resolve one asset's reference to a neighbour. dest must already be a
|
|
||||||
claimed pool string -- this writes into it, it does not claim one. */
|
|
||||||
akerr_ErrorContext *resolve_neighbour(char *mapdir, char *reference)
|
|
||||||
{
|
|
||||||
akgl_String *resolved = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
PASS(errctx, akgl_heap_next_string(&resolved));
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_path_relative(mapdir, reference, resolved));
|
|
||||||
SDL_Log("%s -> %s", reference, resolved->data);
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(resolved));
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The order it tries things in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
akgl_path_relative(root, path, dest)
|
|
||||||
|
|
|
||||||
1. realpath(path) <- relative to the process CWD
|
|
||||||
| |
|
|
||||||
| +-- succeeds -> absolute result into dest, done
|
|
||||||
| |
|
|
||||||
| +-- ENOENT ---+
|
|
||||||
| |
|
|
||||||
2. +-> realpath(root + "/" + path)
|
|
||||||
|
|
|
||||||
+-- succeeds -> absolute result, done
|
|
||||||
|
|
|
||||||
+-- fails -> the errno propagates
|
|
||||||
```
|
|
||||||
|
|
||||||
Either way the result is **absolute**, with symlinks and `..` folded out, because both
|
|
||||||
branches end in `realpath(3)`.
|
|
||||||
|
|
||||||
Three things to know:
|
|
||||||
|
|
||||||
- **`dest` must already be a claimed pool string.** This writes into it; it does not claim
|
|
||||||
one for you. `root` is required even on the path where it goes unused.
|
|
||||||
- **The fallback pastes `root` in front of `path` unconditionally**, so an absolute `path`
|
|
||||||
that does not exist gets `root` prepended and fails on a nonsense spelling. Do not send
|
|
||||||
absolute paths you expect to fall through.
|
|
||||||
- **`AKERR_OUTOFBOUNDS`** if `root + "/" + path` would not fit `AKGL_MAX_STRING_LENGTH`
|
|
||||||
(`PATH_MAX`); **`ENOENT`**, or whatever else `realpath(3)` reports — `EACCES`, `ELOOP`,
|
|
||||||
`ENOTDIR` — if neither spelling names an existing file; **`AKGL_ERR_HEAP`** if the string
|
|
||||||
pool cannot supply the scratch buffers.
|
|
||||||
|
|
||||||
> **Stale defect note.** `util.h:124-128` warns that the fallback path "returns straight out
|
|
||||||
> of the ENOENT handler and so never releases the error context it was handling", costing one
|
|
||||||
> of libakerror's 128 context slots per call for the life of the process, and `TODO.md` item
|
|
||||||
> 15 records the same thing as open. **Both are out of date.** `src/util.c:115-129` sets a
|
|
||||||
> flag in the `HANDLE(errctx, ENOENT)` block and calls `path_relative_root` *after* `FINISH`,
|
|
||||||
> which is exactly the fix that entry proposes, and the code carries a comment explaining
|
|
||||||
> why. The leak is gone; the two notes describing it are not.
|
|
||||||
|
|
||||||
## The JSON accessors
|
|
||||||
|
|
||||||
Every asset format in this library is JSON, parsed by
|
|
||||||
[jansson](https://jansson.readthedocs.io/), and jansson's own accessors answer "missing
|
|
||||||
key", "wrong type" and "index past the end" all with the same `NULL`. `json_helpers.h`
|
|
||||||
splits them apart so a malformed asset file produces a message naming the key and what was
|
|
||||||
wrong with it, instead of a `NULL` dereference three frames later.
|
|
||||||
|
|
||||||
**jansson owns `json_t`, `json_decref`, the parser and the reference-counting rules.** What
|
|
||||||
this section documents is which status means what, and who owns the results.
|
|
||||||
|
|
||||||
### Five conventions that run through the whole set
|
|
||||||
|
|
||||||
**Absence is an error.** A missing key is `AKERR_KEY` — not a quiet `NULL`, not a zero. That
|
|
||||||
is the opposite of the search functions in libakstdlib, and it is deliberate: an optional key
|
|
||||||
is expressed by handing the resulting error to `akgl_get_json_with_default`, not by the
|
|
||||||
accessor staying quiet. So `AKERR_KEY` in a loader is frequently a `HANDLE` block rather than
|
|
||||||
a failure.
|
|
||||||
|
|
||||||
**Results come back through `dest`,** because the return value is the error context. Every
|
|
||||||
argument is `NULL`-checked — the document or array, the key where there is one, and `dest` —
|
|
||||||
and each raises `AKERR_NULLPOINTER` with a message saying which. `dest` is not written on any
|
|
||||||
failure path.
|
|
||||||
|
|
||||||
**`json_t *` results are borrowed, not owned.** `akgl_get_json_object_value`,
|
|
||||||
`akgl_get_json_array_value` and `akgl_get_json_array_index_object` hand back pointers *into*
|
|
||||||
the document. They are valid until the document is freed and **must never be
|
|
||||||
`json_decref`'d.** Doing so drops a reference the accessor never took and takes the sub-object
|
|
||||||
out from under the document that owns it.
|
|
||||||
|
|
||||||
**Strings are the exception: they are copied.** `akgl_get_json_string_value` and
|
|
||||||
`akgl_get_json_array_index_string` write into a pooled `akgl_String` you release yourself.
|
|
||||||
`*dest` doubles as an input — `NULL` means "claim one for me", non-`NULL` means "write into
|
|
||||||
this one" — and it must be *initialized* either way, because an indeterminate `*dest` is
|
|
||||||
dereferenced. A value longer than `AKGL_MAX_STRING_LENGTH` is truncated silently.
|
|
||||||
|
|
||||||
**The document is never modified.** No accessor writes to it, adds to it, or reorders it.
|
|
||||||
|
|
||||||
### The status table
|
|
||||||
|
|
||||||
These are the four statuses this family raises. [Chapter 4](04-errors.md) carries the full
|
|
||||||
tables for the library; this one says what each means *here*.
|
|
||||||
|
|
||||||
| Status | Means | Raised by |
|
|
||||||
|---|---|---|
|
|
||||||
| `AKERR_NULLPOINTER` | Any argument was `NULL` — the document, the key, or `dest` | All |
|
|
||||||
| `AKERR_KEY` | **The key is absent.** The idiomatic "optional thing was not there" | The object accessors |
|
|
||||||
| `AKERR_TYPE` | The key is present but is the wrong JSON type | All |
|
|
||||||
| `AKERR_OUTOFBOUNDS` | The index is outside the array. A negative index reports the same way as one past the end | The `akgl_get_json_array_index_*` family |
|
|
||||||
| `AKGL_ERR_HEAP` | `*dest` was `NULL` and the string pool is exhausted | The two string accessors |
|
|
||||||
|
|
||||||
**`AKERR_INDEX` is not in that list, and libakgl never raises it anywhere.** It appears in
|
|
||||||
this subsystem only as a `HANDLE_GROUP` arm inside `akgl_get_json_with_default`, defending
|
|
||||||
against a status no accessor produces. Harmless, and worth knowing before you write a
|
|
||||||
`HANDLE` block for it.
|
|
||||||
|
|
||||||
> **Stale header prose.** The conventions block at the top of `json_helpers.h` says "**@p
|
|
||||||
> dest is not `NULL`-checked** except where noted, so a `NULL` there is a crash rather than an
|
|
||||||
> error context", and `akgl_get_json_string_value`'s `@param key` says it is "checked here,
|
|
||||||
> unlike the other accessors in this file". **Neither is true.** All eleven accessors check
|
|
||||||
> `dest`, and all seven that take a `key` check that too — `src/json_helpers.c` raises
|
|
||||||
> `AKERR_NULLPOINTER` with "NULL destination pointer" or "NULL key string" at every one. The
|
|
||||||
> per-function claim that a `NULL` key "is reported as a missing key rather than as a `NULL`
|
|
||||||
> pointer" is wrong the same way.
|
|
||||||
|
|
||||||
### Integer is strict; number is not
|
|
||||||
|
|
||||||
This is the distinction that catches people:
|
|
||||||
|
|
||||||
| Accessor | `"w": 3` | `"w": 3.0` | `"w": "3"` |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `akgl_get_json_integer_value` | 3 | **`AKERR_TYPE`** | `AKERR_TYPE` |
|
|
||||||
| `akgl_get_json_number_value` | 3.0f | 3.0f | `AKERR_TYPE` |
|
|
||||||
| `akgl_get_json_double_value` | 3.0 | 3.0 | `AKERR_TYPE` |
|
|
||||||
|
|
||||||
`3.0` is a *real* in JSON, and `akgl_get_json_integer_value` refuses it rather than
|
|
||||||
truncating it. Use one of the number accessors where either spelling should be accepted;
|
|
||||||
`_double_value` exists because the physics constants are `double`.
|
|
||||||
|
|
||||||
`akgl_get_json_boolean_value` is strict in the same direction: `0` and `1` are numbers in
|
|
||||||
JSON and are refused. It wants `true` or `false`.
|
|
||||||
|
|
||||||
### Reading a document
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <jansson.h>
|
|
||||||
#include <akgl/json_helpers.h>
|
|
||||||
#include <akgl/heap.h>
|
|
||||||
#include <akgl/staticstring.h>
|
|
||||||
|
|
||||||
/* A required key, an optional one with a default, and a borrowed sub-object. */
|
|
||||||
akerr_ErrorContext *read_sprite_header(json_t *doc)
|
|
||||||
{
|
|
||||||
json_t *frames = NULL; /* BORROWED. Never json_decref this. */
|
|
||||||
akgl_String *name = NULL; /* Copied, and pooled. Release it. */
|
|
||||||
int width = 0;
|
|
||||||
int width_default = 32;
|
|
||||||
float32_t scale = 0.0f;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
/* Required: an absent "name" is AKERR_KEY and propagates. */
|
|
||||||
CATCH(errctx, akgl_get_json_string_value(doc, "name", &name));
|
|
||||||
|
|
||||||
/* Optional: AKERR_KEY becomes width_default and a NULL return. */
|
|
||||||
CATCH(errctx, akgl_get_json_with_default(
|
|
||||||
akgl_get_json_integer_value(doc, "width", &width),
|
|
||||||
&width_default,
|
|
||||||
&width,
|
|
||||||
sizeof(int)));
|
|
||||||
|
|
||||||
/* Strict: "scale": 2 and "scale": 2.0 are both accepted here.
|
|
||||||
akgl_get_json_integer_value would refuse the second. */
|
|
||||||
CATCH(errctx, akgl_get_json_number_value(doc, "scale", &scale));
|
|
||||||
|
|
||||||
/* Borrowed: valid until the document is freed. */
|
|
||||||
CATCH(errctx, akgl_get_json_array_value(doc, "frames", &frames));
|
|
||||||
SDL_Log("%s: %d wide, %.1f scale, %d frames",
|
|
||||||
name->data, width, scale, (int)json_array_size(frames));
|
|
||||||
} CLEANUP {
|
|
||||||
IGNORE(akgl_heap_release_string(name));
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `akgl_get_json_with_default` — how optional is spelled
|
|
||||||
|
|
||||||
Run the accessor, hand its error context here along with the fallback. A status it handles
|
|
||||||
becomes `dest` holding the default and a `NULL` return; anything else propagates untouched.
|
|
||||||
When the error *is* consumed it is also released, so you must not release it again. A `NULL`
|
|
||||||
`e` — the accessor succeeded — returns at once without touching `dest`.
|
|
||||||
|
|
||||||
**It defaults on three statuses**, and this is the current behaviour, checked against
|
|
||||||
`src/json_helpers.c:207-211`:
|
|
||||||
|
|
||||||
| Status | Defaulted? | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `AKERR_KEY` | Yes | The key is absent. That is what "optional" means for an object member |
|
|
||||||
| `AKERR_OUTOFBOUNDS` | **Yes** | The index is past the end. That is what "optional" means for an array element |
|
|
||||||
| `AKERR_INDEX` | Yes | Defensive only — nothing in libakgl raises it |
|
|
||||||
| `AKERR_TYPE` | **No** | Propagates, and that is the correct line to draw |
|
|
||||||
|
|
||||||
**The `AKERR_TYPE` exclusion is the interesting one.** "The key is missing" and "the key is
|
|
||||||
present but holds a string where a number belongs" are not the same situation. The first is
|
|
||||||
an omitted setting and a default is exactly right for it. The second is a malformed document,
|
|
||||||
and quietly substituting a default there would hide the defect and hand the game a value the
|
|
||||||
author never wrote. So it propagates.
|
|
||||||
|
|
||||||
`AKERR_OUTOFBOUNDS` is the one that changed, because it is what the
|
|
||||||
`akgl_get_json_array_index_*` family actually raises for a short array. **An array element
|
|
||||||
does get a default now**, and "this element is optional" works for an array index as well as
|
|
||||||
for an object member.
|
|
||||||
|
|
||||||
> **Stale header prose.** `include/akgl/json_helpers.h:230-234` still says the opposite —
|
|
||||||
> that it defaults on `AKERR_KEY` and `AKERR_INDEX` "but *not* on `AKERR_OUTOFBOUNDS` … so
|
|
||||||
> this pairs with the object accessors and does not currently give an array index a default".
|
|
||||||
> That was true before 0.5.0. `src/json_helpers.c` now carries a third
|
|
||||||
> `HANDLE_GROUP(e, AKERR_OUTOFBOUNDS)` arm, placed *above* the arm holding the `memcpy`
|
|
||||||
> because `HANDLE_GROUP` emits no `break` and every arm falls into that body, and
|
|
||||||
> `tests/json_helpers.c` covers it through both the integer and object index accessors.
|
|
||||||
> `TODO.md` item 18 records it as fixed. The header comment wants correcting in its own
|
|
||||||
> commit.
|
|
||||||
|
|
||||||
`defsize` is trusted, not derived: it is a `memcpy` through `void *` with no type
|
|
||||||
information, and it must match the type both sides actually are.
|
|
||||||
|
|
||||||
**`defsize` is not the trap, though. Ownership is.**
|
|
||||||
|
|
||||||
```c
|
|
||||||
#include <akerror.h>
|
|
||||||
#include <jansson.h>
|
|
||||||
#include <akgl/error.h>
|
|
||||||
#include <akgl/json_helpers.h>
|
|
||||||
|
|
||||||
/* akgl_get_json_with_default hands BACK the context it was given when it
|
|
||||||
does not handle the status. Take the result into a local, drop your own
|
|
||||||
pointer, and release it yourself -- a CLEANUP block that also releases it
|
|
||||||
double-releases, and a double-released context corrupts the failure
|
|
||||||
instead of reporting it. */
|
|
||||||
akerr_ErrorContext *optional_int(json_t *doc, char *key, int *dest, int fallback)
|
|
||||||
{
|
|
||||||
akerr_ErrorContext *keyerr = NULL;
|
|
||||||
akerr_ErrorContext *defaulted = NULL;
|
|
||||||
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
keyerr = akgl_get_json_integer_value(doc, key, dest);
|
|
||||||
defaulted = akgl_get_json_with_default(keyerr, &fallback, dest, sizeof(int));
|
|
||||||
keyerr = NULL; /* ownership passed either way */
|
|
||||||
if ( defaulted != NULL ) {
|
|
||||||
defaulted->handled = true;
|
|
||||||
defaulted = akerr_release_error(defaulted);
|
|
||||||
FAIL_RETURN(errctx, AKGL_ERR_BEHAVIOR, "%s was present but not an integer", key);
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The inline form in the previous example — passing the accessor's return straight into
|
|
||||||
`akgl_get_json_with_default` inside a `CATCH` — is safe, because you never hold a second
|
|
||||||
pointer to the context. It is when you *keep* one that this matters, and a test written the
|
|
||||||
naive way passes against broken code: the first draft of the `AKERR_OUTOFBOUNDS` test in
|
|
||||||
`tests/json_helpers.c` did exactly that. See [Chapter 4](04-errors.md) and `AGENTS.md` under
|
|
||||||
"Testing Guidelines".
|
|
||||||
|
|
||||||
### The Tiled property helpers are elsewhere
|
|
||||||
|
|
||||||
`akgl_get_json_properties_integer`, `_number`, `_float`, `_double`, `_string` and
|
|
||||||
`akgl_get_json_tilemap_property` are declared in `tilemap.h`, not here. They read Tiled's
|
|
||||||
custom-property array — a list of `{"name": …, "type": …, "value": …}` objects — rather than
|
|
||||||
a plain JSON object, which is a different shape and belongs to a different chapter. See
|
|
||||||
[Chapter 13](13-tilemaps.md).
|
|
||||||
|
|
||||||
## `akgl_String`
|
|
||||||
|
|
||||||
The library allocates nothing at runtime, so a string here is a fixed-capacity buffer claimed
|
|
||||||
from the pool:
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/staticstring.h
|
|
||||||
#define AKGL_MAX_STRING_LENGTH PATH_MAX
|
|
||||||
|
|
||||||
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
|
|
||||||
typedef struct
|
|
||||||
{
|
|
||||||
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
|
|
||||||
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
|
|
||||||
} akgl_String;
|
|
||||||
```
|
|
||||||
|
|
||||||
`data` is a plain `char[]`, so `str->data` goes anywhere a `char *` does. `refcount` belongs
|
|
||||||
to the heap layer — [Chapter 5](05-the-heap.md) — and **`akgl_heap_next_string` is the one
|
|
||||||
acquire function that takes the reference for you**, unlike the other seven pools.
|
|
||||||
|
|
||||||
Two functions operate on the contents:
|
|
||||||
|
|
||||||
| Function | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_string_initialize(obj, init)` | Copies `init` in, or zero-fills when `init` is `NULL`, and sets `refcount` to 1. Always NUL-terminates; truncates silently |
|
|
||||||
| `akgl_string_copy(src, dest, count)` | Bounded copy between two already-claimed slots. `count` of 0 selects the whole buffer. Always NUL-terminates. Leaves `refcount` alone |
|
|
||||||
|
|
||||||
**Capacity is `PATH_MAX`**, which is what makes the string pool by far the largest of the
|
|
||||||
five — 256 slots is about a megabyte — and why claiming the *last* free slot from it costs
|
|
||||||
250 ns against 4 ns from the actor pool. Same algorithm, different cache behaviour; the scan
|
|
||||||
touches one reference count every 4 KiB. `PERFORMANCE.md` has the measurement.
|
|
||||||
|
|
||||||
`akgl_string_copy` refuses a negative `count` or one above `AKGL_MAX_STRING_LENGTH` with
|
|
||||||
`AKERR_OUTOFBOUNDS`, because both buffers are exactly that long and a larger count walked off
|
|
||||||
the end of two pool slots at once.
|
|
||||||
|
|
||||||
## The two test-only helpers
|
|
||||||
|
|
||||||
`akgl_compare_sdl_surfaces` and `akgl_render_and_compare` are in `util.h` and are labelled in
|
|
||||||
the header as "REALLY slow routines that are only useful in testing harnesses". They are not
|
|
||||||
frame-path functions and this manual does not otherwise cover them:
|
|
||||||
|
|
||||||
- `akgl_compare_sdl_surfaces(s1, s2)` — dimensions, pitch and format first, then a `memcmp`
|
|
||||||
over the raw pixels. A mismatch is reported as `AKERR_VALUE`, an error rather than an
|
|
||||||
out-parameter.
|
|
||||||
- `akgl_render_and_compare(t1, t2, x, y, w, h, writeout)` — draws each texture into the same
|
|
||||||
rectangle, reads the framebuffer back after each, and compares. Answers "do these render
|
|
||||||
the same", which is not the same question as "are these identical", because the renderer's
|
|
||||||
scaling and blending sit in between. `writeout` names a PNG of the first render, under
|
|
||||||
`SDL_GetBasePath()`, which is how you see what was actually drawn when an assertion fails.
|
|
||||||
|
|
||||||
Both have a scar worth repeating. Until 0.5.0 `akgl_render_and_compare` drew `t1` on **both**
|
|
||||||
passes, so it always reported a match and every image assertion built on it asserted nothing.
|
|
||||||
396
docs/22-ui.md
@@ -1,396 +0,0 @@
|
|||||||
# 22. User interfaces
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling
|
|
||||||
them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once.
|
|
||||||
[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line
|
|
||||||
`textbox.c` is still the right call for one panel with no ambitions. This chapter is for
|
|
||||||
everything past that point: HUDs, menus, options screens, and the layout arithmetic that
|
|
||||||
makes them miserable to maintain by hand.
|
|
||||||
|
|
||||||
The layout engine is [clay](https://github.com/nicbarker/clay), vendored under
|
|
||||||
`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is
|
|
||||||
documented by clay — its README is thorough — and this chapter covers what libakgl adds
|
|
||||||
or constrains. The split:
|
|
||||||
|
|
||||||
| clay owns | libakgl owns |
|
|
||||||
|---|---|
|
|
||||||
| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` |
|
|
||||||
| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry |
|
|
||||||
| The render command list each frame produces | Drawing those commands, through the render backend |
|
|
||||||
| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed |
|
|
||||||
|
|
||||||
**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`,
|
|
||||||
`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple
|
|
||||||
game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's
|
|
||||||
declarative blocks yourself between the same two frame calls, with the whole DSL
|
|
||||||
available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does
|
|
||||||
both: its title menu and HUD are widgets, its options screen is raw `CLAY()`.
|
|
||||||
|
|
||||||
**What this chapter assumes.** A program that already brings libakgl up — the
|
|
||||||
`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or
|
|
||||||
[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from
|
|
||||||
[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only
|
|
||||||
three calls to it, shown below. When something in *your* bring-up fights you, the demo's
|
|
||||||
`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete
|
|
||||||
working sequence to diff against — including the `main()` shape, which the runnable
|
|
||||||
snippets in this manual deliberately hide.
|
|
||||||
|
|
||||||
Like collision, the subsystem is optional at runtime rather than at build time: it is
|
|
||||||
always compiled in, costs static storage until `akgl_ui_init` runs, and a game that
|
|
||||||
never calls that pays nothing else.
|
|
||||||
|
|
||||||
**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its
|
|
||||||
symbols, because the `CLAY()` macros in *your* translation units expand to calls into
|
|
||||||
them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay
|
|
||||||
— two definitions of the same symbols, and the loader picks one silently.
|
|
||||||
|
|
||||||
## Bring it up, lay something out
|
|
||||||
|
|
||||||
`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not
|
|
||||||
read from the camera or the window, so a headless program can bring the UI up with no
|
|
||||||
renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them,
|
|
||||||
and refuses **with both byte counts in the message** when it does not: a raised ceiling
|
|
||||||
without a raised arena is a loud startup failure, never a corruption at frame forty
|
|
||||||
thousand.
|
|
||||||
|
|
||||||
After that, a frame of UI is a bracket with declarations inside:
|
|
||||||
|
|
||||||
```c run=akglapp
|
|
||||||
PASS(errctx, akgl_ui_init(320, 240));
|
|
||||||
|
|
||||||
PASS(errctx, akgl_ui_frame_begin());
|
|
||||||
CLAY({
|
|
||||||
.id = CLAY_ID("panel"),
|
|
||||||
.layout = {
|
|
||||||
.sizing = {
|
|
||||||
.width = CLAY_SIZING_FIXED(120),
|
|
||||||
.height = CLAY_SIZING_FIXED(40)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
.backgroundColor = { 24, 20, 37, 255 }
|
|
||||||
}) {}
|
|
||||||
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
||||||
|
|
||||||
PASS(errctx, akgl_ui_shutdown());
|
|
||||||
printf("one panel, laid out and drawn\n");
|
|
||||||
```
|
|
||||||
|
|
||||||
```output
|
|
||||||
one panel, laid out and drawn
|
|
||||||
```
|
|
||||||
|
|
||||||
`frame_begin` starts the clay layout and feeds it the pointer state the event handler
|
|
||||||
has been accumulating; `frame_end` computes the layout and draws every render command it
|
|
||||||
produces through the backend — fills and rounded fills, borders, clip rectangles, text,
|
|
||||||
and sprites. Everything lands in screen coordinates on top of whatever is already on the
|
|
||||||
target.
|
|
||||||
|
|
||||||
Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId;
|
|
||||||
libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is
|
|
||||||
`akgl_ui_font_register`, and the demo's whole font story is three lines:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE));
|
|
||||||
PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT));
|
|
||||||
PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid));
|
|
||||||
```
|
|
||||||
|
|
||||||
The first font registered gets id 0, which is what the widgets' default style uses — so
|
|
||||||
a one-font game never mentions a fontId again. Two things worth knowing before they
|
|
||||||
surprise you:
|
|
||||||
|
|
||||||
- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at
|
|
||||||
load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is
|
|
||||||
the size the font behind its id was loaded at. One face at two sizes is two loads, two
|
|
||||||
registrations, two ids.
|
|
||||||
- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a
|
|
||||||
registered font makes the next frame fail loudly with the name in the message — not
|
|
||||||
dangle.
|
|
||||||
|
|
||||||
## The frame contract
|
|
||||||
|
|
||||||
Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`,
|
|
||||||
which calls `akgl_game_update` where this program paints a checkerboard:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
||||||
if ( state == UIDEMO_STATE_PLAY ) {
|
|
||||||
PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT));
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
PASS(errctx, akgl_ui_frame_begin());
|
|
||||||
switch ( state ) {
|
|
||||||
case UIDEMO_STATE_TITLE:
|
|
||||||
PASS(errctx, declare_title());
|
|
||||||
break;
|
|
||||||
case UIDEMO_STATE_OPTIONS:
|
|
||||||
PASS(errctx, declare_options());
|
|
||||||
break;
|
|
||||||
case UIDEMO_STATE_PLAY:
|
|
||||||
PASS(errctx, declare_play());
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
||||||
```
|
|
||||||
|
|
||||||
The UI draws after the world because it is declared after the world — the overlay slot
|
|
||||||
between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew
|
|
||||||
its hand-rolled text box, and nothing about that slot changed.
|
|
||||||
|
|
||||||
Events go through the UI *first*. `akgl_ui_handle_event` takes every event
|
|
||||||
unconditionally — the same pass-everything contract as `akgl_controller_handle_event` —
|
|
||||||
and reports back whether the UI claimed it, so a click on a menu never leaks through and
|
|
||||||
also fires a game control:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
|
|
||||||
if ( consumed ) {
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ( state ) {
|
|
||||||
case UIDEMO_STATE_TITLE:
|
|
||||||
PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed));
|
|
||||||
break;
|
|
||||||
```
|
|
||||||
|
|
||||||
Three rules govern what gets consumed, and each is a decision worth stating:
|
|
||||||
|
|
||||||
- **Presses, releases and the wheel are consumed when the pointer is over any UI
|
|
||||||
element.** Motion and resizes never are — the game may care where the mouse is, and
|
|
||||||
certainly cares about its window.
|
|
||||||
- **The hit test runs against the layout the previous frame declared**, because this
|
|
||||||
frame's does not exist while events are being polled. clay retains the last tree for
|
|
||||||
exactly this purpose; one frame of staleness is the standard model's accepted cost,
|
|
||||||
and before any frame has been laid out, nothing is over anything.
|
|
||||||
- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow
|
|
||||||
keys is something the application declares — the `switch` above *is* the focus model —
|
|
||||||
not something a pointer position implies.
|
|
||||||
|
|
||||||
## A dialog in one call — and what it replaces
|
|
||||||
|
|
||||||
The play screen's declarations, whole:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score));
|
|
||||||
PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives));
|
|
||||||
PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
|
|
||||||
PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
|
|
||||||
if ( dialog_open ) {
|
|
||||||
PASS(errctx, akgl_ui_dialog("dialog",
|
|
||||||
"This panel is one call. Space dismisses it; "
|
|
||||||
"compare examples/jrpg/textbox.c.",
|
|
||||||
NULL));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open
|
|
||||||
because this frame declares it — a declarative frame *is* the flag. The text buffers are
|
|
||||||
`static` because clay borrows the pointer until `frame_end` rather than copying; format
|
|
||||||
your score into storage that outlives the bracket.
|
|
||||||
|
|
||||||
Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the
|
|
||||||
comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the
|
|
||||||
string, and its draw function does the geometry itself:
|
|
||||||
|
|
||||||
```c excerpt=examples/jrpg/textbox.c
|
|
||||||
panel.x = TEXTBOX_MARGIN;
|
|
||||||
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
|
|
||||||
panel.h = TEXTBOX_HEIGHT;
|
|
||||||
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
|
|
||||||
|
|
||||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
|
|
||||||
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
|
|
||||||
PASS(errctx,
|
|
||||||
akgl_text_rendertextat(
|
|
||||||
font,
|
|
||||||
textbox_text,
|
|
||||||
TEXTBOX_INK,
|
|
||||||
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
|
|
||||||
(int)(panel.x + TEXTBOX_PADDING),
|
|
||||||
(int)(panel.y + TEXTBOX_PADDING)
|
|
||||||
));
|
|
||||||
```
|
|
||||||
|
|
||||||
The widget's default style is **deliberately that panel's palette** — near-black fill,
|
|
||||||
parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and
|
|
||||||
the trade is visible with nothing hidden in a theme:
|
|
||||||
|
|
||||||
- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no
|
|
||||||
frame bracket; its geometry is four assignments you can read. For one panel, that is a
|
|
||||||
perfectly good deal — which is why chapter 21 still teaches it and its game still
|
|
||||||
ships it.
|
|
||||||
- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a
|
|
||||||
registered font, and the bracket in your frame. The payment buys every *next* piece of
|
|
||||||
interface — the second panel is also one line, the score label is one line, the menu
|
|
||||||
is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem
|
|
||||||
from then on.
|
|
||||||
|
|
||||||
Neither is deprecated. The library's own position: build one panel by hand; build an
|
|
||||||
interface on the subsystem.
|
|
||||||
|
|
||||||
## Menus: one selection, three devices
|
|
||||||
|
|
||||||
A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics.
|
|
||||||
There is no heap pool behind it because it owns no texture, no font, and no registry
|
|
||||||
entry:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
static akgl_UiMenu title_menu = {
|
|
||||||
.id = "title",
|
|
||||||
.items = { "Start", "Options", "Quit" },
|
|
||||||
.count = 3,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and
|
|
||||||
they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the
|
|
||||||
arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad
|
|
||||||
South — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`,
|
|
||||||
and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against
|
|
||||||
`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you
|
|
||||||
*synthesize* events, as the demo's `--demo` script does: a hand-built key event must
|
|
||||||
fill in `key.key`, or it matches nothing and the menu silently ignores it. The mouse
|
|
||||||
selects by *moving onto* a row and activates by clicking one, during the declaration
|
|
||||||
itself. A stationary pointer claims nothing — parking the mouse over the
|
|
||||||
menu must not pin the selection against the keyboard, which would otherwise fight it
|
|
||||||
sixty times a second and lose.
|
|
||||||
|
|
||||||
`activated` latches until you clear it, so the check lives wherever reading it is
|
|
||||||
convenient — the demo reads it after the frame closes, which catches both input routes:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
if ( title_menu.activated ) {
|
|
||||||
title_menu.activated = false;
|
|
||||||
switch ( title_menu.selected ) {
|
|
||||||
case 0:
|
|
||||||
state = UIDEMO_STATE_PLAY;
|
|
||||||
score = 0;
|
|
||||||
dialog_open = false;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
state = UIDEMO_STATE_OPTIONS;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
default:
|
|
||||||
running = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing layout directly with CLAY()
|
|
||||||
|
|
||||||
The options screen uses no widgets, and exists to show that the widgets are a
|
|
||||||
convenience rather than a boundary. One row of it carries every idea — hover styling
|
|
||||||
computed *inside* the declaration, and a click paired with a press edge the application
|
|
||||||
tracks itself:
|
|
||||||
|
|
||||||
```c excerpt=examples/uidemo/uidemo.c
|
|
||||||
CLAY({
|
|
||||||
.id = CLAY_ID("options-music"),
|
|
||||||
.layout = { .padding = { 8, 8, 4, 4 } },
|
|
||||||
.backgroundColor = Clay_Hovered()
|
|
||||||
? (Clay_Color){ 64, 58, 88, 255 }
|
|
||||||
: (Clay_Color){ 0, 0, 0, 0 }
|
|
||||||
}) {
|
|
||||||
if ( Clay_Hovered() && clicked ) {
|
|
||||||
opt_music = !opt_music;
|
|
||||||
}
|
|
||||||
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }),
|
|
||||||
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`clicked` is one application-owned `bool`: set when a left press arrives in the event
|
|
||||||
loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is
|
|
||||||
always consumed — and cleared at the end of the frame. The widgets keep an equivalent
|
|
||||||
edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll
|
|
||||||
containers, floating attach points, aspect ratios — is clay's DSL, and clay's README
|
|
||||||
documents it far better than a restatement here would.
|
|
||||||
|
|
||||||
## Images and styles
|
|
||||||
|
|
||||||
An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration
|
|
||||||
to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame
|
|
||||||
is drawn stretched to the element's box. Two current limits, both deliberate scope
|
|
||||||
rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped
|
|
||||||
by the executor, and per-corner radii collapse to the top-left value — the widgets only
|
|
||||||
produce uniform corners. The mouse cursor stays the operating system's; a game that
|
|
||||||
wants a themed cursor draws a floating image element at the pointer, which makes a good
|
|
||||||
exercise.
|
|
||||||
|
|
||||||
A style is one struct shared by all three widgets — colours, padding, corner radius,
|
|
||||||
fontId — and `NULL` means the textbox palette described above. There is no per-field
|
|
||||||
defaulting: a transparent fill and a zero radius are things a style legitimately says,
|
|
||||||
so copy the default and change what you mean to change:
|
|
||||||
|
|
||||||
```c wrap=akglbody
|
|
||||||
akgl_UiStyle alert = {
|
|
||||||
.fill = { 120, 24, 24, 235 },
|
|
||||||
.edge = { 240, 236, 214, 255 },
|
|
||||||
.ink = { 240, 236, 214, 255 },
|
|
||||||
.padding = 8.0f,
|
|
||||||
.corner_radius = 6.0f,
|
|
||||||
.fontid = 0
|
|
||||||
};
|
|
||||||
|
|
||||||
PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert));
|
|
||||||
```
|
|
||||||
|
|
||||||
## When things go wrong
|
|
||||||
|
|
||||||
Everything here reports through the ordinary error protocol
|
|
||||||
([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which
|
|
||||||
refusal it was. Lifecycle misuse fails at the call that misused it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
|
|
||||||
/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866
|
|
||||||
/home/andrew/source/libakgl/main.c:main:13
|
|
||||||
/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors *inside the layout* cannot fail at the call that caused them, because clay
|
|
||||||
reports through a void callback with libakgl nowhere on the stack. So they are logged as
|
|
||||||
they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the
|
|
||||||
protocol can carry them — with the first message and a count of the rest. A frame that
|
|
||||||
fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears
|
|
||||||
any clip rectangle on its way out so a failed UI cannot leave the next frame's *world*
|
|
||||||
clipped.
|
|
||||||
|
|
||||||
The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu
|
|
||||||
entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable,
|
|
||||||
and every refusal names the number to raise.
|
|
||||||
|
|
||||||
## Build it and run it
|
|
||||||
|
|
||||||
The demo builds with the library:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
cmake -S . -B build
|
|
||||||
cmake --build build -j$(nproc)
|
|
||||||
./build/examples/uidemo/uidemo
|
|
||||||
```
|
|
||||||
|
|
||||||
Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the
|
|
||||||
dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same
|
|
||||||
program headless through a scripted tour of every screen.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing,
|
|
||||||
floating elements, scroll containers, and the debug tools.
|
|
||||||
- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once
|
|
||||||
the UI has declined an event.
|
|
||||||
- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per
|
|
||||||
frame; a UI panel of text pays the same immediate-mode price.
|
|
||||||
- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps
|
|
||||||
comparing against, in the game that earns it.
|
|
||||||
- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status
|
|
||||||
cross-reference.
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
# 23. Appendix: limits, statuses and properties
|
|
||||||
|
|
||||||
Three reference tables that no single chapter owns: which function raises which status,
|
|
||||||
every compile-time bound, and every configuration property the library reads.
|
|
||||||
|
|
||||||
The constant tables come in as `excerpt=` blocks against their headers, so they cannot
|
|
||||||
drift from the values the library was built with — and so the hand-aligned columns those
|
|
||||||
headers maintain survive.
|
|
||||||
|
|
||||||
## A. Status cross-reference
|
|
||||||
|
|
||||||
**What this table is.** The statuses a caller can expect to see out of each function. It is
|
|
||||||
built from the `FAIL_*` and `HANDLE*` sites in `src/` — the function's own body and the
|
|
||||||
`static` helpers only it calls — plus, where a nested libakgl call is part of the documented
|
|
||||||
contract, that status too.
|
|
||||||
|
|
||||||
**It is not exhaustive, and cannot be.** Every libakgl function a given one calls can
|
|
||||||
propagate anything from its own row, and so can libakstdlib: nearly everything that touches
|
|
||||||
a file also carries whatever `aksl_fopen`, `aksl_fread`, `aksl_fwrite` and `aksl_fclose`
|
|
||||||
report, including bare `errno` values such as `ENOENT`, `EACCES` and `ENOSPC`. Use the table
|
|
||||||
to write the `HANDLE` arms you care about, not to prove a status cannot arrive.
|
|
||||||
|
|
||||||
`(H)` marks a status the function **handles** rather than raises.
|
|
||||||
|
|
||||||
What the statuses mean is [Chapter 4](04-errors.md).
|
|
||||||
|
|
||||||
### `error.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_error_init` | `AKERR_STATUS_RANGE_OVERLAP`, `AKERR_STATUS_RANGE_FULL`, `AKERR_STATUS_NAME_FULL` (all libakerror's) |
|
|
||||||
|
|
||||||
### `heap.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_heap_init`, `akgl_heap_init_actor` | *nothing — no failure path today* |
|
|
||||||
| `akgl_heap_next_actor`, `_sprite`, `_spritesheet`, `_character`, `_string`, `_collision_proxy`, `_collision_cell`, `_bspnode` | `AKGL_ERR_HEAP` |
|
|
||||||
| `akgl_heap_release_actor`, `_sprite`, `_spritesheet`, `_character`, `_string`, `_collision_proxy`, `_collision_cell`, `_bspnode` | `AKERR_NULLPOINTER` |
|
|
||||||
|
|
||||||
### `registry.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_registry_init` and the eight `akgl_registry_init_*` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_registry_load_properties` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE`, `AKGL_ERR_HEAP` |
|
|
||||||
| `akgl_set_property` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_get_property` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE`, `AKGL_ERR_HEAP` |
|
|
||||||
|
|
||||||
### `game.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_game_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_game_update_fps`, `akgl_game_lowfps` | *`void` — cannot report* |
|
|
||||||
| `akgl_game_state_lock` | `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_game_state_unlock` | *nothing* |
|
|
||||||
| `akgl_game_update` | `AKGL_ERR_SDL`, plus whatever `updatefunc`, `akgl_tilemap_scale_actor`, the physics backend or the renderer raises |
|
|
||||||
| `akgl_game_save` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_game_save_actors` | `AKERR_NULLPOINTER`, `AKERR_IO` |
|
|
||||||
| `akgl_game_load` | `AKERR_NULLPOINTER`, `AKERR_API`, `AKERR_EOF`, `AKERR_IO`, `AKERR_VALUE` |
|
|
||||||
| `akgl_game_load_versioncmp` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_API` |
|
|
||||||
|
|
||||||
### `actor.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_actor_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_actor_set_character` | `AKERR_NULLPOINTER` — **including "no such character"** |
|
|
||||||
| `akgl_actor_add_child` | `AKERR_NULLPOINTER`, `AKERR_RELATIONSHIP`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_actor_update` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H) |
|
|
||||||
| `akgl_actor_render` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H) |
|
|
||||||
| `akgl_actor_automatic_face`, `_logic_movement`, `_logic_changeframe` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_actor_cmhf_*_on`, `akgl_actor_cmhf_*_off` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_registry_iterate_actor` | `AKERR_NULLPOINTER`, `AKERR_KEY` — **`void`; unhandled means the process exits** |
|
|
||||||
|
|
||||||
### `character.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_character_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_character_sprite_add` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_character_sprite_get` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_character_load_json` | `AKERR_NULLPOINTER`, `AKERR_KEY` for a state name that is not in `AKGL_REGISTRY_ACTOR_STATE_STRINGS`, and `AKERR_TYPE` from the JSON accessors |
|
|
||||||
| `akgl_character_state_sprites_iterate` | `AKERR_NULLPOINTER` — **`void`; unhandled means the process exits** |
|
|
||||||
|
|
||||||
### `sprite.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_sprite_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_sprite_load_json` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_spritesheet_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_spritesheet_coords_for_frame` | `AKERR_NULLPOINTER` |
|
|
||||||
|
|
||||||
### `json_helpers.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_get_json_object_value`, `_boolean_`, `_integer_`, `_number_`, `_double_`, `_string_`, `_array_value` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for absent**, **`AKERR_TYPE` for wrong type** |
|
|
||||||
| `akgl_get_json_array_index_object`, `_integer`, `_string` | `AKERR_NULLPOINTER`, **`AKERR_OUTOFBOUNDS` for a short array**, `AKERR_TYPE` |
|
|
||||||
| `akgl_get_json_with_default` | `AKERR_NULLPOINTER`; handles `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H), `AKERR_INDEX` (H) |
|
|
||||||
|
|
||||||
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` also raise
|
|
||||||
`AKGL_ERR_HEAP` when they have to claim a pooled string and the pool is empty.
|
|
||||||
|
|
||||||
### `physics.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_physics_init_null`, `akgl_physics_init_arcade` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_physics_factory` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for an unknown backend name** |
|
|
||||||
| `akgl_physics_simulate` | `AKERR_NULLPOINTER`; handles `AKGL_ERR_LOGICINTERRUPT` (H) |
|
|
||||||
| `akgl_physics_null_gravity`, `_collide`, `_move` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_physics_arcade_gravity`, `_move` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_physics_arcade_collide` | `AKERR_NULLPOINTER`, plus `akgl_collision_resolve`'s. A no-op when `self->collision` is `NULL` |
|
|
||||||
|
|
||||||
### `collision.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_collision_shape_box`, `_shape_circle`, `_shape_capsule`, `_shape_bounds`, `_shape_interacts` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_collision_world_init` | `AKERR_NULLPOINTER`, **`AKERR_VALUE` for a non-positive cell size**, plus `akgl_partitioner_factory`'s |
|
|
||||||
| `akgl_collision_bind_tilemap` | `AKERR_NULLPOINTER`, plus `akgl_get_json_properties_boolean`'s; handles `AKERR_KEY` (H) for a layer with no `collidable` property |
|
|
||||||
| `akgl_collision_test` | `AKERR_NULLPOINTER`, **`AKGL_ERR_COLLISION` when the ccd arena is exhausted** — the message carries the high-water mark |
|
|
||||||
| `akgl_collision_resolve` | `AKERR_NULLPOINTER`, plus `akgl_collision_test`'s and the actor's own `collidefunc`'s |
|
|
||||||
| `akgl_collision_settle` | `AKERR_NULLPOINTER`, **`AKERR_VALUE` when the shape is not clear within `maxsteps`** |
|
|
||||||
| `akgl_collision_solid_at`, `_box_blocked`, `_query_box`, `_substeps`, `_sync_actors` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_collision_proxy_initialize`, `_proxy_sync`, `_cell_initialize` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_partitioner_factory` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for an unknown partitioner name** |
|
|
||||||
| `akgl_partitioner_init_grid`, `_init_bsp` | `AKERR_NULLPOINTER`. The BSP's `insert` raises `AKGL_ERR_HEAP` past `AKGL_MAX_HEAP_COLLISION_PROXY` proxies |
|
|
||||||
| `akgl_actor_collide_block` | `AKERR_NULLPOINTER` |
|
|
||||||
|
|
||||||
### `renderer.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_render_2d_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, plus `akgl_get_property`'s |
|
|
||||||
| `akgl_render_2d_bind`, `_shutdown`, `_frame_start`, `_frame_end`, `_draw_texture`, `_draw_world` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_render_2d_draw_mesh` | **`AKERR_API` — not implemented** |
|
|
||||||
|
|
||||||
### `draw.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_draw_point`, `_line`, `_rect`, `_filled_rect` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_draw_background` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` |
|
|
||||||
| `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_draw_filled_rounded_rect` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_draw_arc` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_draw_set_clip` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` — a `NULL` rectangle is legal here and clears the clip |
|
|
||||||
|
|
||||||
### `tilemap.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_tilemap_load` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_tilemap_load_layers` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_tilemap_load_layer_tile` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_tilemap_load_layer_image` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_tilemap_load_layer_objects` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_tilemap_load_layer_object_actor` | `AKERR_KEY` |
|
|
||||||
| `akgl_tilemap_load_tilesets` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` past `AKGL_TILEMAP_MAX_TILESETS` |
|
|
||||||
| `akgl_tilemap_load_tilesets_each` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_tilemap_load_physics` | handles `AKERR_KEY` (H) — every physics property is optional |
|
|
||||||
| `akgl_get_json_tilemap_property` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE` |
|
|
||||||
| `akgl_tilemap_draw`, `_draw_tileset` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_tilemap_scale_actor`, `akgl_tilemap_release` | `AKERR_NULLPOINTER` |
|
|
||||||
|
|
||||||
### `controller.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_controller_open_gamepads`, `_list_keyboards` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_controller_handle_event`, `_button_down`, `_button_up`, `_added`, `_removed` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_controller_pushmap` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site |
|
|
||||||
| `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` |
|
|
||||||
|
|
||||||
### `ui.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_ui_init` | `AKERR_OUTOFBOUNDS`, `AKGL_ERR_UI` — already initialized, or an arena clay's structures do not fit; the message carries both byte counts |
|
|
||||||
| `akgl_ui_shutdown` | *nothing — idempotent by design* |
|
|
||||||
| `akgl_ui_resize` | `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_ui_font_register` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` past `AKGL_UI_FONT_NAME_LENGTH` |
|
|
||||||
| `akgl_ui_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` from a resize event |
|
|
||||||
| `akgl_ui_frame_begin` | `AKGL_ERR_UI` — uninitialized, or a frame already open |
|
|
||||||
| `akgl_ui_frame_end` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` — no frame open, clay layout errors, an over-long text run, an unresolvable fontId — plus whatever the draw primitives raise |
|
|
||||||
| `akgl_ui_dialog`, `_label`, `_menu` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` outside the frame bracket; `_label` and `_menu` add `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_ui_menu_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_ui_execute_commands` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, plus whatever the draw primitives raise |
|
|
||||||
| `akgl_ui_measure_text` | *cannot report — clay's callback signature; failures stash and surface from `akgl_ui_frame_end`* |
|
|
||||||
|
|
||||||
### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h`
|
|
||||||
|
|
||||||
| Function | Raises |
|
|
||||||
|---|---|
|
|
||||||
| `akgl_text_loadfont` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_text_unloadfont` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
|
||||||
| `akgl_text_rendertextat` | `AKERR_NULLPOINTER` — including a `NULL` `akgl_renderer`, a `NULL` `sdl_renderer`, and a backend never run through `akgl_render_2d_bind` and so missing `draw_texture`. An **empty** string is not an error: it returns success having drawn nothing |
|
|
||||||
| `akgl_text_measure`, `_measure_wrapped` | `AKERR_NULLPOINTER`. An empty string is legal and measures 0 wide by one line high |
|
|
||||||
| `akgl_audio_tone`, `akgl_audio_sweep` | `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_audio_voice_active`, `akgl_audio_mix` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_load_start_bgm` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_path_relative` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
|
||||||
| `akgl_collide_rectangles` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_compare_sdl_surfaces` | `AKERR_NULLPOINTER`, `AKERR_VALUE` |
|
|
||||||
| `akgl_render_and_compare` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKGL_ERR_SDL` |
|
|
||||||
| `akgl_string_initialize` | `AKERR_NULLPOINTER` |
|
|
||||||
| `akgl_string_copy` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE` |
|
|
||||||
|
|
||||||
## B. Compile-time limits
|
|
||||||
|
|
||||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight,
|
|
||||||
`AKGL_CCD_ARENA_BYTES` and the `AKGL_UI_*` family are overridable**, and even those have to
|
|
||||||
be overridden for the whole build — see
|
|
||||||
[Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are
|
|
||||||
plain `#define`s with no `#ifndef` guard: changing one means editing the header and
|
|
||||||
rebuilding libakgl and everything linking it.
|
|
||||||
|
|
||||||
### The object pools
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/heap.h
|
|
||||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
|
||||||
#define AKGL_MAX_HEAP_ACTOR 64
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITE
|
|
||||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_SPRITESHEET
|
|
||||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_CHARACTER
|
|
||||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_STRING
|
|
||||||
#define AKGL_MAX_HEAP_STRING 256
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/heap.h
|
|
||||||
#ifndef AKGL_MAX_HEAP_COLLISION_PROXY
|
|
||||||
#define AKGL_MAX_HEAP_COLLISION_PROXY (AKGL_MAX_HEAP_ACTOR * 2)
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/heap.h
|
|
||||||
#ifndef AKGL_MAX_HEAP_BSPNODE
|
|
||||||
#define AKGL_MAX_HEAP_BSPNODE (AKGL_MAX_HEAP_COLLISION_PROXY)
|
|
||||||
#endif
|
|
||||||
#ifndef AKGL_MAX_HEAP_COLLISION_CELL
|
|
||||||
#define AKGL_MAX_HEAP_COLLISION_CELL (AKGL_MAX_HEAP_COLLISION_PROXY * 16)
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
Exceeding any of them is `AKGL_ERR_HEAP`.
|
|
||||||
|
|
||||||
### Collision
|
|
||||||
|
|
||||||
Three of these are not in a public header, because nothing outside the broad phase may size
|
|
||||||
anything against them:
|
|
||||||
|
|
||||||
| Constant | Value | Where | Bounds |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| `AKGL_CCD_ARENA_BYTES` | 65536 | `collision_arena.h` | The narrowphase's whole allocation budget. Overridable. One GJK/EPA box pair measures 7,264 bytes |
|
|
||||||
| `AKGL_COLLISION_GRID_COLS` / `_ROWS` | 128 | `src/collision_grid.c` | The dense cell-head array, 32 KB. A map wider than 128 cells wraps rather than growing |
|
|
||||||
| `AKGL_COLLISION_GRID_MAX_SPAN` | 16 | `src/collision_grid.c` | Cells one proxy may occupy. Anything larger spills to one oversized chain, which is what makes `AKGL_MAX_HEAP_COLLISION_CELL` provable rather than hopeful |
|
|
||||||
| `AKGL_COLLISION_MAX_SUBSTEPS` | 8 | `src/collision.c` | Sub-steps per actor per step, and therefore the speed above which tunnelling returns |
|
|
||||||
| `AKGL_COLLISION_MAX_ITERATIONS` | 100 | `src/collision.c` | `ccd_t.max_iterations`. libccd's own default is `(unsigned long)-1` — an unbounded loop inside a frame |
|
|
||||||
| `AKGL_COLLISION_DEPTH_RATIO` | 2.0 | `collision.h` | The z extrusion, as a multiple of the largest planar half-extent. See [Chapter 15](15-collision.md) before changing it |
|
|
||||||
|
|
||||||
### Strings
|
|
||||||
|
|
||||||
`AKGL_MAX_STRING_LENGTH` is `PATH_MAX`, which is 4096 on Linux. That is the capacity of
|
|
||||||
every `akgl_String`, and it is why the string pool is over a megabyte on its own.
|
|
||||||
|
|
||||||
### Actors
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/actor.h
|
|
||||||
/** @brief Longest actor name, including the terminator. Names are truncated, not rejected. */
|
|
||||||
#define AKGL_ACTOR_MAX_NAME_LENGTH 128
|
|
||||||
/** @brief Children one actor can carry. A child moves with its parent rather than simulating. */
|
|
||||||
#define AKGL_ACTOR_MAX_CHILDREN 8
|
|
||||||
```
|
|
||||||
|
|
||||||
`AKGL_ACTOR_MAX_STATES` is 32, fixed by the width of `int32_t state` rather than chosen.
|
|
||||||
A ninth child is `AKERR_OUTOFBOUNDS`; an over-long name is silently truncated.
|
|
||||||
|
|
||||||
### Sprites and spritesheets
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/sprite.h
|
|
||||||
#define AKGL_SPRITE_MAX_FRAMES 16
|
|
||||||
#define AKGL_SPRITE_MAX_NAME_LENGTH 128
|
|
||||||
#define AKGL_SPRITE_MAX_REGISTRY_SIZE 1024
|
|
||||||
#define AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH 512
|
|
||||||
```
|
|
||||||
|
|
||||||
`akgl_sprite_load_json` bounds the `frames` array against `AKGL_SPRITE_MAX_FRAMES` before
|
|
||||||
writing anything, and refuses a frame number that will not fit a `uint8_t` rather than
|
|
||||||
truncating it into an index naming a different tile (`TODO.md` item 16).
|
|
||||||
|
|
||||||
**`AKGL_SPRITE_MAX_REGISTRY_SIZE` is dead.** It is defined in `sprite.h` and referenced
|
|
||||||
nowhere in `src/`, `include/`, `tests/` or `util/`. It bounds nothing. Do not size anything
|
|
||||||
against it.
|
|
||||||
|
|
||||||
### Tilemaps
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/tilemap.h
|
|
||||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
|
||||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
|
||||||
/** @brief Tallest map, in tiles. */
|
|
||||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
|
||||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
|
||||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
|
||||||
/** @brief Tilesets per map. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
|
||||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
|
||||||
/** @brief Longest tileset name. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
|
||||||
/** @brief Longest resolved tileset image path. */
|
|
||||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
|
||||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
|
||||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
|
||||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
|
||||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
|
||||||
```
|
|
||||||
|
|
||||||
Three corrections to what those comments say, all verified against `src/tilemap.c`:
|
|
||||||
|
|
||||||
- **`AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` *is* enforced.** The comment above says it is not.
|
|
||||||
`akgl_tilemap_load_layer_objects` bounds `j` at the top of the loop body and raises
|
|
||||||
`AKERR_OUTOFBOUNDS`; `akgl_tilemap_load_tilesets` does the same for
|
|
||||||
`AKGL_TILEMAP_MAX_TILESETS`. Both landed in 0.5.0 (`TODO.md`, "Known and still open"
|
|
||||||
item 17), and the header comment was not updated with them.
|
|
||||||
- **Width and height are not bounded individually.** `akgl_tilemap_load` checks
|
|
||||||
`width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, and the comparison
|
|
||||||
is `>=`, so the true ceiling is **262143 tiles** in any shape. A 1024×256 map loads; a
|
|
||||||
512×512 one does not.
|
|
||||||
- **`AKGL_TILEMAP_MAX_TILES_PER_IMAGE` is not checked anywhere.** It sizes
|
|
||||||
`tile_offsets[65536][2]` in the struct and nothing validates a tileset against it. It is
|
|
||||||
also why `akgl_Tileset` is large: 512 KiB of offset table per tileset, whatever the image
|
|
||||||
actually holds.
|
|
||||||
|
|
||||||
### Controllers
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/controller.h
|
|
||||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
|
||||||
#define AKGL_MAX_CONTROL_MAPS 8
|
|
||||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
|
||||||
#define AKGL_MAX_CONTROLS 32
|
|
||||||
```
|
|
||||||
|
|
||||||
`AKGL_CONTROLLER_KEY_BUFFER` is 32 — the keystroke ring, sized so a program polling once a
|
|
||||||
frame never loses a key to a fast typist. `AKGL_CONTROLLER_KEYSTROKE_TEXT` is 8, enough for
|
|
||||||
one UTF-8 code point and its terminator; a longer commit is truncated **on a code point
|
|
||||||
boundary** rather than through the middle of one.
|
|
||||||
|
|
||||||
### Drawing
|
|
||||||
|
|
||||||
`AKGL_DRAW_MAX_FLOOD_SPANS` is 4096 — the fixed stack of horizontal runs `akgl_draw_flood_fill`
|
|
||||||
keeps instead of recursing per pixel. A region needing more pending runs at once reports
|
|
||||||
`AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape
|
|
||||||
needs a few dozen.
|
|
||||||
|
|
||||||
`AKGL_DRAW_ROUNDED_RECT_SEGMENTS` is 16 — triangles per corner of
|
|
||||||
`akgl_draw_filled_rounded_rect`. `AKGL_DRAW_ARC_MAX_POINTS` is 64 — the vertex ceiling one
|
|
||||||
`akgl_draw_arc` spends, two per step along the curve; the step count scales with the swept
|
|
||||||
angle under it. Both size fixed arrays, so neither is a per-call cost and neither is
|
|
||||||
overridable.
|
|
||||||
|
|
||||||
### The UI
|
|
||||||
|
|
||||||
Every `AKGL_UI_*` limit is guarded by `#ifndef`, so a consumer may override any of them the
|
|
||||||
way the pool ceilings are overridden — for the whole build, before `<akgl/ui.h>` is seen.
|
|
||||||
`akgl_ui_init` checks that the arena still fits the element and word ceilings and refuses
|
|
||||||
with both byte counts in the message when it does not, so a raised ceiling without a raised
|
|
||||||
arena is a loud startup failure rather than a corruption.
|
|
||||||
|
|
||||||
| Constant | Value | Bounds |
|
|
||||||
|---|---|---|
|
|
||||||
| `AKGL_UI_MAX_ELEMENTS` | 1024 | Elements one layout may declare |
|
|
||||||
| `AKGL_UI_MAX_MEASURE_WORDS` | 4096 | Words clay's text-measurement cache holds |
|
|
||||||
| `AKGL_UI_ARENA_BYTES` | 1048576 | Static storage clay lays its structures out in (812544 needed at the defaults) |
|
|
||||||
| `AKGL_UI_MAX_FONTS` | 8 | fontId table slots |
|
|
||||||
| `AKGL_UI_FONT_NAME_LENGTH` | 64 | Bytes per fontId slot's registry name, terminator included |
|
|
||||||
| `AKGL_UI_MAX_TEXT_BYTES` | 1024 | Bytes one text render command's line may occupy |
|
|
||||||
| `AKGL_UI_MENU_MAX_ITEMS` | 16 | Entries one `akgl_UiMenu` may carry |
|
|
||||||
| `AKGL_UI_DIALOG_HEIGHT` | 56 | Height of the `akgl_ui_dialog` panel, in pixels |
|
|
||||||
|
|
||||||
### Audio
|
|
||||||
|
|
||||||
```c excerpt=include/akgl/audio.h
|
|
||||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
|
||||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
|
||||||
|
|
||||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
|
||||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
|
||||||
```
|
|
||||||
|
|
||||||
`AKGL_AUDIO_MAX_VOICES` is 3. `AKGL_AUDIO_SWEEP_TICK_HZ` is 60, and it divides the sample
|
|
||||||
rate exactly, so a sweep step boundary always lands on a whole frame —
|
|
||||||
`AKGL_AUDIO_SWEEP_TICK_FRAMES` is 735.
|
|
||||||
|
|
||||||
The SDL_mixer side is separate: `AKGL_GAME_AUDIO_MAX_TRACKS` is 64 playback tracks, and
|
|
||||||
**slot 0 is unused** — `AKGL_GAME_AUDIO_TRACK_BGM` is slot 1, and the rest are yours.
|
|
||||||
|
|
||||||
### Time and the frame
|
|
||||||
|
|
||||||
| Constant | Value | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `AKGL_TIME_ONESEC_NS` | 1000000000 | Nanoseconds in a second — the unit `SDL_GetTicksNS` reports in |
|
|
||||||
| `AKGL_TIME_ONEMS_NS` | 1000000 | Nanoseconds in a millisecond — the scale factor from JSON frame durations to internal ones |
|
|
||||||
| `AKGL_GAME_STATE_LOCK_BUDGET_MS` | 1000 | How long `akgl_game_state_lock` keeps trying before raising |
|
|
||||||
| `AKGL_GAME_STATE_LOCK_RETRY_MS` | 100 | How long it sleeps between attempts |
|
|
||||||
| `AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` | 0.05 | Default `physics.max_timestep`, in seconds — three frames at 60 Hz |
|
|
||||||
|
|
||||||
`AKGL_TIME_ONEMS_NS` was called `AKGL_TIME_ONESEC_MS` until 0.5.0 and held 1000000 — a name
|
|
||||||
and a value describing two different quantities. The state lock read it as a one-second
|
|
||||||
budget and was wrong by a factor of a thousand, blocking for roughly sixteen minutes.
|
|
||||||
|
|
||||||
### The status band
|
|
||||||
|
|
||||||
| Constant | Value |
|
|
||||||
|---|---|
|
|
||||||
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
|
|
||||||
| `AKGL_ERR_COUNT` | 7 |
|
|
||||||
| `AKGL_ERR_LIMIT` | 263 — **the one-past-the-end sentinel, not a status** |
|
|
||||||
|
|
||||||
## C. Configuration properties
|
|
||||||
|
|
||||||
Everything the library reads out of `AKGL_REGISTRY_PROPERTIES`. **Every value is a string**,
|
|
||||||
including the numbers, and each is parsed by whoever reads it.
|
|
||||||
|
|
||||||
| Property | Default | Read by | Read when | Meaning |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera width in pixels. Passed straight to `SDL_CreateWindowAndRenderer` |
|
|
||||||
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera height in pixels |
|
|
||||||
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on x, px/s² |
|
|
||||||
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on y, px/s². Positive is down |
|
|
||||||
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on z, px/s² |
|
|
||||||
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Fraction of environmental velocity shed per second on x |
|
|
||||||
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for y. **The only brake on falling** — there is no terminal velocity, and `ey` approaches `gravity_y / drag_y` |
|
|
||||||
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for z |
|
|
||||||
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | once, at init | Ceiling on a single simulation `dt`, in seconds |
|
|
||||||
|
|
||||||
That is the complete list — nine properties, read by two functions, each exactly once.
|
|
||||||
|
|
||||||
**`physics.engine` is not read by anything.** `physics.h` says in two places that
|
|
||||||
`akgl_game_init` passes it to `akgl_physics_factory`; `akgl_game_init` never calls the
|
|
||||||
factory, and the string does not appear in `src/`. Pass `"null"` or `"arcade"` to
|
|
||||||
`akgl_physics_factory` yourself. Correcting the header is a source change and is deliberately
|
|
||||||
out of scope for this manual.
|
|
||||||
|
|
||||||
**Everything is read at init.** Setting any of these after `akgl_render_2d_init` or
|
|
||||||
`akgl_physics_init_arcade` has no effect at all, and reports success. See
|
|
||||||
[Chapter 6](06-the-registry.md), and the startup order in
|
|
||||||
[Chapter 7](07-the-game-and-the-frame.md).
|
|
||||||
|
|
||||||
**A property set before `AKGL_REGISTRY_PROPERTIES` exists is silently discarded.**
|
|
||||||
`akgl_set_property` does not check SDL's return value. `akgl_game_init` and
|
|
||||||
`akgl_registry_init` both create the registry; a hand-rolled startup that creates neither
|
|
||||||
gets no configuration and no error.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
- [Chapter 4](04-errors.md) — what each status means.
|
|
||||||
- [Chapter 5](05-the-heap.md) — the pools, and the ABI constraint on their ceilings.
|
|
||||||
- [Chapter 6](06-the-registry.md) — the property store these live in.
|
|
||||||
- The generated Doxygen reference — every function's own `@throws` list and full signature.
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
# Maintaining the manual
|
|
||||||
|
|
||||||
This file documents the harness that keeps `docs/` honest. It is not a chapter —
|
|
||||||
a reader learning libakgl never needs it — and it is checked by the same harness
|
|
||||||
it describes, so the file that publishes the convention is held to it.
|
|
||||||
|
|
||||||
**The harness checks that every listing is true. It cannot check that a chapter
|
|
||||||
teaches.** For that, the tutorials get a second test: the chapter is handed to a
|
|
||||||
subagent on a weaker model with the library, the headers and the art but *not*
|
|
||||||
the finished program, and that reader has to build and run the game. See
|
|
||||||
`AGENTS.md`, "Testing a Tutorial". Four read-only reviews of chapters 20 and 21
|
|
||||||
missed three defects the first build-and-run found in minutes.
|
|
||||||
|
|
||||||
## Documentation examples
|
|
||||||
|
|
||||||
**Every fenced block in `docs/*.md` is compiled, linked, run, or matched against
|
|
||||||
a source file by `ctest`.** The test is `docs_examples`; it runs in an ordinary
|
|
||||||
build and it fails the build.
|
|
||||||
|
|
||||||
It exists because the documentation this manual replaced had not been executed
|
|
||||||
since the day it was typed. In four hundred lines of `README.md`: two `PASS()`
|
|
||||||
calls with unbalanced parentheses, a `sprite->frameids = [0, 1, 2, 3];` that is
|
|
||||||
not C in any dialect, a stray `9` inside a bitmask expression, an
|
|
||||||
`int screenwidth = NULL`, and — in the first snippet a reader ever saw — the
|
|
||||||
exact `strncpy` call `AGENTS.md` forbids under **Copying Into Fixed-Width
|
|
||||||
Fields**. Two prose claims in the headers were false against `src/` as well.
|
|
||||||
That is not a reader routing around typos; it is what happens to samples nothing
|
|
||||||
runs.
|
|
||||||
|
|
||||||
Documentation goes stale because the **code** moved, not because somebody edited
|
|
||||||
a chapter. So this is an ordinary test case rather than something a docs-only CI
|
|
||||||
job runs behind a path filter — it fails when the library changes under the
|
|
||||||
chapter, which is precisely the case a path filter would miss.
|
|
||||||
|
|
||||||
**An untagged block is a failure**, not a default, and so is a tag this harness
|
|
||||||
does not know. That is deliberate: the way a harness like this dies is by
|
|
||||||
quietly matching nothing and passing, so an untagged block is a missing decision
|
|
||||||
rather than a free pass.
|
|
||||||
|
|
||||||
### The tags
|
|
||||||
|
|
||||||
| Info string | What happens |
|
|
||||||
|---|---|
|
|
||||||
| `c` | A whole translation unit. Compiled with `-fsyntax-only -std=gnu99 -Wall -Werror` against the real include path. It shows its own `#include` lines |
|
|
||||||
| `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after |
|
|
||||||
| `c run=NAME` | Wrapped in `NAME`, **linked against `akgl` and run headless**. Must exit 0 and must not report an unhandled or ignored error |
|
|
||||||
| `c excerpt=PATH` | Must still appear in `PATH`, ignoring comments and whitespace. **Not compiled** |
|
|
||||||
| `json excerpt=PATH` | The same check against an asset file, for a fragment too large to show whole |
|
|
||||||
| `c screenshot=NAME` | Compiled as an `akglframe` body, and the source of `docs/images/NAME.png`. Optionally `size=WxH` |
|
|
||||||
| `json kind=KIND` | Written to a file and loaded through libakgl's own loader. `KIND` is `sprite`, `character`, `tilemap` or `properties` |
|
|
||||||
| `output` | The **exact stdout** of the runnable block above it, compared byte for byte |
|
|
||||||
| `sh` | Run in a sandbox. Must exit 0. A leading `$ ` is stripped |
|
|
||||||
| `sh norun` | Shown, not run. Destructive, networked, or it re-enters this suite |
|
|
||||||
| `cmake` | **Never executed.** Hand-maintained, by decision |
|
|
||||||
| `text` | **Never executed.** A block diagram, or a stack trace captured from a real run |
|
|
||||||
| `norun` | Shown, never executed, whatever it is |
|
|
||||||
|
|
||||||
These attributes combine with several of the above:
|
|
||||||
|
|
||||||
| Attribute | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `setup=NAME` | Runs `tests/docs_setups/NAME.sh` in the sandbox first, with the repository root as its only argument. Valid on `c run=`, `json`, `sh` and `c screenshot=` |
|
|
||||||
| `size=WxH` | A figure's surface size. Only on `c screenshot=`; the default is 320x240 |
|
|
||||||
| `name=NAME` | Gives a `json` block a name a later block in the same chapter can preload |
|
|
||||||
| `preload=A,B` | Loads those named `json` blocks, in that order, before this one |
|
|
||||||
|
|
||||||
### Which one to reach for
|
|
||||||
|
|
||||||
In this order, and a step down the list is a decision that belongs in the prose:
|
|
||||||
|
|
||||||
- **`c run=akglapp` with an `output` block below it.** The strongest shape there
|
|
||||||
is. `-fsyntax-only` proves a call typechecks; it does not prove the startup
|
|
||||||
order works, that the sprite loaded, or that the `ATTEMPT` block gave back
|
|
||||||
what it took. A `run` block proves all three, and the `output` block pins what
|
|
||||||
the reader will actually see.
|
|
||||||
- **`c run=akglapp` alone**, where the example has no output. The harness still
|
|
||||||
asserts a clean exit and that nothing raised.
|
|
||||||
- **`c wrap=akglbody`**, for a call sequence whose effect is not visible on
|
|
||||||
stdout.
|
|
||||||
- **`c`**, for a translation unit the chapter wants shown whole — a header, a
|
|
||||||
backend definition, a `main`.
|
|
||||||
- **`c excerpt=PATH`**, for a constant table, a `typedef` or a declaration.
|
|
||||||
Compiling one of those would only redefine the type; the question worth asking
|
|
||||||
is whether it still says what the header says.
|
|
||||||
- **`json kind=`**, for any asset file.
|
|
||||||
- **`norun`**, for a fragment or something that cannot be run here. Say why in
|
|
||||||
the prose. **A rising `norun` count is a review finding** — it is the harness
|
|
||||||
being talked out of its job.
|
|
||||||
|
|
||||||
### Preludes
|
|
||||||
|
|
||||||
`tests/docs_preludes/NAME.pre` and `NAME.post` bracket the block. They exist so
|
|
||||||
an example can be written the way a reader wants to read it while still
|
|
||||||
compiling — four lines of `PREPARE_ERROR` scaffolding repeated in twenty
|
|
||||||
chapters teaches the protocol twenty times and the call once.
|
|
||||||
|
|
||||||
| Prelude | What the block supplies |
|
|
||||||
|---|---|
|
|
||||||
| `akglfile` | File scope: declarations, a `movementlogicfunc`, a backend vtable. Includes only; there is no `.post` |
|
|
||||||
| `akglbody` | The inside of a function returning an error context, with `errctx` already prepared |
|
|
||||||
| `akglapp` | The same body, plus a `main` that brings libakgl up headless and runs it. This is what `run=` uses |
|
|
||||||
| `akglframe` | One frame of drawing, for a figure. Linked against `tools/docs_screenshot.c` |
|
|
||||||
|
|
||||||
All four begin with `tests/docs_preludes/docs_prelude.h`, which is the include
|
|
||||||
set — every public header, since the `headers` suite already proves each one is
|
|
||||||
self-contained. A block with no `wrap=` gets none of it and shows its own
|
|
||||||
includes, which is usually what a chapter wants a reader to see.
|
|
||||||
|
|
||||||
What does **not** belong in a prelude is anything that would let a wrong example
|
|
||||||
compile. A prelude declaring `akgl_game_init` itself would defeat the entire
|
|
||||||
check.
|
|
||||||
|
|
||||||
Compiler diagnostics point at the markdown: the harness emits a `#line`
|
|
||||||
directive before each body, so a broken example reports as
|
|
||||||
`docs/14-physics.md:112: error: too few arguments`, not as a line in a scratch
|
|
||||||
file nobody can open.
|
|
||||||
|
|
||||||
### Why `-Werror` here and not on the library
|
|
||||||
|
|
||||||
`AGENTS.md` keeps `AKGL_WERROR` **off** by default, and for a good reason:
|
|
||||||
libakgl is consumed with `add_subdirectory()` — `akbasic` does exactly that — so
|
|
||||||
a target-level `-Werror` turns every diagnostic a newer compiler invents into a
|
|
||||||
broken build for somebody else's project.
|
|
||||||
|
|
||||||
A documentation snippet is not a consumer. A sample that warns is a sample that
|
|
||||||
teaches the warning, and it is the maintainer of this repository who sees the
|
|
||||||
failure. So the snippets get `-Werror` and the library does not.
|
|
||||||
|
|
||||||
`gnu99` rather than `c99`, because `akerror.h` uses `PATH_MAX`, which
|
|
||||||
`<limits.h>` hides under `__STRICT_ANSI__`. A stricter dialect would fail on the
|
|
||||||
dependency rather than on the example.
|
|
||||||
|
|
||||||
### Asset formats
|
|
||||||
|
|
||||||
A `json kind=` block is written to a file and handed to `akgl_docs_checkjson`,
|
|
||||||
which brings libakgl up headless and calls the loader that really reads that
|
|
||||||
format — `akgl_sprite_load_json`, `akgl_character_load_json`,
|
|
||||||
`akgl_tilemap_load` or `akgl_registry_load_properties`.
|
|
||||||
|
|
||||||
This exists because the formats are described in prose here and parsed in
|
|
||||||
`src/`, with nothing tying the two together. That gap is not hypothetical:
|
|
||||||
`util/assets/littleguy.json` ships beside the loader it is invalid against.
|
|
||||||
|
|
||||||
The loaders really open the images and tilesets a block names, so a block that
|
|
||||||
references one needs a `setup=`. Adding a format is a row in the `KINDS` table
|
|
||||||
in `tools/docs_checkjson.c`, not a branch.
|
|
||||||
|
|
||||||
**Three of the four formats are not self-contained.** A character names sprites
|
|
||||||
that have to be in `AKGL_REGISTRY_SPRITE` already; a tilemap names characters,
|
|
||||||
and really opens its tileset image. There are two ways to satisfy that, and
|
|
||||||
which one to use depends on whether the dependency is something the reader
|
|
||||||
should see.
|
|
||||||
|
|
||||||
**Show it, with `name=` and `preload=`.** A `json kind=sprite name=herostand`
|
|
||||||
block earlier in a chapter and a `json kind=character preload=herostand` block
|
|
||||||
later in the same one are loaded into a single process, in that order. The
|
|
||||||
dependency ends up in front of the reader — which is right when the chapter is
|
|
||||||
teaching how the two files relate. Names are per-document, so one chapter cannot
|
|
||||||
reach into another and make the order documents are checked in load-bearing.
|
|
||||||
|
|
||||||
**Hide it, with `setup=`.** When the chapter is about one file and the others
|
|
||||||
would be noise, the fixtures go in a setup script and the `prepare` column of
|
|
||||||
the `KINDS` table in `tools/docs_checkjson.c` loads them:
|
|
||||||
|
|
||||||
| Setup | What it stages | What loads it |
|
|
||||||
|---|---|---|
|
|
||||||
| `setup=spritesheet` | `./spritesheet.png`, a 12x8 grid of 48x48 frames | the `sprite` row's `prepare` |
|
|
||||||
| `setup=character` | `./sprites/*.json` plus a sheet, named `hero standing left` and `hero walking left` | the `character` row's `prepare` |
|
|
||||||
| `setup=tilemap` | `./sprites/*.json`, `./characters/testcharacter.json`, and `assets/tileset.png` | the `tilemap` row's `prepare` |
|
|
||||||
| `setup=sidescroller` | every PNG and sprite file from `docs/tutorials/assets/sidescroller`, in `./` and `./sprites` | both rows' `prepare` |
|
|
||||||
| `setup=jrpg` | the same, from `docs/tutorials/assets/jrpg` | both rows' `prepare` |
|
|
||||||
|
|
||||||
The last two are how the tutorials show a reader a *real* asset file rather than a
|
|
||||||
generic one. A beginner following chapter 20 or 21 types what is on the page, so
|
|
||||||
what is on the page has to be a file that loads — the setup stages the tutorial's
|
|
||||||
own art so the block on the page is the block in the repository.
|
|
||||||
|
|
||||||
Those names are a contract between the setup script and the chapter using it.
|
|
||||||
Change one and change the other, in the same commit. `assets/tileset.png` is
|
|
||||||
deliberately not the RPG-Maker-named image `tests/assets/testmap.tmj` uses — see
|
|
||||||
the comment in `tests/docs_setups/tilemap.sh`.
|
|
||||||
|
|
||||||
Two things about map fixtures, both confirmed against `src/tilemap.c` rather
|
|
||||||
than against the prose:
|
|
||||||
|
|
||||||
- **Tilesets must be embedded.** `"source"` appears nowhere in the loader;
|
|
||||||
`akgl_tilemap_load_tilesets_each` reads `columns`, `firstgid`, `tilecount` and
|
|
||||||
`image` straight off each element of the map's own `tilesets` array. The
|
|
||||||
external reference Tiled writes by default does not load.
|
|
||||||
- **Every object in an object group needs a `type` string**, plain rectangles
|
|
||||||
included. A missing one fails the whole load.
|
|
||||||
|
|
||||||
### Figures are output, not assets
|
|
||||||
|
|
||||||
A chapter about drawing wants a picture of what the drawing calls draw, and the
|
|
||||||
way a picture goes wrong is that it stops being of the code beside it —
|
|
||||||
silently, because a hand-taken screenshot still looks like a screenshot long
|
|
||||||
after the call changed. So a figure is generated from the listing it
|
|
||||||
illustrates:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
cmake --build build --target docs_screenshots
|
|
||||||
```
|
|
||||||
|
|
||||||
`tools/docs_screenshot.c` is a small second host: the dummy video driver, a
|
|
||||||
software renderer, one call to the block's `docs_frame()`, read the target back,
|
|
||||||
write a PNG. `tools/docs_screenshots.sh` compiles the listing against it.
|
|
||||||
|
|
||||||
Three rules, and `docs/images/README.md` carries the long version:
|
|
||||||
|
|
||||||
- **The PNGs are tracked.** A reader on the forge has no build tree. They are
|
|
||||||
generated files tracked on purpose, so **do not hand-edit one** — change the
|
|
||||||
listing and regenerate.
|
|
||||||
- **The build never regenerates them.** The target is only ever run
|
|
||||||
deliberately.
|
|
||||||
- **`docs_examples` fails a tagged block with no image**, so a figure cannot be
|
|
||||||
added to a chapter and then forgotten. Whether the image is *of that listing*
|
|
||||||
is the separate `docs_screenshots` test's question: it re-renders every figure
|
|
||||||
into a scratch directory and compares byte for byte, and it never repairs what
|
|
||||||
it finds.
|
|
||||||
|
|
||||||
### When `docs_examples` fails
|
|
||||||
|
|
||||||
The message names the file and the line of the block's **opening fence**. For an
|
|
||||||
output mismatch it prints both sides through `cat -A`, because the errors it
|
|
||||||
catches are trailing spaces and missing newlines, which a plain diff renders
|
|
||||||
invisibly.
|
|
||||||
|
|
||||||
**Fix the documentation, not the expectation** — unless the library is what
|
|
||||||
changed, in which case fix the library first and the documentation second. Never
|
|
||||||
edit an `output` block to match output you have not looked at.
|
|
||||||
|
|
||||||
Run one document at a time while you work:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
./tests/docs_examples.sh --root . \
|
|
||||||
--cflags-file build/docs_cflags.txt \
|
|
||||||
--ldflags-file build/docs_ldflags.txt \
|
|
||||||
--checkjson build/akgl_docs_checkjson \
|
|
||||||
docs/14-physics.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Every path is made absolute by the script before it starts, because it `cd`s
|
|
||||||
into sandbox directories. A relative one used to fail every example and every
|
|
||||||
`setup=` — invisible under CTest, which passes absolute paths, and immediate for
|
|
||||||
anyone running one document by hand.
|
|
||||||
|
|
||||||
Omit a flag and the blocks that need it are **skipped and counted**, not
|
|
||||||
silently passed, so a partial invocation cannot look like a clean run.
|
|
||||||
|
|
||||||
**Exit status is the number of failed examples**, the house convention; `2` is a
|
|
||||||
usage or setup error, which is a different thing and has to be distinguishable.
|
|
||||||
A document named on the command line that cannot be read is `2` — never a
|
|
||||||
silent fall back to the default list, which is how a suite ends up passing
|
|
||||||
having checked nothing.
|
|
||||||
|
|
||||||
### Read the census line
|
|
||||||
|
|
||||||
The last two lines of a run report **what it executed, by kind**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ran: 41 c snippets, 6 linked programs, 6 output comparisons, 18 excerpts, 4 json blocks, 2 shell blocks, 3 figures
|
|
||||||
skipped: 5 norun, 2 cmake, 7 text, 0 c (no compiler flags given), 0 run (no linker flags given), 0 json (no validator given)
|
|
||||||
```
|
|
||||||
|
|
||||||
That count is part of the result, not decoration. A harness that passes because
|
|
||||||
a chapter stopped matching the extractor looks exactly like a harness that
|
|
||||||
passes because the documentation is correct, and the census is the only thing
|
|
||||||
that tells them apart. In `akbasic` it caught exactly that: a CMake generator
|
|
||||||
expression evaluated to an empty argument, the script read it as a filename, and
|
|
||||||
the suite passed having checked no documents at all.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# The libakgl manual
|
|
||||||
|
|
||||||
libakgl is a C library for building 2D games on SDL3. It is not an engine: there is no
|
|
||||||
editor, no scripting layer, no inheritance and no runtime `malloc`. Behaviour attaches to a
|
|
||||||
struct as function pointers, objects come from fixed pools, and every call reports failure
|
|
||||||
through an error context you cannot silently ignore.
|
|
||||||
|
|
||||||
This manual teaches the library. It deliberately does **not** re-document its dependencies
|
|
||||||
— libakerror owns the error-handling protocol, SDL3 owns renderers and events, Tiled owns
|
|
||||||
the map format, jansson owns the JSON API. Each chapter says what libakgl adds or
|
|
||||||
constrains, shows what a caller actually writes, and links out for the rest. For
|
|
||||||
per-function reference, build the Doxygen output with `doxygen Doxyfile`.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **[1. Introduction](01-introduction.md)** | What libakgl is, what it refuses to be, the frame budget, and who owns which documentation |
|
|
||||||
| **[2. Design philosophy](02-design-philosophy.md)** | Bounded pools, pluggable backends, bit flags, name-based registries, one world at a time |
|
|
||||||
| **[3. Getting started](03-getting-started.md)** | Dependencies, `add_subdirectory` vs `akgl.pc`, and the smallest program that opens a window |
|
|
||||||
| **[4. Errors and status codes](04-errors.md)** | The status tables, what each code means *here*, and the traps that fail silently |
|
|
||||||
| **[5. The heap](05-the-heap.md)** | The eight pools, `akgl_heap_next_*`, `akgl_String`, and why exhaustion usually means a missing release |
|
|
||||||
| **[6. The registry](06-the-registry.md)** | The eight registries, configuration properties, and the id-0 silent no-op |
|
|
||||||
| **[7. The game and the frame](07-the-game-and-the-frame.md)** | Startup order, `akgl_game_update`, the state lock, iterators, savegames |
|
|
||||||
| **[8. Rendering](08-rendering.md)** | The backend vtable, the frame contract, cameras, and embedding a host's own `SDL_Renderer` |
|
|
||||||
| **[9. Drawing](09-drawing.md)** | Points, lines, rectangles, circles, flood fill, and saving a region |
|
|
||||||
| **[10. Spritesheets and sprites](10-spritesheets-and-sprites.md)** | One texture shared by path, the sprite JSON format, frame animation |
|
|
||||||
| **[11. Characters](11-characters.md)** | State-to-sprite mappings, movement constants, the character JSON format |
|
|
||||||
| **[12. Actors](12-actors.md)** | The state bitmask, the seven behaviour hooks, parents and children, layers |
|
|
||||||
| **[13. Tilemaps](13-tilemaps.md)** | Tiled TMJ, libakgl's three extensions, and the limits that bind a map |
|
|
||||||
| **[14. Physics](14-physics.md)** | Thrust, environment and velocity; the `null` and `arcade` backends; what is not implemented |
|
|
||||||
| **[15. Collision](15-collision.md)** | Shapes, masks, tiles as geometry, the response hook, and the two partitioners |
|
|
||||||
| **[16. Input](16-input.md)** | Control maps, push-not-poll dispatch, the keystroke ring, gamepads |
|
|
||||||
| **[17. Text and fonts](17-text-and-fonts.md)** | Loading, drawing, measuring, and the teardown order that matters |
|
|
||||||
| **[18. Audio](18-audio.md)** | The three-voice synthesizer, and the separate background-music path |
|
|
||||||
| **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings |
|
|
||||||
| **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** |
|
|
||||||
| **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world |
|
|
||||||
| **[22. User interfaces](22-ui.md)** | Menus, HUDs and dialogs on the clay layout engine: the widgets, the frame bracket, and writing `CLAY()` yourself |
|
|
||||||
| **[23. Appendix](23-appendix-limits.md)** | Every limit, every status, every configuration property |
|
|
||||||
|
|
||||||
**If you are new, read chapter 20 first and read it in order.** It builds a working game from
|
|
||||||
an empty directory and teaches the library as it needs each piece; chapter 21 assumes it.
|
|
||||||
Both are complete programs under [`examples/`](../examples) — they build with the library and
|
|
||||||
run headless in CI, and the chapters quote them rather than restating them, so a tutorial
|
|
||||||
cannot drift from a program that compiles. The picture at the top of each chapter is a frame
|
|
||||||
out of the game itself, regenerated by `cmake --build build --target docs_game_figures`.
|
|
||||||
|
|
||||||
## Every example here is checked
|
|
||||||
|
|
||||||
The examples in these chapters are compiled, linked, run, and cross-checked against the
|
|
||||||
source tree by the test suite. A snippet that stops compiling, an excerpt that no longer
|
|
||||||
matches the header it quotes, or a JSON document the loader would reject turns
|
|
||||||
`ctest -R docs_examples` red.
|
|
||||||
|
|
||||||
This exists because the documentation it replaces had drifted badly: the previous FAQ's
|
|
||||||
examples did not compile — two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];`
|
|
||||||
that is not C in any dialect, and, in the first snippet a reader ever saw, the exact
|
|
||||||
`strncpy` call `AGENTS.md` forbids. The prose had drifted with it, and writing these
|
|
||||||
chapters turned up **twenty-seven** header claims that were false against `src/`.
|
|
||||||
|
|
||||||
Where a chapter documents behaviour that is a known defect rather than a design decision,
|
|
||||||
it says so and points at `TODO.md`. See `MAINTENANCE.md` if you are editing an example.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
The tutorial art, tiles and music are CC0, vendored under
|
|
||||||
[`tutorials/assets/`](tutorials/assets) with provenance recorded per file in
|
|
||||||
[`PROVENANCE.md`](tutorials/assets/PROVENANCE.md) and geometry documented in
|
|
||||||
[`README.md`](tutorials/assets/README.md). CC0 specifically, not merely free: a reader who
|
|
||||||
copies a tutorial into their own game inherits no obligation.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# The figures in this directory are generated, and are tracked deliberately
|
|
||||||
|
|
||||||
Every `.png` beside this file is **output**, not art. Each one is rendered by
|
|
||||||
`tools/docs_screenshots.sh` from a ```` ```c screenshot=NAME ```` block in a
|
|
||||||
chapter — the same code the reader is looking at, compiled against
|
|
||||||
`tools/docs_screenshot.c`, run headless, and read back off the render target.
|
|
||||||
|
|
||||||
They are committed to the repository **on purpose**: a reader looking at the
|
|
||||||
manual on the forge has no build tree, and a chapter whose figures only exist
|
|
||||||
after `cmake --build` renders as a page of broken images. This is the same
|
|
||||||
tracked-generated-artifact contract `AGENTS.md` spells out for
|
|
||||||
`include/akgl/SDL_GameControllerDB.h`. Do not delete them, do not add them to
|
|
||||||
`.gitignore`, and do not "clean up" the fact that generated files are tracked.
|
|
||||||
|
|
||||||
Working rules:
|
|
||||||
|
|
||||||
- **Never hand-edit one, and never replace one with a hand-taken screenshot.**
|
|
||||||
The whole point of the arrangement is that the picture follows the code. A
|
|
||||||
figure that was retouched is a figure that has stopped being of the listing
|
|
||||||
beside it, silently — which is exactly the failure this replaced.
|
|
||||||
- **An ordinary build does not touch them.** Regeneration is
|
|
||||||
`cmake --build build --target docs_screenshots`, and nothing else runs the
|
|
||||||
script. A build that quietly rewrote eight binaries would put that diff in
|
|
||||||
front of whoever happened to run `make`.
|
|
||||||
- **Regenerate as a deliberate change**, in the same commit as whatever moved
|
|
||||||
the picture, so the binary diff is attributable.
|
|
||||||
- **`ctest -R docs_screenshots` re-renders every figure and compares it byte for
|
|
||||||
byte**, into a scratch directory. It never repairs what it finds: a test that
|
|
||||||
fixes what it is measuring passes the second time for the wrong reason.
|
|
||||||
- **`ctest -R docs_examples` fails a tagged block with no image here**, so a new
|
|
||||||
figure cannot be added to a chapter and then forgotten.
|
|
||||||
|
|
||||||
A byte comparison of a rendered PNG is a deliberate bet: that the dummy video
|
|
||||||
driver and the software renderer are reproducible run to run and build to build.
|
|
||||||
They are. What that does not cover is an SDL upgrade shifting one pixel of a
|
|
||||||
diagonal — and the answer to that is to regenerate the figures in the same
|
|
||||||
commit as the bump, not to weaken the check to a size comparison that would pass
|
|
||||||
for every wrong picture of the right dimensions.
|
|
||||||
|
|
||||||
See `docs/MAINTENANCE.md` for the fence tags and the rest of the harness.
|
|
||||||
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 730 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,121 +0,0 @@
|
|||||||
Creative Commons Legal Code
|
|
||||||
|
|
||||||
CC0 1.0 Universal
|
|
||||||
|
|
||||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
|
||||||
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
|
||||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
|
||||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
|
||||||
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
|
||||||
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
|
||||||
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
|
||||||
HEREUNDER.
|
|
||||||
|
|
||||||
Statement of Purpose
|
|
||||||
|
|
||||||
The laws of most jurisdictions throughout the world automatically confer
|
|
||||||
exclusive Copyright and Related Rights (defined below) upon the creator
|
|
||||||
and subsequent owner(s) (each and all, an "owner") of an original work of
|
|
||||||
authorship and/or a database (each, a "Work").
|
|
||||||
|
|
||||||
Certain owners wish to permanently relinquish those rights to a Work for
|
|
||||||
the purpose of contributing to a commons of creative, cultural and
|
|
||||||
scientific works ("Commons") that the public can reliably and without fear
|
|
||||||
of later claims of infringement build upon, modify, incorporate in other
|
|
||||||
works, reuse and redistribute as freely as possible in any form whatsoever
|
|
||||||
and for any purposes, including without limitation commercial purposes.
|
|
||||||
These owners may contribute to the Commons to promote the ideal of a free
|
|
||||||
culture and the further production of creative, cultural and scientific
|
|
||||||
works, or to gain reputation or greater distribution for their Work in
|
|
||||||
part through the use and efforts of others.
|
|
||||||
|
|
||||||
For these and/or other purposes and motivations, and without any
|
|
||||||
expectation of additional consideration or compensation, the person
|
|
||||||
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
|
||||||
is an owner of Copyright and Related Rights in the Work, voluntarily
|
|
||||||
elects to apply CC0 to the Work and publicly distribute the Work under its
|
|
||||||
terms, with knowledge of his or her Copyright and Related Rights in the
|
|
||||||
Work and the meaning and intended legal effect of CC0 on those rights.
|
|
||||||
|
|
||||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
|
||||||
protected by copyright and related or neighboring rights ("Copyright and
|
|
||||||
Related Rights"). Copyright and Related Rights include, but are not
|
|
||||||
limited to, the following:
|
|
||||||
|
|
||||||
i. the right to reproduce, adapt, distribute, perform, display,
|
|
||||||
communicate, and translate a Work;
|
|
||||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
|
||||||
iii. publicity and privacy rights pertaining to a person's image or
|
|
||||||
likeness depicted in a Work;
|
|
||||||
iv. rights protecting against unfair competition in regards to a Work,
|
|
||||||
subject to the limitations in paragraph 4(a), below;
|
|
||||||
v. rights protecting the extraction, dissemination, use and reuse of data
|
|
||||||
in a Work;
|
|
||||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
|
||||||
European Parliament and of the Council of 11 March 1996 on the legal
|
|
||||||
protection of databases, and under any national implementation
|
|
||||||
thereof, including any amended or successor version of such
|
|
||||||
directive); and
|
|
||||||
vii. other similar, equivalent or corresponding rights throughout the
|
|
||||||
world based on applicable law or treaty, and any national
|
|
||||||
implementations thereof.
|
|
||||||
|
|
||||||
2. Waiver. To the greatest extent permitted by, but not in contravention
|
|
||||||
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
|
||||||
irrevocably and unconditionally waives, abandons, and surrenders all of
|
|
||||||
Affirmer's Copyright and Related Rights and associated claims and causes
|
|
||||||
of action, whether now known or unknown (including existing as well as
|
|
||||||
future claims and causes of action), in the Work (i) in all territories
|
|
||||||
worldwide, (ii) for the maximum duration provided by applicable law or
|
|
||||||
treaty (including future time extensions), (iii) in any current or future
|
|
||||||
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
|
||||||
including without limitation commercial, advertising or promotional
|
|
||||||
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
|
||||||
member of the public at large and to the detriment of Affirmer's heirs and
|
|
||||||
successors, fully intending that such Waiver shall not be subject to
|
|
||||||
revocation, rescission, cancellation, termination, or any other legal or
|
|
||||||
equitable action to disrupt the quiet enjoyment of the Work by the public
|
|
||||||
as contemplated by Affirmer's express Statement of Purpose.
|
|
||||||
|
|
||||||
3. Public License Fallback. Should any part of the Waiver for any reason
|
|
||||||
be judged legally invalid or ineffective under applicable law, then the
|
|
||||||
Waiver shall be preserved to the maximum extent permitted taking into
|
|
||||||
account Affirmer's express Statement of Purpose. In addition, to the
|
|
||||||
extent the Waiver is so judged Affirmer hereby grants to each affected
|
|
||||||
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
|
||||||
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
|
||||||
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
|
||||||
maximum duration provided by applicable law or treaty (including future
|
|
||||||
time extensions), (iii) in any current or future medium and for any number
|
|
||||||
of copies, and (iv) for any purpose whatsoever, including without
|
|
||||||
limitation commercial, advertising or promotional purposes (the
|
|
||||||
"License"). The License shall be deemed effective as of the date CC0 was
|
|
||||||
applied by Affirmer to the Work. Should any part of the License for any
|
|
||||||
reason be judged legally invalid or ineffective under applicable law, such
|
|
||||||
partial invalidity or ineffectiveness shall not invalidate the remainder
|
|
||||||
of the License, and in such case Affirmer hereby affirms that he or she
|
|
||||||
will not (i) exercise any of his or her remaining Copyright and Related
|
|
||||||
Rights in the Work or (ii) assert any associated claims and causes of
|
|
||||||
action with respect to the Work, in either case contrary to Affirmer's
|
|
||||||
express Statement of Purpose.
|
|
||||||
|
|
||||||
4. Limitations and Disclaimers.
|
|
||||||
|
|
||||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
|
||||||
surrendered, licensed or otherwise affected by this document.
|
|
||||||
b. Affirmer offers the Work as-is and makes no representations or
|
|
||||||
warranties of any kind concerning the Work, express, implied,
|
|
||||||
statutory or otherwise, including without limitation warranties of
|
|
||||||
title, merchantability, fitness for a particular purpose, non
|
|
||||||
infringement, or the absence of latent or other defects, accuracy, or
|
|
||||||
the present or absence of errors, whether or not discoverable, all to
|
|
||||||
the greatest extent permissible under applicable law.
|
|
||||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
|
||||||
that may apply to the Work or any use thereof, including without
|
|
||||||
limitation any person's Copyright and Related Rights in the Work.
|
|
||||||
Further, Affirmer disclaims responsibility for obtaining any necessary
|
|
||||||
consents, permissions or other rights required for any use of the
|
|
||||||
Work.
|
|
||||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
|
||||||
party to this document and has no duty or obligation with respect to
|
|
||||||
this CC0 or use of the Work.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Music Jingles
|
|
||||||
|
|
||||||
by Kenney Vleugels (Kenney.nl)
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
License (Creative Commons Zero, CC0)
|
|
||||||
http://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
You may use these assets in personal and commercial projects.
|
|
||||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Donate: http://support.kenney.nl
|
|
||||||
Request: http://request.kenney.nl
|
|
||||||
|
|
||||||
Follow on Twitter for updates:
|
|
||||||
@KenneyNL
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
Pixel Line Platformer (1.0)
|
|
||||||
|
|
||||||
Created/distributed by Kenney (www.kenney.nl)
|
|
||||||
Creation date: 15-08-2021
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
License: (Creative Commons Zero, CC0)
|
|
||||||
http://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
This content is free to use in personal, educational and commercial projects.
|
|
||||||
|
|
||||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Donate: http://support.kenney.nl
|
|
||||||
Patreon: http://patreon.com/kenney/
|
|
||||||
|
|
||||||
Follow on Twitter for updates:
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
RPG Urban Pack 1.0
|
|
||||||
|
|
||||||
Created/distributed by Kenney (www.kenney.nl)
|
|
||||||
Creation date: 05-01-2019
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
License: (Creative Commons Zero, CC0)
|
|
||||||
http://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
This content is free to use in personal, educational and commercial projects.
|
|
||||||
|
|
||||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
|
||||||
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Donate: http://support.kenney.nl
|
|
||||||
Request: http://request.kenney.nl
|
|
||||||
Patreon: http://patreon.com/kenney/
|
|
||||||
|
|
||||||
Follow on Twitter for updates:
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# Provenance of the tutorial assets
|
|
||||||
|
|
||||||
Everything under `docs/tutorials/assets/` is **CC0 1.0** or was written for this
|
|
||||||
repository. Nothing here carries an attribution requirement, a share-alike
|
|
||||||
clause, or a field-of-use restriction, which is the whole point: a reader who
|
|
||||||
copies a tutorial into their own game inherits whatever obligation these assets
|
|
||||||
carry, and CC0 carries none. "Free to download" is not the same thing and was
|
|
||||||
not accepted -- each pack's licence was read on its Kenney page *and* in the
|
|
||||||
`License.txt` it ships, and `scripts/fetch_tutorial_assets.sh` re-checks both on
|
|
||||||
every refresh and refuses to vendor a pack that fails either.
|
|
||||||
|
|
||||||
`LICENSE` is the CC0 1.0 legal code. The three `LICENSE.kenney_*.txt` files are
|
|
||||||
the licence statements the packs themselves ship, kept beside the art in the
|
|
||||||
same way `tests/assets/akgl_test_mono.LICENSE.txt` sits beside its font.
|
|
||||||
|
|
||||||
The two tables below account for all 56 asset files: 14 carry upstream bytes and
|
|
||||||
42 were written here. This file and `README.md` are the remaining two, and are
|
|
||||||
documentation of the same kind as the rest of `docs/`.
|
|
||||||
|
|
||||||
## Upstream packs
|
|
||||||
|
|
||||||
| Pack | URL | Licence | Licence file in this tree |
|
|
||||||
|-----------------------|------------------------------------------------|---------|--------------------------------------------|
|
|
||||||
| Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `LICENSE.kenney_pixel-line-platformer.txt` |
|
|
||||||
| RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `LICENSE.kenney_rpg-urban-pack.txt` |
|
|
||||||
| Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `LICENSE.kenney_music-jingles.txt` |
|
|
||||||
|
|
||||||
Kenney asks for credit and does not require it. Credit is given here and in the
|
|
||||||
tutorial chapters.
|
|
||||||
|
|
||||||
## Files carrying upstream bytes
|
|
||||||
|
|
||||||
| File | Pack | Source URL | Licence | Cropped / repacked into |
|
|
||||||
|--------------------------------------------|-----------------------|-----------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------|
|
|
||||||
| `LICENSE` | creativecommons.org | https://creativecommons.org/publicdomain/zero/1.0/legalcode.txt | CC0 1.0 | The CC0 1.0 Universal legal code, fetched verbatim |
|
|
||||||
| `LICENSE.kenney_pixel-line-platformer.txt` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
|
||||||
| `LICENSE.kenney_rpg-urban-pack.txt` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
|
||||||
| `LICENSE.kenney_music-jingles.txt` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
|
|
||||||
| `sidescroller/tiles.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 160x96, 10x6 tiles of 16x16, no spacing, no margin |
|
|
||||||
| `sidescroller/player.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 40, 41, 42 and their horizontal mirrors, bottom-centred in six 32x32 cells |
|
|
||||||
| `sidescroller/coin.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tile 44, bottom-centred in one 32x32 cell |
|
|
||||||
| `sidescroller/hazard.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 55, 56, 51, 52, bottom-centred in four 32x32 cells |
|
|
||||||
| `sidescroller/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/8-Bit jingles/jingles_NES00.ogg`, copied verbatim (1.8 s) |
|
|
||||||
| `jrpg/tiles.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 432x288, 27x18 tiles of 16x16, no spacing, no margin |
|
|
||||||
| `jrpg/player.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 0 (tileset columns 23-26, rows 0-2) regrouped into twelve 32x32 cells |
|
|
||||||
| `jrpg/npc_shopkeeper.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 3 (tileset columns 23-26, rows 9-11) regrouped into twelve 32x32 cells |
|
|
||||||
| `jrpg/npc_elder.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 2 (tileset columns 23-26, rows 6-8) regrouped into twelve 32x32 cells |
|
|
||||||
| `jrpg/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/Pizzicato jingles/jingles_PIZZI07.ogg`, copied verbatim (1.3 s) |
|
|
||||||
|
|
||||||
## Files written for this repository
|
|
||||||
|
|
||||||
These contain no upstream content. They are libakgl source in JSON form: they
|
|
||||||
name the art above, they do not embed it.
|
|
||||||
|
|
||||||
| File | Kind | What it is |
|
|
||||||
|-------------------------------------------------|-----------|---------------------------------------------------------------------------------|
|
|
||||||
| `jrpg/character_jrpg_elder.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `jrpg/character_jrpg_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `jrpg/character_jrpg_shopkeeper.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `jrpg/sprite_jrpg_elder_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_elder_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_player_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/sprite_jrpg_shopkeeper_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `jrpg/town.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 30x20 cells, ground + decoration + actors |
|
|
||||||
| `sidescroller/character_ss_coin.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `sidescroller/character_ss_hazard_blob.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `sidescroller/character_ss_hazard_moth.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `sidescroller/character_ss_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
|
|
||||||
| `sidescroller/level1.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 40x15 cells, background + terrain + actors |
|
|
||||||
| `sidescroller/sprite_ss_coin.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_hazard_blob.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_hazard_moth.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_jump_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_jump_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_run_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
| `sidescroller/sprite_ss_player_run_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
|
|
||||||
|
|
||||||
## How the repacking works, and why
|
|
||||||
|
|
||||||
**Tilesets are copied verbatim.** Both packs already ship a 16x16 grid with zero
|
|
||||||
spacing and zero margin, which is the only geometry
|
|
||||||
`akgl_tilemap_compute_tileset_offsets` gets right -- it adds `spacing` to the
|
|
||||||
tile pitch but sets the *first* row's y offset to `spacing` rather than zero,
|
|
||||||
and it ignores `margin` completely. A packed sheet with a 1 px gutter, which is
|
|
||||||
what most of Kenney's older packs ship, would render every tile in row 0 one
|
|
||||||
pixel low. That ruled several otherwise-suitable packs out.
|
|
||||||
|
|
||||||
**Character sheets are repacked into one row of 32x32 cells.** The asset
|
|
||||||
contract for these tutorials is 16x16 tiles and 32x32 character frames, and
|
|
||||||
neither pack ships 32x32 art, so each 16x16 source tile is composited at (8, 16)
|
|
||||||
inside its cell -- horizontally centred, sitting on the cell's bottom edge. The
|
|
||||||
art is not resampled: a 2x upscale would put 2x2 pixel blocks next to 1x1 tiles,
|
|
||||||
and every sprite in the repository would stop matching the source pixel for
|
|
||||||
pixel. The cost is that three quarters of each cell is empty, and the benefit is
|
|
||||||
one rule the tutorials can rely on: **an actor drawn into a 32x32 destination
|
|
||||||
rectangle has its feet on the bottom edge of that rectangle and its body
|
|
||||||
centred**, whichever sheet it came from.
|
|
||||||
|
|
||||||
**Left-facing frames are stored, not mirrored at draw time.**
|
|
||||||
`akgl_actor_render` calls `draw_texture` with `SDL_FLIP_NONE` hard-coded
|
|
||||||
(`src/actor.c`), so there is no way to ask for a mirrored blit. The sidescroller
|
|
||||||
sheet therefore carries frames 0-2 facing right and frames 3-5 as their
|
|
||||||
horizontal mirrors. The JRPG sheets need no mirroring: the RPG Urban Pack draws
|
|
||||||
both side views.
|
|
||||||
|
|
||||||
**One frame is used twice.** Pixel Line Platformer has no dedicated jump pose.
|
|
||||||
Frame 1 is the passing position of the run cycle, which is the airborne one, and
|
|
||||||
`ss_player_jump_right` reuses it. Said here rather than left for a reader to
|
|
||||||
notice.
|
|
||||||
|
|
||||||
**The coin does not animate.** Pixel Line Platformer ships one gold pickup tile
|
|
||||||
and no rotation frames, so `sprite_ss_coin.json` is a single frame. Nothing was
|
|
||||||
invented to pad it out.
|
|
||||||
|
|
||||||
## Reproducing this
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/fetch_tutorial_assets.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetches all three packs, checks each page and each shipped `License.txt` says
|
|
||||||
CC0, verifies the source images are the size the frame arithmetic assumes,
|
|
||||||
repacks into a temporary directory, checks every staged file's dimensions, and
|
|
||||||
only then moves anything into place. A failure at any step leaves the tracked
|
|
||||||
copies untouched and exits non-zero. The output is byte-for-byte reproducible --
|
|
||||||
ImageMagick's wall-clock `date:create` and `date:modify` chunks are stripped, so
|
|
||||||
a refresh that changes nothing produces no diff.
|
|
||||||
|
|
||||||
The script does not touch the JSON or the TMJ maps. Those are hand-maintained.
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# The tutorial asset contract
|
|
||||||
|
|
||||||
What `examples/sidescroller/` and `examples/jrpg/` are drawing, and the exact
|
|
||||||
numbers they have to use. Licensing is in `PROVENANCE.md`; refreshing the art
|
|
||||||
from upstream is `scripts/fetch_tutorial_assets.sh`.
|
|
||||||
|
|
||||||
Every claim below was checked against `src/`, not against header prose, and the
|
|
||||||
whole set was loaded through the real library -- `akgl_sprite_load_json`,
|
|
||||||
`akgl_character_load_json`, `akgl_tilemap_load` -- before being committed.
|
|
||||||
|
|
||||||
## Geometry
|
|
||||||
|
|
||||||
| Thing | Value | Why it is that value |
|
|
||||||
|-------------------------|--------|---------------------------------------------------------------------------|
|
|
||||||
| Tile | 16x16 | Both tilesets ship on a 16x16 grid |
|
|
||||||
| Tileset spacing, margin | 0, 0 | `akgl_tilemap_compute_tileset_offsets` gets nothing else right; see below |
|
|
||||||
| Character frame | 32x32 | The fixed contract for these tutorials |
|
|
||||||
| Art inside a frame | 16x16 | Composited at (8, 16): centred, sitting on the cell's bottom edge |
|
|
||||||
| Frames per sheet row | all | Every sheet is a single row, so `coords_for_frame` never has to wrap |
|
|
||||||
| Frames per animation | max 16 | `AKGL_SPRITE_MAX_FRAMES`; a 17th is `AKERR_OUTOFBOUNDS` at load |
|
|
||||||
| Frame id | 0..255 | `uint8_t`; a larger id is `AKERR_OUTOFBOUNDS` at load |
|
|
||||||
|
|
||||||
**The one alignment rule.** `akgl_actor_render` draws into a rectangle of
|
|
||||||
`sprite->width` by `sprite->height` with its top-left at the actor's `x`, `y`.
|
|
||||||
Because every frame's art is bottom-centred in its cell, an actor at `(x, y)`
|
|
||||||
has its feet at `y + 32` and its 16 px body spanning `x + 8` to `x + 23`. Ground
|
|
||||||
contact, pickup tests and hitboxes should use that inner rectangle, not the
|
|
||||||
32x32 cell.
|
|
||||||
|
|
||||||
## Sidescroller sheets
|
|
||||||
|
|
||||||
`sidescroller/player.png` -- 192x32, six frames.
|
|
||||||
|
|
||||||
| Frame | Facing | Pose | Used by |
|
|
||||||
|-------|--------|---------------------------------|-----------------------------------------------|
|
|
||||||
| 0 | right | contact | `ss_player_idle_right`, `ss_player_run_right` |
|
|
||||||
| 1 | right | passing, airborne | `ss_player_run_right`, `ss_player_jump_right` |
|
|
||||||
| 2 | right | opposite contact | `ss_player_run_right` |
|
|
||||||
| 3 | left | contact (mirror of 0) | `ss_player_idle_left`, `ss_player_run_left` |
|
|
||||||
| 4 | left | passing, airborne (mirror of 1) | `ss_player_run_left`, `ss_player_jump_left` |
|
|
||||||
| 5 | left | opposite contact (mirror of 2) | `ss_player_run_left` |
|
|
||||||
|
|
||||||
The left-facing frames exist as their own art because `akgl_actor_render` passes
|
|
||||||
`SDL_FLIP_NONE` to `draw_texture` unconditionally (`src/actor.c`). There is no
|
|
||||||
way to ask the library for a mirrored blit.
|
|
||||||
|
|
||||||
`sidescroller/coin.png` -- 32x32, one frame. The pack ships no rotation frames.
|
|
||||||
|
|
||||||
`sidescroller/hazard.png` -- 128x32, four frames.
|
|
||||||
|
|
||||||
| Frame | What | Used by |
|
|
||||||
|-------|--------------------------|------------------|
|
|
||||||
| 0 | red blob, ground, pose A | `ss_hazard_blob` |
|
|
||||||
| 1 | red blob, ground, pose B | `ss_hazard_blob` |
|
|
||||||
| 2 | moth, flying, wings up | `ss_hazard_moth` |
|
|
||||||
| 3 | moth, flying, wings down | `ss_hazard_moth` |
|
|
||||||
|
|
||||||
## JRPG sheets
|
|
||||||
|
|
||||||
`jrpg/player.png`, `jrpg/npc_shopkeeper.png` and `jrpg/npc_elder.png` are
|
|
||||||
384x32, twelve frames, and share one layout.
|
|
||||||
|
|
||||||
| Frames | Facing | Poses | Idle sprite uses | Walk sprite uses |
|
|
||||||
|--------|--------|-----------------------|------------------|------------------|
|
|
||||||
| 0-2 | down | stand, step A, step B | 0 | 1, 0, 2, 0 |
|
|
||||||
| 3-5 | left | stand, step A, step B | 3 | 4, 3, 5, 3 |
|
|
||||||
| 6-8 | right | stand, step A, step B | 6 | 7, 6, 8, 6 |
|
|
||||||
| 9-11 | up | stand, step A, step B | 9 | 10, 9, 11, 9 |
|
|
||||||
|
|
||||||
The stand frame between the two steps is what makes it read as a walk rather
|
|
||||||
than a shuffle; it is the same three-frame cycle the source art was drawn for.
|
|
||||||
|
|
||||||
## States
|
|
||||||
|
|
||||||
A character's sprite map is keyed by `SDL_itoa(state)` and looked up with
|
|
||||||
`SDL_GetPointerProperty` (`akgl_character_sprite_get`), so **the match is on the
|
|
||||||
whole integer, not on a mask test**. A state the character has no exact entry
|
|
||||||
for makes the actor invisible for that frame -- `actor_visible` treats
|
|
||||||
`AKERR_KEY` as "nothing to draw", which is an answer, not an error.
|
|
||||||
|
|
||||||
These are the values the library's own input handlers produce.
|
|
||||||
`akgl_actor_cmhf_<dir>_on` clears every `FACE_*` and `MOVING_*` bit before
|
|
||||||
setting its own pair, and `_off` clears only its `MOVING_*` bit, so the facing
|
|
||||||
survives the key release and no two `MOVING_*` bits are ever set at once.
|
|
||||||
|
|
||||||
| State | Bits | Sidescroller sprite | JRPG sprite |
|
|
||||||
|-------|-------------------------------------|---------------------|------------------|
|
|
||||||
| 16 | ALIVE | (coin, hazards) | -- |
|
|
||||||
| 17 | ALIVE, FACE_DOWN | `..._idle_right` | `..._idle_down` |
|
|
||||||
| 18 | ALIVE, FACE_LEFT | `..._idle_left` | `..._idle_left` |
|
|
||||||
| 20 | ALIVE, FACE_RIGHT | `..._idle_right` | `..._idle_right` |
|
|
||||||
| 24 | ALIVE, FACE_UP | -- | `..._idle_up` |
|
|
||||||
| 146 | ALIVE, FACE_LEFT, MOVING_LEFT | `..._run_left` | `..._walk_left` |
|
|
||||||
| 276 | ALIVE, FACE_RIGHT, MOVING_RIGHT | `..._run_right` | `..._walk_right` |
|
|
||||||
| 530 | ALIVE, FACE_LEFT, MOVING_UP | `..._jump_left` | -- |
|
|
||||||
| 532 | ALIVE, FACE_RIGHT, MOVING_UP | `..._jump_right` | -- |
|
|
||||||
| 536 | ALIVE, FACE_UP, MOVING_UP | `..._jump_right` | `..._walk_up` |
|
|
||||||
| 658 | ALIVE, FACE_LEFT, MOVING_LEFT, UP | `..._jump_left` | -- |
|
|
||||||
| 788 | ALIVE, FACE_RIGHT, MOVING_RIGHT, UP | `..._jump_right` | -- |
|
|
||||||
| 1041 | ALIVE, FACE_DOWN, MOVING_DOWN | -- | `..._walk_down` |
|
|
||||||
|
|
||||||
The four jump states are there because a sidescroller that routes its jump
|
|
||||||
through `akgl_actor_cmhf_up_on` lands on 536 -- that handler clears `FACE_RIGHT`
|
|
||||||
and sets `FACE_UP`, which in a side view is not a facing at all. A game that
|
|
||||||
sets `ey` directly instead never reaches those states, and nothing breaks
|
|
||||||
either way.
|
|
||||||
|
|
||||||
State names in character JSON are the **prefixed** spellings from
|
|
||||||
`src/actor_state_string_names.c`: `AKGL_ACTOR_STATE_ALIVE`, not
|
|
||||||
`ACTOR_STATE_ALIVE`. `util/assets/littleguy.json` uses the old unprefixed names
|
|
||||||
and `velocity_x`, and does not load against the current library. Do not copy it.
|
|
||||||
|
|
||||||
## Maps
|
|
||||||
|
|
||||||
Both maps are Tiled 1.8 TMJ with the tileset **embedded**.
|
|
||||||
|
|
||||||
`akgl_tilemap_load` reads `columns`, `firstgid`, `tilecount`, `image`,
|
|
||||||
`imagewidth`, `imageheight`, `margin`, `spacing`, `tilewidth`, `tileheight` and
|
|
||||||
`name` straight out of each element of the map's `tilesets` array
|
|
||||||
(`akgl_tilemap_load_tilesets_each`). The string `source` appears nowhere in
|
|
||||||
`src/tilemap.c` and nothing in the library ever opens a `.tsj`, so an external
|
|
||||||
tileset reference fails with `AKERR_KEY` on the missing `columns`. `README.md`
|
|
||||||
in the repository root says the opposite; it is wrong.
|
|
||||||
|
|
||||||
| Map | Cells | Layers | Objects |
|
|
||||||
|---------------------------|-------|-------------------------------------------|---------|
|
|
||||||
| `sidescroller/level1.tmj` | 40x15 | background (tile), terrain (tile), actors | 7 |
|
|
||||||
| `jrpg/town.tmj` | 30x20 | ground (tile), decoration (tile), actors | 3 |
|
|
||||||
|
|
||||||
Global tile ids are `(row * columns) + column + 1`; `0` means an empty cell and
|
|
||||||
is skipped. `sidescroller/tiles.png` has ten columns and 60 tiles;
|
|
||||||
`jrpg/tiles.png` has twenty-seven columns and 486 tiles, the last four columns
|
|
||||||
of which are the character art the sheets above were cut from. Those cells are
|
|
||||||
never referenced by `town.tmj`.
|
|
||||||
|
|
||||||
Other things the loader insists on, all of them checked:
|
|
||||||
|
|
||||||
- **Every object needs a `type` string**, including ones that are not actors. A
|
|
||||||
missing `type` fails the whole load, not just that object.
|
|
||||||
- An actor object needs a non-empty `name`, a `character` **string** property
|
|
||||||
and a `state` **int** property. The string-array form of `state` works in
|
|
||||||
character JSON and is not accepted here.
|
|
||||||
- The `character` named must already be in `AKGL_REGISTRY_CHARACTER`, which
|
|
||||||
means every sprite and character JSON has to be loaded before the map.
|
|
||||||
- A map's `properties` are optional; if present, `physics.model` must name a
|
|
||||||
backend that exists (`null` or `arcade`) or the load fails. Gravity and drag
|
|
||||||
keys are `float`.
|
|
||||||
- Tileset image paths resolve through `akgl_path_relative` against the map's own
|
|
||||||
directory. Image-layer paths do not -- those go through a plain `"%s/%s"`
|
|
||||||
join, so an absolute path there does not work. Keep every path relative.
|
|
||||||
|
|
||||||
## Physics
|
|
||||||
|
|
||||||
The sidescroller map asks for the `arcade` backend with `physics.gravity.y`
|
|
||||||
900.0 and `physics.drag.y` 1.5. There is no terminal velocity in the backend;
|
|
||||||
`ey` approaches `gravity_y / drag_y`, so those two numbers set it to 600 px/s.
|
|
||||||
The JRPG map asks for `arcade` with both gravity components at 0.0.
|
|
||||||
|
|
||||||
`ss_player` has `speed_y` and `acceleration_y` of 0.0 on purpose: a zero top
|
|
||||||
speed on an axis means `akgl_physics_simulate` zeroes that axis's thrust
|
|
||||||
outright, so holding a vertical direction cannot make the player fly. A jump
|
|
||||||
belongs in `ey`.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jrpg_elder",
|
|
||||||
"speedtime": 150,
|
|
||||||
"speed_x": 60.0,
|
|
||||||
"speed_y": 60.0,
|
|
||||||
"acceleration_x": 400.0,
|
|
||||||
"acceleration_y": 400.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_idle_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_walk_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_idle_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_walk_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_idle_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_walk_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_idle_up"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_elder_walk_up"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jrpg_player",
|
|
||||||
"speedtime": 150,
|
|
||||||
"speed_x": 60.0,
|
|
||||||
"speed_y": 60.0,
|
|
||||||
"acceleration_x": 400.0,
|
|
||||||
"acceleration_y": 400.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_idle_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_walk_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_idle_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_walk_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_idle_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_walk_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_idle_up"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_player_walk_up"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jrpg_shopkeeper",
|
|
||||||
"speedtime": 150,
|
|
||||||
"speed_x": 60.0,
|
|
||||||
"speed_y": 60.0,
|
|
||||||
"acceleration_x": 400.0,
|
|
||||||
"acceleration_y": 400.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_idle_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_walk_down"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_idle_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_walk_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_idle_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_walk_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_idle_up"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "jrpg_shopkeeper_walk_up"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 640 B |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_idle_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_idle_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_idle_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_idle_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_walk_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
2,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_walk_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
4,
|
|
||||||
3,
|
|
||||||
5,
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_walk_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
7,
|
|
||||||
6,
|
|
||||||
8,
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_elder.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_elder_walk_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
10,
|
|
||||||
9,
|
|
||||||
11,
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_idle_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_idle_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_idle_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_idle_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_walk_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
2,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_walk_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
4,
|
|
||||||
3,
|
|
||||||
5,
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_walk_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
7,
|
|
||||||
6,
|
|
||||||
8,
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_player_walk_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
10,
|
|
||||||
9,
|
|
||||||
11,
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_idle_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_idle_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_idle_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_idle_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_walk_down",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
2,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_walk_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
4,
|
|
||||||
3,
|
|
||||||
5,
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_walk_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
7,
|
|
||||||
6,
|
|
||||||
8,
|
|
||||||
6
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "npc_shopkeeper.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "jrpg_shopkeeper_walk_up",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 150,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
10,
|
|
||||||
9,
|
|
||||||
11,
|
|
||||||
9
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ss_coin",
|
|
||||||
"speedtime": 200,
|
|
||||||
"speed_x": 0.0,
|
|
||||||
"speed_y": 0.0,
|
|
||||||
"acceleration_x": 0.0,
|
|
||||||
"acceleration_y": 0.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE"
|
|
||||||
],
|
|
||||||
"sprite": "ss_coin"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ss_hazard_blob",
|
|
||||||
"speedtime": 180,
|
|
||||||
"speed_x": 24.0,
|
|
||||||
"speed_y": 0.0,
|
|
||||||
"acceleration_x": 200.0,
|
|
||||||
"acceleration_y": 0.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_blob"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_blob"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_blob"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_blob"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_blob"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ss_hazard_moth",
|
|
||||||
"speedtime": 120,
|
|
||||||
"speed_x": 40.0,
|
|
||||||
"speed_y": 40.0,
|
|
||||||
"acceleration_x": 300.0,
|
|
||||||
"acceleration_y": 300.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_moth"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_moth"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_moth"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_moth"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_hazard_moth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ss_player",
|
|
||||||
"speedtime": 120,
|
|
||||||
"speed_x": 90.0,
|
|
||||||
"speed_y": 0.0,
|
|
||||||
"acceleration_x": 600.0,
|
|
||||||
"acceleration_y": 0.0,
|
|
||||||
"sprite_mappings": [
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_idle_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_run_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_idle_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_run_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_DOWN"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_idle_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_UP",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_jump_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_jump_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_jump_left"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_RIGHT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_jump_right"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"state": [
|
|
||||||
"AKGL_ACTOR_STATE_ALIVE",
|
|
||||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_LEFT",
|
|
||||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
|
||||||
],
|
|
||||||
"sprite": "ss_player_jump_left"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 122 B |
|
Before Width: | Height: | Size: 246 B |
|
Before Width: | Height: | Size: 383 B |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "coin.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_coin",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 200,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "hazard.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_hazard_blob",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 180,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "hazard.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_hazard_moth",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 120,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
2,
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_player_idle_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_player_idle_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_player_jump_left",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
4
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"spritesheet": {
|
|
||||||
"filename": "player.png",
|
|
||||||
"frame_width": 32,
|
|
||||||
"frame_height": 32
|
|
||||||
},
|
|
||||||
"name": "ss_player_jump_right",
|
|
||||||
"width": 32,
|
|
||||||
"height": 32,
|
|
||||||
"speed": 250,
|
|
||||||
"loop": true,
|
|
||||||
"loopReverse": false,
|
|
||||||
"frames": [
|
|
||||||
1
|
|
||||||
]
|
|
||||||
}
|
|
||||||