Compare commits
21 Commits
149bee0c99
...
todo-to-is
| Author | SHA1 | Date | |
|---|---|---|---|
|
963d511224
|
|||
|
eabb9ad376
|
|||
| bbb7b8f494 | |||
|
5560edf410
|
|||
|
be3c8ae850
|
|||
|
d8458d1e68
|
|||
|
219d7182f1
|
|||
|
2242cfac9f
|
|||
|
f2efbdf1b7
|
|||
|
f772da7296
|
|||
|
8f848d80be
|
|||
|
4ddd8d1ad3
|
|||
|
badaf570ab
|
|||
|
0679d40ee4
|
|||
|
566b870a5c
|
|||
|
3b3bfcbe10
|
|||
|
064e6569e8
|
|||
|
b7ff54b09f
|
|||
|
75da766724
|
|||
|
cf0142ea85
|
|||
|
6e97d27e49
|
@@ -14,7 +14,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake doxygen gcc gcovr pkg-config \
|
||||
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 \
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
fail_on_failure: 'true'
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: code-coverage
|
||||
path: build/coverage/
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
# the next baseline if somebody re-records PERFORMANCE.md.
|
||||
- name: Upload benchmark tables
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: performance-baseline
|
||||
path: perf-baseline.txt
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
run: scripts/memcheck.sh
|
||||
- name: Upload valgrind logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: valgrind-logs
|
||||
path: build/Testing/Temporary/MemoryChecker.*.log
|
||||
|
||||
16
.gitmodules
vendored
16
.gitmodules
vendored
@@ -1,18 +1,18 @@
|
||||
[submodule "deps/semver"]
|
||||
path = deps/semver
|
||||
url = git@github.com:h2non/semver.c.git
|
||||
url = https://github.com/h2non/semver.c.git
|
||||
[submodule "deps/SDL"]
|
||||
path = deps/SDL
|
||||
url = git@github.com:libsdl-org/SDL.git
|
||||
url = https://github.com/libsdl-org/SDL.git
|
||||
[submodule "deps/SDL_image"]
|
||||
path = deps/SDL_image
|
||||
url = git@github.com:libsdl-org/SDL_image.git
|
||||
url = https://github.com/libsdl-org/SDL_image.git
|
||||
[submodule "deps/SDL_mixer"]
|
||||
path = deps/SDL_mixer
|
||||
url = git@github.com:libsdl-org/SDL_mixer.git
|
||||
url = https://github.com/libsdl-org/SDL_mixer.git
|
||||
[submodule "deps/SDL_ttf"]
|
||||
path = deps/SDL_ttf
|
||||
url = git@github.com:libsdl-org/SDL_ttf.git
|
||||
url = https://github.com/libsdl-org/SDL_ttf.git
|
||||
[submodule "deps/libsdlerror"]
|
||||
path = deps/libakerror
|
||||
url = https://source.starfort.tech/andrew/libakerror.git
|
||||
@@ -21,10 +21,14 @@
|
||||
url = https://source.starfort.tech/andrew/libakstdlib.git
|
||||
[submodule "deps/jansson"]
|
||||
path = deps/jansson
|
||||
url = git@github.com:akheron/jansson.git
|
||||
url = https://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
|
||||
|
||||
125
AGENTS.md
125
AGENTS.md
@@ -124,7 +124,7 @@ 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".
|
||||
could not fail", which is the record of how that happened.
|
||||
|
||||
## Coding Style
|
||||
|
||||
@@ -309,7 +309,7 @@ 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
|
||||
whole argument of issue #5. Read into a correctly typed local, check
|
||||
the range, and assign.
|
||||
|
||||
### No repository-wide formatter
|
||||
@@ -496,8 +496,8 @@ 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.
|
||||
inconsistent about them already; see issue #19, which is the decision this
|
||||
waits on, 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
|
||||
@@ -549,12 +549,13 @@ cheap to check and expensive to assume.
|
||||
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
|
||||
- **Do not trust a comment, an issue, 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.
|
||||
exiting 0 while running one of its four tests. `TODO.md`, when it still held
|
||||
outstanding work, carried eleven entries describing code that had already
|
||||
changed, and three more were found stale when it was migrated to the tracker.
|
||||
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
|
||||
@@ -564,6 +565,19 @@ cheap to check and expensive to assume.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Outstanding work goes in the issue tracker, not in a file.** Open an issue at
|
||||
<https://source.starfort.tech/andrew/libakgl/issues> — `tea issues create --repo andrew/libakgl`
|
||||
— with the file and line it touches, the functional consequence, and what closing it would
|
||||
touch. Label it by kind and blast radius, and leave `status::grooming` on it until its scope
|
||||
and approach are settled. **Do not add outstanding items to `TODO.md`**; that file is the
|
||||
record of decisions, measurements and defects already closed, and the reason for the split is
|
||||
that a description of work still to do goes stale the moment somebody does it.
|
||||
- **When you learn something about an open issue, say so in the issue** — including that it is
|
||||
worse than recorded, that it is already fixed, or that its premise has expired. An issue
|
||||
nobody has re-checked is worth what an unverified comment is worth.
|
||||
- **A defect in a dependency is filed against that dependency**, not worked around here.
|
||||
`libakerror`, `libakstdlib` and `akbasic` all have trackers on the same forge. Comment the
|
||||
workaround at its site with the words "filed upstream" and delete it when the fix lands.
|
||||
- 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.
|
||||
|
||||
@@ -682,6 +696,101 @@ 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.
|
||||
|
||||
@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
|
||||
# The single source of truth for the library version. It drives the generated
|
||||
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
|
||||
# Version field in akgl.pc. Bump it here and nowhere else.
|
||||
project(akgl VERSION 0.8.0 LANGUAGES C)
|
||||
project(akgl VERSION 0.9.0 LANGUAGES C)
|
||||
|
||||
# Memory checking reuses the suites that already exist -- `ctest -T memcheck`
|
||||
# runs every registered test under valgrind -- rather than adding programs of its
|
||||
@@ -217,6 +217,7 @@ set(AKGL_PUBLIC_HEADERS
|
||||
text
|
||||
tilemap
|
||||
types
|
||||
ui
|
||||
util
|
||||
)
|
||||
|
||||
@@ -317,6 +318,8 @@ add_library(akgl SHARED
|
||||
src/sprite.c
|
||||
src/staticstring.c
|
||||
src/tilemap.c
|
||||
src/ui.c
|
||||
src/ui_clay.c
|
||||
src/util.c
|
||||
src/version.c
|
||||
)
|
||||
@@ -368,6 +371,26 @@ set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES
|
||||
COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
|
||||
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE")
|
||||
|
||||
# clay supplies the UI layout engine. Its sources are listed rather than
|
||||
# add_subdirectory()'d, on the semver/libccd precedent, because
|
||||
# deps/clay/CMakeLists.txt is unusable as a subproject and none of it is ours
|
||||
# to fix in a submodule:
|
||||
#
|
||||
# - it declares no library target at all -- clay is a single header, and the
|
||||
# add_library(INTERFACE) lines at the bottom are commented out upstream.
|
||||
# - option(CLAY_INCLUDE_ALL_EXAMPLES ... ON) add_subdirectory()s every
|
||||
# example by default, dragging raylib, cairo and sokol into our configure.
|
||||
# - cmake_minimum_required(VERSION 3.27) against this project's 3.10.
|
||||
#
|
||||
# src/ui_clay.c is the one translation unit that defines CLAY_IMPLEMENTATION;
|
||||
# it is 99% vendored code, so it gets -w on the same terms as semver and
|
||||
# libccd. Deliberately NOT -fvisibility=hidden, unlike libccd: the CLAY()
|
||||
# macros in a consuming game expand to Clay__OpenElement() and friends, which
|
||||
# must resolve against libakgl.so, so clay's symbols are part of the ABI on
|
||||
# purpose. The matching obligation -- a consumer must not link a second clay --
|
||||
# is documented in akgl/ui.h.
|
||||
set_source_files_properties(src/ui_clay.c PROPERTIES COMPILE_OPTIONS "-w")
|
||||
|
||||
# PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers`
|
||||
# suite compiles each public header as the first include of a translation unit
|
||||
# with only include/ available, so a public header that pulled in <ccd/ccd.h>
|
||||
@@ -406,6 +429,7 @@ set(AKGL_TEST_SUITES
|
||||
staticstring
|
||||
text
|
||||
tilemap
|
||||
ui
|
||||
util
|
||||
version
|
||||
)
|
||||
@@ -516,6 +540,11 @@ set_tests_properties(
|
||||
target_include_directories(akgl PUBLIC
|
||||
include/
|
||||
deps/semver/
|
||||
# PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes
|
||||
# <clay.h> in its public interface -- consumers write CLAY() blocks, so
|
||||
# hiding the header would hide the point. clay.h is installed beside
|
||||
# semver.h for the same reason.
|
||||
deps/clay/
|
||||
# akgl/version.h is generated by configure_file, so it lives in the build tree
|
||||
# rather than beside the headers it is included from.
|
||||
${CMAKE_CURRENT_BINARY_DIR}/include/
|
||||
@@ -633,20 +662,26 @@ if(AKGL_VENDORED_DEPENDENCIES)
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib"
|
||||
)
|
||||
# set_property rather than set_tests_properties for the list-valued
|
||||
# property: set_tests_properties parses its PROPERTIES arguments as
|
||||
# name/value pairs, so a semicolon-separated value is split and every
|
||||
# element after the first is consumed as a bogus property name -- which
|
||||
# silently reduced this prepend list to its first directory for as long as
|
||||
# it existed. RPATH covered for it locally; a runner found it.
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(AKGL_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}"
|
||||
set_property(
|
||||
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD}
|
||||
)
|
||||
else()
|
||||
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
set_property(
|
||||
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTY ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
@@ -754,7 +789,12 @@ add_test(
|
||||
set_tests_properties(docs_examples PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
TIMEOUT 900
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
# set_property for the same pair-splitting reason as the suites above.
|
||||
set_property(TEST docs_examples PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
|
||||
# Every figure in docs/ re-rendered and byte-compared against the tracked copy.
|
||||
@@ -779,7 +819,11 @@ add_test(
|
||||
set_tests_properties(docs_screenshots PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
TIMEOUT 900
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
set_property(TEST docs_screenshots PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
|
||||
# Regenerating the figures is a deliberate act, never part of a build: the PNGs
|
||||
@@ -832,7 +876,15 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt")
|
||||
--demo --frames 240
|
||||
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
|
||||
--screenshot-frame 230
|
||||
DEPENDS sidescroller jrpg
|
||||
# Frame 100 of the UI demo's scripted tour is the play screen with the
|
||||
# dialog up and the score mid-count -- all three widgets in one frame.
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
|
||||
$<TARGET_FILE:uidemo>
|
||||
--demo --frames 240
|
||||
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/uidemo.png"
|
||||
--screenshot-frame 100
|
||||
DEPENDS sidescroller jrpg uidemo
|
||||
COMMENT "Regenerating the tutorial figures in docs/images"
|
||||
VERBATIM
|
||||
)
|
||||
@@ -882,11 +934,14 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/")
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/")
|
||||
install(TARGETS akgl DESTINATION "lib/")
|
||||
install(FILES "deps/semver/semver.h" DESTINATION "include/")
|
||||
# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice
|
||||
# to travel with the binary form. Nothing else in deps/ is linked in statically
|
||||
# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects
|
||||
# that ship their own -- so this is the only third-party notice we owe.
|
||||
install(FILES "deps/clay/clay.h" DESTINATION "include/")
|
||||
# libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and
|
||||
# zlib respectively) ask that the notice travel with the binary form. Nothing
|
||||
# else in deps/ is linked in statically -- SDL, jansson, libakerror and
|
||||
# libakstdlib are all separate shared objects that ship their own -- so these
|
||||
# are the only third-party notices we owe.
|
||||
install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd")
|
||||
install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay")
|
||||
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
|
||||
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
|
||||
endforeach()
|
||||
|
||||
@@ -563,7 +563,7 @@ day a GPU backend makes the pixels cheap.
|
||||
|
||||
## Defects these tests found
|
||||
|
||||
Six, and they are filed where defects live: `TODO.md`, under **Performance ->
|
||||
Six, and they are recorded 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.
|
||||
|
||||
@@ -184,7 +184,7 @@ 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**.
|
||||
`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**; the plan for the missed targets is issue #60.
|
||||
|
||||
## Memory checking
|
||||
|
||||
@@ -211,7 +211,7 @@ 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
|
||||
vendored but **compiled into nothing** -- see `TODO.md`, "Why deps/tg is still in the
|
||||
consumer".
|
||||
|
||||
libccd is given a static arena rather than the allocator it ships with, so "libakgl does not
|
||||
|
||||
27
TODO.txt
27
TODO.txt
@@ -1,27 +0,0 @@
|
||||
Rendering should move to the SDL GPU renderer so i can do lighting and particles etc
|
||||
- Example suitable for my most primitive use case: https://github.com/TheSpydog/SDL_gpu_examples/blob/main/Examples/Blit2DArray.c
|
||||
- Try vulkan and D3D tutorials to come up to speed on the moving pieces, then figure ou the details from the examples and API docs
|
||||
- https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Introduction
|
||||
- https://learn.microsoft.com/en-us/previous-versions/windows/desktop/bb153302(v=vs.85)
|
||||
- http://www.directxtutorial.com/LessonList.aspx?listid=112
|
||||
- Shaders compiled with: https://github.com/libsdl-org/SDL_shadercross. There are no docs on this tool.
|
||||
- Shader language is tricky. MS is abandoning HLSL and everyone unifying on Spir-V. But Spir-V is intermediate, HLSL is more high level so easier to pick up.
|
||||
- https://flathub.org/apps/org.shadered.SHADERed
|
||||
- Really good HLSL tutorials: https://www.youtube.com/playlist?list=PL78XDi0TS4lEMvytsE_MoWEpzBcukXv9b
|
||||
- Spir-V tutorials: https://github.com/google/spirv-tutor
|
||||
|
||||
Actors should collide with each other
|
||||
- Box2D is going to be overkill for this for simpler platforms. We're going to benefit from a simpler approach.
|
||||
- https://katyscode.wordpress.com/2013/01/18/2d-platform-games-collision-detection-for-dummies/ looks promising
|
||||
|
||||
World collision geometry should be loaded from object layers on the map
|
||||
|
||||
Actors should collide with the world
|
||||
|
||||
Actors should be able to follow a path defined with a polyline on a layer on the map
|
||||
|
||||
Actors should be able to calculate their facing state automatically depending on their movement state (for path following)
|
||||
|
||||
Maps should be able to have image layers
|
||||
|
||||
Map parallax should work
|
||||
1
deps/clay
vendored
Submodule
1
deps/clay
vendored
Submodule
Submodule deps/clay added at b25a31c1a1
@@ -68,7 +68,7 @@ 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`.
|
||||
hit it, and an issue in the tracker.
|
||||
|
||||
| Gap | Behaviour today | Chapter |
|
||||
|---|---|---|
|
||||
|
||||
@@ -117,7 +117,7 @@ pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansso
|
||||
`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
|
||||
Recorded as issue #17, along with why the fix wants
|
||||
its own commit: adding a `Requires:` line changes what `pkg-config --libs akgl` emits for
|
||||
every existing consumer.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i
|
||||
|
||||
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 six starting there, under the owner string `"libakgl"`:
|
||||
libakgl claims a band of seven starting there, under the owner string `"libakgl"`:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#define AKGL_ERR_OWNER "libakgl"
|
||||
@@ -37,12 +37,13 @@ libakgl claims a band of six starting there, under the owner string `"libakgl"`:
|
||||
#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 + 6`, one past the last
|
||||
**`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 a seventh real code is ever added it would silently
|
||||
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 |
|
||||
@@ -53,10 +54,12 @@ start catching that instead.
|
||||
| `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
|
||||
six: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
||||
`"Logic Interrupt"`, `"Collision Error"`. Those names are what a stack trace prints.
|
||||
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.
|
||||
|
||||
@@ -99,7 +102,7 @@ Statuses raised by the libraries underneath also reach you unchanged. `aksl_fope
|
||||
`akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
|
||||
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`.
|
||||
|
||||
[Chapter 22](22-appendix-limits.md) has the per-function cross-reference.
|
||||
[Chapter 23](23-appendix-limits.md) has the per-function cross-reference.
|
||||
|
||||
## Table 3 — the exit status trap
|
||||
|
||||
@@ -170,7 +173,7 @@ So the context has exactly one owner at a time, and it is never both yours and i
|
||||
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
|
||||
`AGENTS.md` documents the shape under "Testing Guidelines"; `TODO.md` records the
|
||||
history.
|
||||
|
||||
The correct shape: take the result into a local, `NULL` your own pointer immediately, and
|
||||
@@ -266,6 +269,6 @@ Four things in there are libakgl-specific and worth naming:
|
||||
release.
|
||||
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
|
||||
`AKERR_NULLPOINTER`.
|
||||
- [Chapter 22](22-appendix-limits.md) — which function raises what.
|
||||
- [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.
|
||||
|
||||
@@ -105,7 +105,7 @@ 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
|
||||
rather than a design (issue #14; also the `@warning` on
|
||||
`heap.h` itself).
|
||||
|
||||
| Acquire | Takes the reference? | Who takes it |
|
||||
@@ -344,4 +344,4 @@ all visible in `src/heap.c`.
|
||||
|
||||
- [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 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
|
||||
@@ -35,8 +35,8 @@ same `SDL_Texture`, and the texture is loaded once.
|
||||
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.
|
||||
`MOVING_OUT`, **so those two states cannot be named from JSON at all** (`TODO.md`,
|
||||
"`AKGL_ACTOR_STATE_STRING_NAMES`"). 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.
|
||||
@@ -51,8 +51,7 @@ 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,
|
||||
properties call was added in 0.5.0 (`TODO.md`, "Formatting and hygiene"). 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,
|
||||
@@ -113,12 +112,11 @@ document. The fields:
|
||||
|
||||
**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
|
||||
the registry cannot tell. That is issue #54, and it is not fixed yet 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*
|
||||
There is a second half that the issue 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:
|
||||
|
||||
@@ -224,5 +222,5 @@ registry's.
|
||||
- [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 22](22-appendix-limits.md) — the configuration table again, alongside every
|
||||
- [Chapter 23](23-appendix-limits.md) — the configuration table again, alongside every
|
||||
`AKGL_MAX_*`.
|
||||
|
||||
@@ -347,7 +347,7 @@ Three further things to know:
|
||||
- **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
|
||||
"Defects") — 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.
|
||||
@@ -367,4 +367,4 @@ error-reporting path. See [Chapter 4](04-errors.md).
|
||||
what `draw_world` does with the layers.
|
||||
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
|
||||
pace.
|
||||
- [Chapter 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
- [Chapter 23](23-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
|
||||
@@ -187,7 +187,7 @@ land.
|
||||
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
|
||||
> **Known defect (issue #26).** 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
|
||||
|
||||
@@ -295,14 +295,14 @@ game that wants one binds its own `changeframefunc`.
|
||||
- **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
|
||||
error. Recorded as issue #54; 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
|
||||
`refcount`; `next_sprite` and `next_spritesheet` do not. Issue #14. 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.
|
||||
are not. `TODO.md`, "Targets", row 16; issue #13.
|
||||
- **`akgl_spritesheet_coords_for_frame` bounds-checks nothing.** See above.
|
||||
|
||||
## Where to look next
|
||||
|
||||
@@ -310,7 +310,7 @@ character out from under a live actor leaves a dangling `basechar`.
|
||||
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
|
||||
related to issue #54, 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
|
||||
|
||||
@@ -331,7 +331,7 @@ backend accumulates gravity, so tapping down mid-jump stopped the character in t
|
||||
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
|
||||
> **Known defect (issue #30).** 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.
|
||||
@@ -431,12 +431,12 @@ for `akgl_Actor`.
|
||||
`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".
|
||||
the registry keeps an entry pointing at a zeroed slot. That is issue #38;
|
||||
issue #54 covers the related truncation collision.
|
||||
- **`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.
|
||||
issues #29 through #32. [Chapter 14](14-physics.md) covers both.
|
||||
|
||||
## Where to look next
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
||||
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 22](22-appendix-limits.md).
|
||||
changes the ABI — see [Chapter 23](23-appendix-limits.md).
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -362,7 +362,7 @@ 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`.
|
||||
its issue.
|
||||
|
||||
**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
|
||||
@@ -375,8 +375,8 @@ 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
|
||||
as of 0.5.0, and a second release is safe rather than a use-after-free (`TODO.md`,
|
||||
"Defects"). 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`.
|
||||
|
||||
@@ -177,7 +177,7 @@ cannot get in the way of that. The first version of that helper set `gravity_tim
|
||||
`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".
|
||||
stepped exactly is itself a finding**, recorded as issue #31.
|
||||
|
||||
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
|
||||
@@ -332,7 +332,7 @@ this section used to carry.
|
||||
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".
|
||||
0.6.0. These four are not. All four are issues #29 through #32.
|
||||
|
||||
| Gap | What you see | The workaround today |
|
||||
|---|---|---|
|
||||
|
||||
@@ -225,7 +225,7 @@ akgl_collision_world_init(&world, "bsp", 16.0f, 16.0f);
|
||||
|
||||
- **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`.
|
||||
adding it touches one static function in the narrowphase; see issue #62.
|
||||
- **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
|
||||
|
||||
@@ -353,7 +353,7 @@ re-derives them from the header prose:
|
||||
`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.
|
||||
are stale; `TODO.md`, "Defects", records the fix.
|
||||
- **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
|
||||
|
||||
@@ -127,8 +127,8 @@ Measuring is 340 times cheaper than drawing, which tells you the whole cost is t
|
||||
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.
|
||||
`PERFORMANCE.md` calls it the single clearest optimisation in the library, and it is
|
||||
issue #22.
|
||||
|
||||
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,
|
||||
@@ -209,7 +209,7 @@ guards, so drawing nothing still refuses everything drawing something refuses.
|
||||
> 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
|
||||
> runs on every path. `TODO.md` records both as fixed. The header comments
|
||||
> want correcting in their own commit.
|
||||
|
||||
## Fonts, the pools, and what is not shared
|
||||
|
||||
@@ -302,7 +302,7 @@ touches `akgl_mixer`, so calling only that leaves `akgl_load_start_bgm` handing
|
||||
`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
|
||||
and stops, and `akgl_load_start_bgm` reports success either way. Issue #16; the fix
|
||||
is `SDL_CreateProperties()` into `bgmprops`, checked, and destroyed in `CLEANUP`.
|
||||
|
||||
**`AKGL_REGISTRY_MUSIC` exists and nothing populates it.** `akgl_registry_init_music()`
|
||||
|
||||
@@ -161,8 +161,8 @@ Three things to know:
|
||||
|
||||
> **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
|
||||
> of libakerror's 128 context slots per call for the life of the process, and `TODO.md`
|
||||
> recorded the same thing as open. **Both were 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.
|
||||
@@ -332,7 +332,7 @@ for an object member.
|
||||
> `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
|
||||
> `TODO.md` 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
|
||||
|
||||
@@ -134,19 +134,36 @@ target_compile_definitions(sidescroller
|
||||
|
||||
The parts that matter:
|
||||
|
||||
Each `.c` file includes `sidescroller.h` plus whatever it calls directly. `main.c` adds
|
||||
`akgl/character.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/registry.h`, `akgl/renderer.h`,
|
||||
`akgl/sprite.h` and `akgl/text.h`; `player.c` adds `akgl/controller.h`, `akgl/heap.h`,
|
||||
`akgl/registry.h`, `akgl/util.h` and `<math.h>`; `actors.c` adds `akgl/heap.h`,
|
||||
`akgl/registry.h` and `<math.h>`. libakgl's headers are self-contained, so including the one
|
||||
that declares what you are calling is always enough.
|
||||
Each `.c` file includes `sidescroller.h` plus whatever it calls directly:
|
||||
|
||||
| File | Adds |
|
||||
|---|---|
|
||||
| `main.c` | `<string.h>`, `akstdlib.h`, `SDL3_image/SDL_image.h`, `SDL3_mixer/SDL_mixer.h`, `SDL3_ttf/SDL_ttf.h`, `akgl/character.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/registry.h`, `akgl/renderer.h`, `akgl/sprite.h`, `akgl/text.h`, `akgl/tilemap.h` |
|
||||
| `player.c` | `<math.h>`, `akstdlib.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/physics.h`, `akgl/registry.h`, `akgl/util.h` |
|
||||
| `actors.c` | `<math.h>`, `akstdlib.h`, `akgl/heap.h`, `akgl/registry.h` |
|
||||
|
||||
**libakstdlib's header is `<akstdlib.h>`**, not `<aksl.h>` — its functions are prefixed
|
||||
`aksl_` but the file is not. libakerror's is `<akerror.h>`. libakgl's own headers are
|
||||
self-contained, so including the one that declares what you are calling is always enough.
|
||||
|
||||
The build file:
|
||||
|
||||
- You link all four SDL libraries even though this game has no text and no sound, because
|
||||
libakgl itself is built against them.
|
||||
- `SS_ASSET_DIR` is baked in at compile time, so the program can be run from any working
|
||||
directory. The code falls back to `"."` if it is not defined.
|
||||
directory. Give it a fallback so the file still compiles without CMake, and **use it as the
|
||||
default**: a program that defaults to `"."` only finds its assets when it happens to be
|
||||
launched from the right directory.
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
#ifndef SS_ASSET_DIR
|
||||
#define SS_ASSET_DIR "."
|
||||
#endif
|
||||
```
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
char *assetdir = SS_ASSET_DIR;
|
||||
```
|
||||
|
||||
### The header
|
||||
|
||||
@@ -395,7 +412,7 @@ window:
|
||||
```
|
||||
|
||||
Without this line the first frame calls through a null function pointer. Making the library
|
||||
pick a default is tracked in `TODO.md` under "Known and still open"; until it does, this call
|
||||
pick a default is issue #36; until it does, this call
|
||||
belongs in every libakgl program.
|
||||
|
||||
### The frame loop
|
||||
@@ -507,7 +524,7 @@ Assign it right after `akgl_game_init()`:
|
||||
```
|
||||
|
||||
The hook exists so a game can shed work when it is running slowly. This one has nothing to
|
||||
shed. The first-second false positive is recorded in `TODO.md`.
|
||||
shed. The first-second false positive is recorded in `TODO.md`, "Performance".
|
||||
|
||||
---
|
||||
|
||||
@@ -649,7 +666,14 @@ reads. Load a character before its sprites and it fails on the first name it can
|
||||
|
||||
## 5. Draw a level
|
||||
|
||||
Open Tiled, make a new map, and set it up like this:
|
||||
**Draw the map in [Tiled](https://mapeditor.org) and save it as `.tmj`.** The rest of this
|
||||
section is what to set in the editor; Tiled writes the JSON. [Chapter 13](13-tilemaps.md) is
|
||||
the reference for the format itself, and for what libakgl reads out of it and what it
|
||||
ignores — read that before hand-editing a `.tmj`, because the loader needs several fields
|
||||
Tiled fills in automatically (a root-level `width` and `height`, an `id` on every layer, and
|
||||
`tilecount`, `columns`, `imagewidth` and `imageheight` on every tileset).
|
||||
|
||||
Make a new map and set it up like this:
|
||||
|
||||
| Setting | Value |
|
||||
|---|---|
|
||||
@@ -739,8 +763,7 @@ Add three custom properties to the **map itself** (Map → Map Properties):
|
||||
| `physics.drag.y` | float | `1.5` | Air resistance on the vertical axis |
|
||||
|
||||
Gravity of 900 px/s² with a drag of 1.5 gives a terminal fall speed of 600 px/s. Drag is what
|
||||
bounds a fall, so do not set it to zero. (A `terminal_velocity` setting is in `TODO.md` under
|
||||
"Arcade physics feel".)
|
||||
bounds a fall, so do not set it to zero. (A `terminal_velocity` setting is issue #29.)
|
||||
|
||||
Putting physics in the map rather than in code is what lets a swimming level and a walking
|
||||
level differ by data.
|
||||
@@ -792,7 +815,7 @@ Then the map:
|
||||
|
||||
**Load into `akgl_gamemap`, which already points at storage the library owns.** Do not
|
||||
declare an `akgl_Tilemap` on the stack: it is about 26 MB, several times a default thread
|
||||
stack, and you get a segfault before the loader writes a byte. Shrinking it is `TODO.md`
|
||||
stack, and you get a segfault before the loader writes a byte. Shrinking it is issue #28
|
||||
targets 14 and 15.
|
||||
|
||||
That one call creates an actor for every `actor` object in the object layer, binds each to
|
||||
@@ -1176,7 +1199,7 @@ Snap to zero below a pixel per second, because an exponential decay never actual
|
||||
|
||||
The library's own `akgl_actor_cmhf_left_off` zeroes `tx` outright, which stops the actor dead
|
||||
in one frame — right for a top-down game, wrong for a sidescroller. Friction and deceleration
|
||||
in the backend are tracked in `TODO.md` under "Arcade physics feel"; when they land, this
|
||||
in the backend are issues #29 through #32; when they land, this
|
||||
whole section becomes a setting.
|
||||
|
||||
### The whole movement function, in order
|
||||
@@ -1545,7 +1568,7 @@ else.
|
||||
underneath the registry that still points at them.
|
||||
|
||||
Do not call `akgl_tilemap_release` unless you are loading a second level — it has a
|
||||
double-free, recorded in `TODO.md` under "Known and still open" item 2. A process that is
|
||||
double-free, recorded in `TODO.md` under "Defects". A process that is
|
||||
exiting can leave the textures to `SDL_Quit`.
|
||||
|
||||
### Reporting a failure from `main`
|
||||
@@ -1625,6 +1648,19 @@ It prints where the player finished:
|
||||
sidescroller: 240 frames, 0 of 4 coins, 0 deaths, player at 136.0,160.0 grounded
|
||||
```
|
||||
|
||||
**Capture that position at the end of `run()`, not in `main`.** Teardown happens in `main`'s
|
||||
`CLEANUP` block and releases the actor pool, so by the time the summary is printed
|
||||
`ss_game.player` points at a slot that has been given back — and the line reports `0.0,0.0`:
|
||||
|
||||
```c excerpt=examples/sidescroller/main.c
|
||||
if ( ss_game.player != NULL ) {
|
||||
ss_game.final_x = ss_game.player->x;
|
||||
ss_game.final_y = ss_game.player->y;
|
||||
}
|
||||
```
|
||||
|
||||
That is what `final_x` and `final_y` on `ss_Game` are for.
|
||||
|
||||
`grounded` and a sensible `y` are how you know collision ran. If the player is hundreds of
|
||||
pixels below the level, revisit step 7.
|
||||
|
||||
|
||||
@@ -80,11 +80,16 @@ TrueType font at runtime. Bake both paths in at compile time:
|
||||
|
||||
```cmake
|
||||
target_compile_definitions(jrpg PRIVATE
|
||||
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
|
||||
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
|
||||
JRPG_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
|
||||
JRPG_FONT_FILE="${CMAKE_CURRENT_SOURCE_DIR}/assets/font.ttf"
|
||||
)
|
||||
```
|
||||
|
||||
**Two definitions, not one.** The font is a separate path from the assets, and a game that
|
||||
defines only `JRPG_ASSET_DIR` fails at `akgl_text_loadfont` with "Couldn't open" — after
|
||||
everything else has loaded, which makes it look like a font problem rather than a build
|
||||
one.
|
||||
|
||||
Give them defaults in the header so the file still compiles on its own:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.h
|
||||
@@ -425,8 +430,8 @@ already works.
|
||||
|
||||
## 5. Draw the town
|
||||
|
||||
Set the map up as chapter 20 describes — orthogonal, CSV layer format, 16×16 tiles — at
|
||||
30 × 20 tiles, with three layers:
|
||||
Draw it in Tiled and save it as `.tmj`, set up as chapter 20 describes — orthogonal, CSV
|
||||
layer format, 16×16 tiles — at 30 × 20 tiles, with three layers:
|
||||
|
||||
| Layer | Type | What it is |
|
||||
|---|---|---|
|
||||
@@ -531,7 +536,7 @@ state is skipped rather than reported, so every NPC in the town disappears on fr
|
||||
silently, and so does the player as soon as they stop walking.
|
||||
|
||||
Clearing the field leaves the facing bits wherever they were last set, which is what a
|
||||
top-down game wants. `TODO.md` tracks making the default behave; until then this loop belongs
|
||||
top-down game wants. Issue #39 tracks making the default behave; until then this loop belongs
|
||||
in every game with a standing NPC in it.
|
||||
|
||||
The loop covers the player as well as the NPCs, which is what you want — a player who stops
|
||||
@@ -743,7 +748,7 @@ The physics step writes a child's `x` and `y` as an absolute world position, and
|
||||
`akgl_actor_render` adds the parent's position to it a second time. Detaching takes the
|
||||
branch that does not add it, and `CLEANUP` puts the parent back on every path including the
|
||||
failing one — an actor left with a `NULL` parent would be simulated as a free agent on the
|
||||
next step. This is `TODO.md`, "Known and still open"; when the two agree on one reading, this
|
||||
next step. This is issue #37; when the two agree on one reading, this
|
||||
hook becomes `akgl_actor_render` again.
|
||||
|
||||
Run now. A second character walks a fixed distance behind and below the player.
|
||||
@@ -887,7 +892,7 @@ Guard against the empty string:
|
||||
```
|
||||
|
||||
`akgl_text_rendertextat` refuses a zero-width string while `akgl_text_measure` accepts one.
|
||||
The disagreement is recorded in `TODO.md` under "Known and still open"; the check above is
|
||||
The disagreement is issue #37; the check above is
|
||||
the guard until it is settled.
|
||||
|
||||
### Drawing it over the world
|
||||
@@ -957,6 +962,13 @@ range, otherwise do nothing.
|
||||
#define TALK_RANGE 64.0f
|
||||
```
|
||||
|
||||
**Place your NPCs with that number in mind.** The distance is measured between the two
|
||||
actors' positions, and both sprites are 32 pixels, so 64 is two sprite widths — close, by
|
||||
design. An NPC parked behind a building or across a wall from anywhere the player can stand
|
||||
is an NPC the player can see and never talk to, and nothing reports that: the handler finds
|
||||
nobody in range and returns success. If a conversation will not open, print the distance
|
||||
before you go looking at the code.
|
||||
|
||||
```c excerpt=examples/jrpg/world.c
|
||||
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
|
||||
```
|
||||
@@ -1072,6 +1084,15 @@ The `+ 16.0f` centres on the middle of a 32-pixel sprite rather than its top-lef
|
||||
The loop is chapter 20's, with two additions. Drain the events first:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
static akerr_ErrorContext *frame(long frameno)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Event event;
|
||||
akgl_Iterator opflags = {
|
||||
.flags = AKGL_ITERATOR_OP_UPDATE,
|
||||
.layerid = 0
|
||||
};
|
||||
|
||||
while ( SDL_PollEvent(&event) ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
running = false;
|
||||
@@ -1080,6 +1101,10 @@ The loop is chapter 20's, with two additions. Drain the events first:
|
||||
}
|
||||
```
|
||||
|
||||
`frameno` is the outer loop's own counter, incremented once per call. **libakgl does not keep
|
||||
a frame number** — `akgl_game` has an fps figure but no counter, so a game that wants one
|
||||
keeps it.
|
||||
|
||||
Then the camera, the world, and the panel on top of it:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
@@ -1088,17 +1113,9 @@ Then the camera, the world, and the panel on top of it:
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
```
|
||||
|
||||
This game passes an iterator to `akgl_game_update` rather than `NULL`:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
akgl_Iterator opflags = {
|
||||
.flags = AKGL_ITERATOR_OP_UPDATE,
|
||||
.layerid = 0
|
||||
};
|
||||
```
|
||||
|
||||
`AKGL_ITERATOR_OP_UPDATE` asks for the update pass. [Chapter 7](07-the-game-and-the-frame.md)
|
||||
covers the other flags.
|
||||
This game passes the `opflags` declared at the top of `frame()` to `akgl_game_update` rather
|
||||
than the `NULL` chapter 20 passes. `AKGL_ITERATOR_OP_UPDATE` asks for the update pass;
|
||||
[Chapter 7](07-the-game-and-the-frame.md) covers the other flags.
|
||||
|
||||
### Teardown
|
||||
|
||||
@@ -1115,8 +1132,8 @@ covers the other flags.
|
||||
registry and `SDL_Quit` destroys it, taking the last reference to every font with no way left
|
||||
to close them.
|
||||
|
||||
`akgl_tilemap_release` is not called. It double-frees tileset textures, which is `TODO.md`
|
||||
"Known and still open" item 2; `SDL_Quit` reclaims them correctly. A game that loads a second
|
||||
`akgl_tilemap_release` is not called. It double-freed tileset textures, which is `TODO.md`
|
||||
"Defects"; `SDL_Quit` reclaims them correctly. A game that loads a second
|
||||
level has to unwind properly, and that is what the fix will make possible.
|
||||
|
||||
The pools are static storage and the process is exiting, so there is nothing else to free.
|
||||
@@ -1154,6 +1171,77 @@ where the player finished:
|
||||
jrpg: 320 frames, player at (280, 130)
|
||||
```
|
||||
|
||||
### Writing the scripted run
|
||||
|
||||
Neither the demo nor that summary line is part of the game — both exist so the program can be
|
||||
checked without a person at the keyboard, and both are worth having for exactly that reason.
|
||||
|
||||
The script is a table of frame numbers and keys:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.h
|
||||
typedef struct {
|
||||
long frame; /**< Frame number this step fires on. */
|
||||
SDL_Keycode key; /**< Key to synthesize. */
|
||||
bool down; /**< True for a press, false for a release. */
|
||||
} jrpg_ScriptStep;
|
||||
```
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
|
||||
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
|
||||
{ 70, SDLK_RIGHT, false },
|
||||
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
|
||||
{ 205, SDLK_UP, false },
|
||||
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
|
||||
{ 216, SDLK_SPACE, false },
|
||||
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
|
||||
{ 245, SDLK_LEFT, false },
|
||||
{ 255, SDLK_SPACE, true }, /* dismiss */
|
||||
{ 256, SDLK_SPACE, false },
|
||||
{ 265, SDLK_DOWN, true }, /* and walk away */
|
||||
{ 285, SDLK_DOWN, false }
|
||||
};
|
||||
```
|
||||
|
||||
Playing it back is building an `SDL_Event` and handing it to the same function the real event
|
||||
loop uses:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
|
||||
if ( JRPG_DEMO_SCRIPT[i].down ) {
|
||||
event.type = SDL_EVENT_KEY_DOWN;
|
||||
} else {
|
||||
event.type = SDL_EVENT_KEY_UP;
|
||||
}
|
||||
event.key.which = 0;
|
||||
event.key.key = JRPG_DEMO_SCRIPT[i].key;
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||
```
|
||||
|
||||
**Go through `akgl_controller_handle_event`, not straight to the handlers.** A script that
|
||||
calls the handlers directly still passes when the control map matches nothing at all, which
|
||||
is the failure it most needs to catch.
|
||||
|
||||
Two more things make a headless run reproducible. Drive the clock rather than sleeping on it,
|
||||
so a simulated second does not cost a real one:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
|
||||
```
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
|
||||
```
|
||||
|
||||
And print the position while the player still exists — before teardown releases the actor
|
||||
pool:
|
||||
|
||||
```c excerpt=examples/jrpg/jrpg.c
|
||||
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
|
||||
```
|
||||
|
||||
The frame number is the loop's own counter, passed down. libakgl does not keep one.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with gravity, a jump
|
||||
|
||||
396
docs/22-ui.md
Normal file
396
docs/22-ui.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# 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,4 +1,4 @@
|
||||
# 22. Appendix: limits, statuses and properties
|
||||
# 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.
|
||||
@@ -148,6 +148,9 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
| `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`
|
||||
|
||||
@@ -176,6 +179,22 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
| `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 |
|
||||
@@ -196,8 +215,9 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
|
||||
## B. Compile-time limits
|
||||
|
||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight
|
||||
and `AKGL_CCD_ARENA_BYTES` are overridable**, and even those have to be overridden for the whole build — see
|
||||
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.
|
||||
@@ -281,7 +301,8 @@ A ninth child is `AKERR_OUTOFBOUNDS`; an over-long name is silently truncated.
|
||||
|
||||
`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).
|
||||
truncating it into an index naming a different tile (`TODO.md`, "Found while rewriting
|
||||
the Doxygen comments").
|
||||
|
||||
**`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
|
||||
@@ -315,8 +336,8 @@ Three corrections to what those comments say, all verified against `src/tilemap.
|
||||
- **`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.
|
||||
`AKGL_TILEMAP_MAX_TILESETS`. Both landed in 0.5.0 (`TODO.md`, "Found while rewriting
|
||||
the Doxygen comments"), and the header comment was not updated with them -- issue #63.
|
||||
- **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
|
||||
@@ -347,6 +368,31 @@ keeps instead of recursing per pixel. A region needing more pending runs at once
|
||||
`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
|
||||
@@ -383,8 +429,8 @@ budget and was wrong by a factor of a thousand, blocking for roughly sixteen min
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
|
||||
| `AKGL_ERR_COUNT` | 5 |
|
||||
| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** |
|
||||
| `AKGL_ERR_COUNT` | 7 |
|
||||
| `AKGL_ERR_LIMIT` | 263 — **the one-past-the-end sentinel, not a status** |
|
||||
|
||||
## C. Configuration properties
|
||||
|
||||
@@ -4,6 +4,13 @@ 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
|
||||
|
||||
@@ -34,7 +34,8 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`.
|
||||
| **[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. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property |
|
||||
| **[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.
|
||||
@@ -57,7 +58,7 @@ that is not C in any dialect, and, in the first snippet a reader ever saw, the e
|
||||
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.
|
||||
it says so and points at its issue. See `MAINTENANCE.md` if you are editing an example.
|
||||
|
||||
## Assets
|
||||
|
||||
|
||||
BIN
docs/images/uidemo.png
Normal file
BIN
docs/images/uidemo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,4 @@
|
||||
# The two tutorial games.
|
||||
# The tutorial games and demos.
|
||||
#
|
||||
# Each one is a complete, running program that the matching chapter quotes with
|
||||
# `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot
|
||||
@@ -9,7 +9,7 @@
|
||||
# passes and land at different times, and a configure that fails because one of
|
||||
# them is not there yet would block the other. There is nothing clever about the
|
||||
# guard; it exists so an incomplete tree still builds.
|
||||
foreach(_example sidescroller jrpg)
|
||||
foreach(_example sidescroller jrpg uidemo)
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt")
|
||||
add_subdirectory(${_example})
|
||||
endif()
|
||||
|
||||
@@ -47,26 +47,31 @@ endif()
|
||||
# it to suppress the vendored projects' registrations and lifts the suppression
|
||||
# again long before examples/ is added.
|
||||
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
set_tests_properties(example_jrpg PROPERTIES TIMEOUT 120)
|
||||
# set_property, not set_tests_properties: the latter parses its PROPERTIES
|
||||
# arguments as name/value *pairs*, so a semicolon-separated value is split and
|
||||
# every element after the first is consumed as a bogus property name. The
|
||||
# audio and render entries of this list were silently lost that way --
|
||||
# invisible on any machine whose real audio device works, and found the first
|
||||
# time CI ran on a runner without one.
|
||||
set_property(TEST example_jrpg PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
|
||||
# to go in the same property rather than a second one. Only needed when the
|
||||
# dependencies were vendored; an installed build resolves them normally.
|
||||
# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they
|
||||
# were vendored; an installed build resolves them normally.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(JRPG_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(example_jrpg
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
|
||||
set_property(TEST example_jrpg PROPERTY ENVIRONMENT_MODIFICATION ${JRPG_TEST_ENV_MOD})
|
||||
else()
|
||||
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
set_property(TEST example_jrpg APPEND PROPERTY ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -347,8 +347,7 @@ static akerr_ErrorContext *frame(long frameno)
|
||||
* - akgl_tilemap_release is **not** called. Its layer loop destroys
|
||||
* `tilesets[i].texture` rather than `layers[i].texture`, so it double-frees
|
||||
* every tileset texture and never frees an image layer's, and it NULLs
|
||||
* nothing, so a second call is a use-after-free. TODO.md, "Known and still
|
||||
* open" item 2. `SDL_Quit` reclaims the textures correctly; calling the
|
||||
* nothing, so a second call is a use-after-free. TODO.md, "Defects". `SDL_Quit` reclaims the textures correctly; calling the
|
||||
* function that is supposed to would be worse than not.
|
||||
* - The pools are static storage. There is nothing to free and the process is
|
||||
* about to exit.
|
||||
|
||||
@@ -83,8 +83,8 @@ akerr_ErrorContext *jrpg_textbox_draw(void)
|
||||
// akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text
|
||||
// has zero width" and the library passes that on as AKERR_NULLPOINTER,
|
||||
// while akgl_text_measure accepts it. The two disagree, so anything drawing
|
||||
// a line that might be empty checks for it. TODO.md, "Known and still
|
||||
// open".
|
||||
// a line that might be empty checks for it. TODO.md, "Found while
|
||||
// rewriting the Doxygen comments".
|
||||
if ( textbox_text[0] == '\0' ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,15 @@ endif()
|
||||
# while this project is top-level, and by the time examples/ is added the
|
||||
# suppression has already been lifted. An embedded build gets the builtin.
|
||||
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
||||
set_tests_properties(example_sidescroller PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
set_tests_properties(example_sidescroller PROPERTIES TIMEOUT 120)
|
||||
# set_property, not set_tests_properties: the latter parses its PROPERTIES
|
||||
# arguments as name/value *pairs*, so a semicolon-separated value is split and
|
||||
# every element after the first is consumed as a bogus property name. The
|
||||
# audio and render entries of this very list were silently lost that way for
|
||||
# as long as the list existed -- invisible on any machine whose real audio
|
||||
# device works, and found the first time CI ran on a runner without one.
|
||||
set_property(TEST example_sidescroller PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* `movementlogicfunc`, which is the only hook the step calls.
|
||||
*
|
||||
* Two of the library's documented gaps are worked around here rather than
|
||||
* papered over. Both are in `TODO.md` under "Arcade physics feel".
|
||||
* papered over. Both are issues #29 through #32.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
@@ -324,7 +324,7 @@ akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
|
||||
FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
|
||||
/* akgl_controller_pushmap checks the upper bound and not the lower one, so
|
||||
* a negative id indexes before the start of akgl_controlmaps. TODO.md,
|
||||
* "Known and still open" item 11. */
|
||||
* "Defects". */
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
|
||||
|
||||
62
examples/uidemo/CMakeLists.txt
Normal file
62
examples/uidemo/CMakeLists.txt
Normal file
@@ -0,0 +1,62 @@
|
||||
# The UI demo, quoted by the UI chapter's `c excerpt=` blocks.
|
||||
#
|
||||
# A real target built by `all`, on the same terms as the tutorial games: a
|
||||
# chapter whose program does not compile is the failure the documentation
|
||||
# harness exists to stop.
|
||||
|
||||
add_executable(uidemo
|
||||
uidemo.c
|
||||
)
|
||||
|
||||
target_link_libraries(uidemo
|
||||
PRIVATE akstdlib::akstdlib akerror::akerror akgl
|
||||
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
|
||||
jansson::jansson -lm)
|
||||
target_include_directories(uidemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_compile_options(uidemo PRIVATE ${AKGL_WARNING_FLAGS})
|
||||
|
||||
# The demo needs no art -- its "world" is akgl_draw_background -- but it does
|
||||
# need one font, and it uses the same test-asset face the JRPG does, compiled
|
||||
# in as an absolute path for the same run-from-anywhere reason.
|
||||
get_filename_component(UIDEMO_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
target_compile_definitions(uidemo PRIVATE
|
||||
UIDEMO_FONT_FILE="${UIDEMO_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
|
||||
)
|
||||
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
set_target_properties(uidemo PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||
endif()
|
||||
|
||||
# The headless smoke run. `--demo` tours every screen through the real event
|
||||
# chain -- a mouse click on the title menu (the consumed path), keyboard into
|
||||
# and out of the raw-CLAY options screen, the play screen's dialog opened and
|
||||
# dismissed, and Quit confirmed from the menu -- rather than just proving that
|
||||
# main() returns. There is no physics here, so nothing needs a driven clock;
|
||||
# `--frames` is a backstop above the script's last step, not the exit path.
|
||||
add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo)
|
||||
set_tests_properties(example_uidemo PROPERTIES TIMEOUT 120)
|
||||
# set_property, not set_tests_properties: the latter parses its PROPERTIES
|
||||
# arguments as name/value *pairs*, so a semicolon-separated value is split and
|
||||
# every element after the first is consumed as a bogus property name. See the
|
||||
# same note in the other examples' CMakeLists.
|
||||
set_property(TEST example_uidemo PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they
|
||||
# were vendored; an installed build resolves them normally.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(UIDEMO_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_property(TEST example_uidemo PROPERTY ENVIRONMENT_MODIFICATION ${UIDEMO_TEST_ENV_MOD})
|
||||
else()
|
||||
string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_property(TEST example_uidemo APPEND PROPERTY ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}")
|
||||
endif()
|
||||
endif()
|
||||
609
examples/uidemo/uidemo.c
Normal file
609
examples/uidemo/uidemo.c
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* @file uidemo.c
|
||||
* @brief The UI demo: a title menu, an options screen, and a HUD, three ways.
|
||||
*
|
||||
* The chapter on the UI subsystem quotes this program rather than restating
|
||||
* it. Run it with no arguments for a window:
|
||||
*
|
||||
* ./examples/uidemo/uidemo
|
||||
*
|
||||
* The title menu answers to the arrow keys, Return, the D-pad, and the mouse.
|
||||
* Start opens a stand-in play screen -- a checkerboard where a game would be
|
||||
* -- with a score counter ticking in a HUD label; Space opens and closes a
|
||||
* dialog panel there, and Escape backs out. Options is a screen written in
|
||||
* raw CLAY() declarations, because the widgets are a convenience, not a
|
||||
* boundary. `--frames N` bounds the run and `--demo` drives the whole tour
|
||||
* from a script, which is what makes this runnable as a headless smoke test.
|
||||
*
|
||||
* What is deliberately absent: a tilemap, actors, physics. The world here is
|
||||
* one draw call, so everything left is the subject -- what a UI costs and
|
||||
* where it goes in a frame.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/ui.h>
|
||||
|
||||
#include "uidemo.h"
|
||||
|
||||
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
|
||||
#define UIDEMO_FRAME_BUDGET_MS 16
|
||||
|
||||
/*
|
||||
* The scripted tour `--demo` drives. Every route through the demo, by every
|
||||
* input device it supports: the mouse clicks the title menu open (dead centre
|
||||
* lands on the middle row by symmetry -- the menu is centred and Options is
|
||||
* its middle entry), the keyboard walks back out, into the play screen,
|
||||
* through the dialog, and finally down to Quit.
|
||||
*
|
||||
* Frame Event Key / position What it proves
|
||||
*/
|
||||
static const uidemo_ScriptStep UIDEMO_SCRIPT[] = {
|
||||
{ 5, SDL_EVENT_MOUSE_MOTION, 0, 320.0f, 240.0f }, /* hover the menu */
|
||||
{ 10, SDL_EVENT_MOUSE_BUTTON_DOWN, 0, 320.0f, 240.0f }, /* consumed click */
|
||||
{ 11, SDL_EVENT_MOUSE_BUTTON_UP, 0, 320.0f, 240.0f }, /* -> Options screen */
|
||||
{ 40, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */
|
||||
{ 50, SDL_EVENT_KEY_DOWN, SDLK_UP, 0.0f, 0.0f }, /* select Start */
|
||||
{ 60, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f }, /* -> play screen */
|
||||
{ 80, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* open the dialog */
|
||||
{ 120, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* close it again */
|
||||
{ 140, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */
|
||||
{ 150, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Options */
|
||||
{ 160, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Quit */
|
||||
{ 170, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f } /* and out */
|
||||
};
|
||||
|
||||
#define UIDEMO_SCRIPT_STEPS (sizeof(UIDEMO_SCRIPT) / sizeof(UIDEMO_SCRIPT[0]))
|
||||
|
||||
static long frame_limit = 0;
|
||||
/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
|
||||
static char *shotpath = NULL;
|
||||
static long shotframe = 0;
|
||||
static bool demo = false;
|
||||
static bool running = true;
|
||||
static int exitstatus = 0;
|
||||
static bool lowfps_warned = false;
|
||||
|
||||
/** @brief Which screen the frame declares. */
|
||||
static uidemo_State state = UIDEMO_STATE_TITLE;
|
||||
|
||||
/**
|
||||
* @brief The title menu. Caller-owned state, zero machinery: this struct and
|
||||
* the akgl_ui_menu call each frame are the whole main menu.
|
||||
*/
|
||||
static akgl_UiMenu title_menu = {
|
||||
.id = "title",
|
||||
.items = { "Start", "Options", "Quit" },
|
||||
.count = 3,
|
||||
};
|
||||
|
||||
/** @brief The options screen's two settings. What the toggles flip. */
|
||||
static bool opt_music = true;
|
||||
static bool opt_sound = true;
|
||||
|
||||
/** @brief The play screen's HUD numbers. The score ticks so the label visibly updates. */
|
||||
static int score = 0;
|
||||
static int lives = 3;
|
||||
/** @brief Whether the play screen's dialog is up. Space flips it. */
|
||||
static bool dialog_open = false;
|
||||
|
||||
/**
|
||||
* @brief A left click arrived this frame, wherever it landed.
|
||||
*
|
||||
* The raw CLAY() options screen needs a press edge to pair with
|
||||
* Clay_Hovered(), and the application is the right owner of it: it sees
|
||||
* every event before the UI does. Set in route_event, cleared at the end of
|
||||
* each frame. The widget helpers keep their own edge internally -- this one
|
||||
* exists precisely because the options screen does not use them.
|
||||
*/
|
||||
static bool clicked = false;
|
||||
|
||||
/** @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. */
|
||||
static void lowfps_quiet(void)
|
||||
{
|
||||
if ( lowfps_warned == false ) {
|
||||
lowfps_warned = true;
|
||||
SDL_Log("Frame rate is under 30 and this demo does nothing about it");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read `--frames N`, `--demo` and the screenshot flags off the command line.
|
||||
*
|
||||
* @param argc Argument count, from `main`.
|
||||
* @param argv Argument vector, from `main`. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p argv is `NULL`.
|
||||
* @throws AKERR_VALUE If a flag needing an argument is last, or its argument
|
||||
* is not a number.
|
||||
*/
|
||||
static akerr_ErrorContext *parse_args(int argc, char *argv[])
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
int number = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
|
||||
|
||||
for ( i = 1; i < argc; i++ ) {
|
||||
if ( strcmp(argv[i], "--demo") == 0 ) {
|
||||
demo = true;
|
||||
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
||||
if ( (i + 1) >= argc ) {
|
||||
FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
|
||||
}
|
||||
i += 1;
|
||||
PASS(errctx, aksl_atoi(argv[i], &number));
|
||||
frame_limit = number;
|
||||
} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
|
||||
if ( (i + 1) >= argc ) {
|
||||
FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot needs a path");
|
||||
}
|
||||
i += 1;
|
||||
shotpath = argv[i];
|
||||
} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
|
||||
if ( (i + 1) >= argc ) {
|
||||
FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot-frame needs a number");
|
||||
}
|
||||
i += 1;
|
||||
PASS(errctx, aksl_atoi(argv[i], &number));
|
||||
shotframe = number;
|
||||
} else {
|
||||
FAIL_RETURN(errctx, AKERR_VALUE,
|
||||
"usage: uidemo [--frames N] [--demo]"
|
||||
" [--screenshot PATH] [--screenshot-frame N]");
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Bring the library and the UI subsystem up.
|
||||
*
|
||||
* The UI half is three calls: load a font, akgl_ui_init at the screen size,
|
||||
* and register the font for a clay fontId. The id comes back 0 because it is
|
||||
* the first registered, which is what the widgets' default style uses -- so
|
||||
* nothing here ever mentions a fontId again.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *startup(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
uint16_t fontid = 0;
|
||||
|
||||
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl UI 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), "net.aklabs.libakgl.examples.uidemo", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
PASS(errctx, akgl_game_init());
|
||||
akgl_game.lowfpsfunc = &lowfps_quiet;
|
||||
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", UIDEMO_SCREEN_WIDTH));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", UIDEMO_SCREEN_HEIGHT));
|
||||
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
|
||||
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));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Route one event: the UI first, then whatever the current screen wants.
|
||||
*
|
||||
* This is the call-order contract from akgl/ui.h in the flesh. The UI gets
|
||||
* first refusal; a consumed event goes no further, which is what keeps a
|
||||
* click on a menu from also being a click in the game. Only then does the
|
||||
* current screen read the keyboard -- and *that* routing is the whole focus
|
||||
* model: the title menu hears keys because this function sends them there
|
||||
* while the title screen is up, not because anything owns "focus".
|
||||
*
|
||||
* @param event The event to route. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *route_event(SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool consumed = false;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
|
||||
if ( event->type == SDL_EVENT_QUIT ) {
|
||||
running = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
// The options screen's press edge, recorded before the UI can consume
|
||||
// the event -- a click on a toggle row is *always* consumed (the row is
|
||||
// UI), so waiting until afterwards would record nothing.
|
||||
if ( (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN)
|
||||
&& (event->button.button == SDL_BUTTON_LEFT) ) {
|
||||
clicked = true;
|
||||
}
|
||||
|
||||
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;
|
||||
case UIDEMO_STATE_OPTIONS:
|
||||
if ( (event->type == SDL_EVENT_KEY_DOWN) && (event->key.key == SDLK_ESCAPE) ) {
|
||||
state = UIDEMO_STATE_TITLE;
|
||||
}
|
||||
break;
|
||||
case UIDEMO_STATE_PLAY:
|
||||
if ( event->type == SDL_EVENT_KEY_DOWN ) {
|
||||
if ( event->key.key == SDLK_ESCAPE ) {
|
||||
state = UIDEMO_STATE_TITLE;
|
||||
} else if ( event->key.key == SDLK_SPACE ) {
|
||||
dialog_open = !dialog_open;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Feed the scripted events due on this frame through the real routing.
|
||||
*
|
||||
* Synthesized SDL_Events, not direct state changes: the demo run proves the
|
||||
* event chain -- consumed and not, menu and screen -- because it travels it.
|
||||
*
|
||||
* @param frameno The frame about to be drawn.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *demo_step(long frameno)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Event event;
|
||||
size_t i = 0;
|
||||
|
||||
for ( i = 0; i < UIDEMO_SCRIPT_STEPS; i++ ) {
|
||||
if ( UIDEMO_SCRIPT[i].frame != frameno ) {
|
||||
continue;
|
||||
}
|
||||
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
|
||||
event.type = UIDEMO_SCRIPT[i].type;
|
||||
switch ( UIDEMO_SCRIPT[i].type ) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
event.key.key = UIDEMO_SCRIPT[i].key;
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
event.motion.x = UIDEMO_SCRIPT[i].x;
|
||||
event.motion.y = UIDEMO_SCRIPT[i].y;
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
event.button.button = SDL_BUTTON_LEFT;
|
||||
event.button.x = UIDEMO_SCRIPT[i].x;
|
||||
event.button.y = UIDEMO_SCRIPT[i].y;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
PASS(errctx, route_event(&event));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Declare the title screen: one widget call.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *declare_title(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_ui_label("heading", "libakgl UI demo", AKGL_UI_ANCHOR_TOP_LEFT, NULL));
|
||||
PASS(errctx, akgl_ui_menu(&title_menu));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Declare the options screen in raw CLAY(), no widgets anywhere.
|
||||
*
|
||||
* This screen exists to show the other way in: a centred panel, a column of
|
||||
* rows, hover highlighting via Clay_Hovered() *inside* the declaration, and
|
||||
* clicks paired with the application's own press edge. Everything the widgets
|
||||
* do is done with these same pieces; a screen the widgets cannot express is
|
||||
* written like this rather than waiting for a widget to exist.
|
||||
*
|
||||
* The row labels live in statics because clay borrows the pointer until
|
||||
* frame_end -- a local buffer here would be dangling by the time the text is
|
||||
* drawn.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *declare_options(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
static char musicrow[32];
|
||||
static char soundrow[32];
|
||||
int count = 0;
|
||||
|
||||
PASS(errctx, aksl_snprintf(&count, musicrow, sizeof(musicrow), "Music: %s", opt_music ? "ON" : "OFF"));
|
||||
PASS(errctx, aksl_snprintf(&count, soundrow, sizeof(soundrow), "Sound: %s", opt_sound ? "ON" : "OFF"));
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_ID("options"),
|
||||
.layout = {
|
||||
.padding = { 16, 16, 16, 16 },
|
||||
.childGap = 8,
|
||||
.layoutDirection = CLAY_TOP_TO_BOTTOM
|
||||
},
|
||||
.backgroundColor = { 24, 20, 37, 235 },
|
||||
.border = { .color = { 240, 236, 214, 255 }, .width = { 1, 1, 1, 1, 0 } },
|
||||
.floating = {
|
||||
.attachPoints = {
|
||||
.element = CLAY_ATTACH_POINT_CENTER_CENTER,
|
||||
.parent = CLAY_ATTACH_POINT_CENTER_CENTER
|
||||
},
|
||||
.attachTo = CLAY_ATTACH_TO_ROOT
|
||||
}
|
||||
}) {
|
||||
CLAY_TEXT(CLAY_STRING("OPTIONS"), CLAY_TEXT_CONFIG({
|
||||
.textColor = { 240, 236, 214, 255 },
|
||||
.fontId = 0
|
||||
}));
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_ID("options-sound"),
|
||||
.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_sound = !opt_sound;
|
||||
}
|
||||
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(soundrow), .chars = soundrow }),
|
||||
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
|
||||
}
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_ID("options-back"),
|
||||
.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 ) {
|
||||
state = UIDEMO_STATE_TITLE;
|
||||
}
|
||||
CLAY_TEXT(CLAY_STRING("Back (Esc)"), CLAY_TEXT_CONFIG({
|
||||
.textColor = { 240, 236, 214, 255 },
|
||||
.fontId = 0
|
||||
}));
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Declare the play screen's HUD: two labels, and the dialog while it is up.
|
||||
*
|
||||
* The score buffer is static for the same borrowed-until-frame_end reason as
|
||||
* the options rows. Note what is *not* here: no visible flag, no draw call,
|
||||
* no geometry. The dialog is open because this frame declares it.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *declare_play(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
static char scoretext[32];
|
||||
static char livestext[32];
|
||||
int count = 0;
|
||||
|
||||
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));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Read the render target back and write it out as a PNG, for the chapter figure. */
|
||||
static akerr_ErrorContext *save_screenshot(char *path)
|
||||
{
|
||||
SDL_Surface *shot = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError());
|
||||
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL,
|
||||
"IMG_SavePNG(%s): %s", path, SDL_GetError());
|
||||
} CLEANUP {
|
||||
SDL_DestroySurface(shot);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SDL_Log("Wrote %s", path);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief One frame: events, the world stand-in, the UI bracket, present.
|
||||
*
|
||||
* The shape to compare with the JRPG's frame(): where that program calls
|
||||
* akgl_game_update between frame_start and frame_end, this one paints a
|
||||
* checkerboard -- and the UI bracket sits in the same overlay slot the
|
||||
* hand-rolled textbox draw did.
|
||||
*
|
||||
* @param frameno The frame number, for the demo script.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *frame(long frameno)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Event event;
|
||||
|
||||
while ( SDL_PollEvent(&event) ) {
|
||||
PASS(errctx, route_event(&event));
|
||||
}
|
||||
if ( demo ) {
|
||||
PASS(errctx, demo_step(frameno));
|
||||
}
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
if ( state == UIDEMO_STATE_PLAY ) {
|
||||
// Where a game would call akgl_game_update. The score ticking is the
|
||||
// stand-in for play, so the HUD label visibly earns its redraw.
|
||||
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));
|
||||
|
||||
if ( (shotpath != NULL) && (frameno == shotframe) ) {
|
||||
PASS(errctx, save_screenshot(shotpath));
|
||||
}
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
|
||||
// The press edge lives exactly one frame: the declarations above have
|
||||
// seen it, so the next frame starts clean.
|
||||
clicked = false;
|
||||
|
||||
// Menu activation is read after the frame, so it catches both routes in:
|
||||
// Return through akgl_ui_menu_handle_event before the declarations, and a
|
||||
// click through the declaration itself.
|
||||
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;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give back what has to be given back, in the order that works.
|
||||
*
|
||||
* akgl_ui_shutdown first because it is the newest thing up, then the fonts
|
||||
* **before** SDL_Quit -- the registry the fonts live in is an SDL property
|
||||
* set, and SDL_Quit destroys it with them inside. The pools and the clay
|
||||
* arena are static storage; there is nothing to free.
|
||||
*/
|
||||
static void teardown(void)
|
||||
{
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
if ( akgl_window != NULL ) {
|
||||
SDL_DestroyWindow(akgl_window);
|
||||
akgl_window = NULL;
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
long frameno = 0;
|
||||
uint64_t started = 0;
|
||||
uint64_t spent = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, parse_args(argc, argv));
|
||||
CATCH(errctx, startup());
|
||||
|
||||
// The loop is the last thing in this ATTEMPT block on purpose: CATCH
|
||||
// reports failure by `break`ing out of the loop, which falls straight
|
||||
// into CLEANUP. See examples/jrpg/jrpg.c for the long form of this
|
||||
// note.
|
||||
while ( running ) {
|
||||
started = SDL_GetTicks();
|
||||
CATCH(errctx, frame(frameno));
|
||||
frameno += 1;
|
||||
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
|
||||
running = false;
|
||||
}
|
||||
if ( demo == false ) {
|
||||
spent = SDL_GetTicks() - started;
|
||||
if ( spent < UIDEMO_FRAME_BUDGET_MS ) {
|
||||
SDL_Delay((uint32_t)(UIDEMO_FRAME_BUDGET_MS - spent));
|
||||
}
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
teardown();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the UI demo could not finish");
|
||||
exitstatus = 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
// Enough for the smoke run to be read rather than merely passed: the
|
||||
// script quits from the title menu, so a run that finished its tour says
|
||||
// so here.
|
||||
printf("uidemo: %ld frames, score %d, music %s, sound %s\n",
|
||||
frameno, score, opt_music ? "on" : "off", opt_sound ? "on" : "off");
|
||||
return exitstatus;
|
||||
}
|
||||
58
examples/uidemo/uidemo.h
Normal file
58
examples/uidemo/uidemo.h
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file uidemo.h
|
||||
* @brief Shared constants and types for the UI demo.
|
||||
*
|
||||
* There is deliberately little here: the demo is one translation unit, and
|
||||
* this header exists so the chapter can quote the constants and the script
|
||||
* type without quoting the code around them.
|
||||
*/
|
||||
|
||||
#ifndef _UIDEMO_H_
|
||||
#define _UIDEMO_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
/** @brief Window and layout width, in pixels. */
|
||||
#define UIDEMO_SCREEN_WIDTH "640"
|
||||
/** @brief Window and layout height, in pixels. */
|
||||
#define UIDEMO_SCREEN_HEIGHT "480"
|
||||
/** @brief The same width as a number, for akgl_ui_init. */
|
||||
#define UIDEMO_WIDTH 640
|
||||
/** @brief The same height as a number. */
|
||||
#define UIDEMO_HEIGHT 480
|
||||
|
||||
/** @brief Registry name the demo's one font is loaded under. */
|
||||
#define UIDEMO_FONT_NAME "uidemo"
|
||||
/** @brief Point size the font is loaded at. The size is baked into the handle. */
|
||||
#define UIDEMO_FONT_SIZE 16
|
||||
|
||||
/**
|
||||
* @brief Which screen the demo is on.
|
||||
*
|
||||
* Three states, three ways of building an interface: the title screen is the
|
||||
* menu *widget*, the options screen is raw `CLAY()` layout, and the play
|
||||
* screen is the label and dialog widgets over a moving world stand-in.
|
||||
*/
|
||||
typedef enum {
|
||||
UIDEMO_STATE_TITLE, /**< The akgl_UiMenu title menu. */
|
||||
UIDEMO_STATE_OPTIONS, /**< The hand-written CLAY() options panel. */
|
||||
UIDEMO_STATE_PLAY /**< HUD labels and a dialog over the "game". */
|
||||
} uidemo_State;
|
||||
|
||||
/**
|
||||
* @brief One scripted input in a `--demo` run.
|
||||
*
|
||||
* Key steps carry a keycode; mouse steps carry a position. Every step is
|
||||
* synthesized as a real SDL_Event and pushed through the same routing the
|
||||
* interactive loop uses, so the demo exercises the event chain rather than
|
||||
* poking state behind its back.
|
||||
*/
|
||||
typedef struct {
|
||||
long frame; /**< The frame this step fires on. */
|
||||
uint32_t type; /**< SDL_EVENT_KEY_DOWN, _MOUSE_MOTION, _MOUSE_BUTTON_DOWN or _MOUSE_BUTTON_UP. */
|
||||
SDL_Keycode key; /**< For key steps. */
|
||||
float x; /**< For mouse steps. */
|
||||
float y; /**< For mouse steps. */
|
||||
} uidemo_ScriptStep;
|
||||
|
||||
#endif // _UIDEMO_H_
|
||||
@@ -74,7 +74,7 @@ struct akgl_CollisionWorld;
|
||||
*
|
||||
* Sub-stepping bounds how far an actor moves between collision tests, which
|
||||
* stops anything moving at ordinary speeds from passing through a wall. A
|
||||
* projectile is not moving at ordinary speeds. See `TODO.md`.
|
||||
* projectile is not moving at ordinary speeds. See issue #56.
|
||||
*/
|
||||
#define AKGL_COLLISION_FLAG_BULLET 0x00000008u
|
||||
|
||||
@@ -557,8 +557,8 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_sync_actors(akgl_CollisionWorl
|
||||
* Follows the convention every pool here uses: akgl_heap_next_collision_proxy
|
||||
* finds a free slot and does **not** claim it, and this takes the reference.
|
||||
* Write the two adjacent, because until the reference is taken the slot is still
|
||||
* free and the next acquire hands out the same pointer. That is `TODO.md` "Known
|
||||
* and still open" item 8, it applies to four of the five existing pools, and
|
||||
* free and the next acquire hands out the same pointer. That asymmetry is issue
|
||||
* #14, it applies to four of the five existing pools, and
|
||||
* this one does not depart from it -- a sixth convention would be worse than the
|
||||
* defect.
|
||||
*
|
||||
|
||||
@@ -231,7 +231,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_removed(void *appstate
|
||||
*
|
||||
* @warning A **negative** @p controlmapid is not rejected -- only the upper
|
||||
* bound is checked -- and indexes before the start of
|
||||
* ::akgl_controlmaps. TODO.md, "Known and still open" item 11.
|
||||
* ::akgl_controlmaps. TODO.md, "Defects".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_pushmap(int controlmapid, akgl_Control *control);
|
||||
|
||||
|
||||
@@ -36,6 +36,25 @@
|
||||
*/
|
||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
||||
|
||||
/**
|
||||
* @brief Triangles each corner of akgl_draw_filled_rounded_rect() is built from.
|
||||
*
|
||||
* A quarter circle approximated by this many segments is visually round at any
|
||||
* radius a UI panel plausibly uses; the vertex arrays are fixed at this size,
|
||||
* so it is a compile-time cost, not a per-call one.
|
||||
*/
|
||||
#define AKGL_DRAW_ROUNDED_RECT_SEGMENTS 16
|
||||
|
||||
/**
|
||||
* @brief Most vertices akgl_draw_arc() will spend on one arc.
|
||||
*
|
||||
* An arc is a triangle strip between its inner and outer edge, two vertices
|
||||
* per step, so half of these are positions along the curve. The step count
|
||||
* scales with the swept angle and is clamped to fit this array -- a full
|
||||
* circle gets all of them, a sliver gets a few -- rather than allocating.
|
||||
*/
|
||||
#define AKGL_DRAW_ARC_MAX_POINTS 64
|
||||
|
||||
/**
|
||||
* @brief Paint an 8x8 grey checkerboard over a region, the way an image editor shows transparency.
|
||||
*
|
||||
@@ -242,4 +261,86 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_copy_region(akgl_RenderBackend *sel
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y);
|
||||
|
||||
/**
|
||||
* @brief Fill a rectangle whose corners are rounded off with quarter circles.
|
||||
*
|
||||
* The body is three axis-aligned fills and the corners are triangle fans
|
||||
* through `SDL_RenderGeometry` -- #AKGL_DRAW_ROUNDED_RECT_SEGMENTS triangles
|
||||
* each -- so nothing here is anti-aliased, matching every other primitive in
|
||||
* this header.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param rect The rectangle, in render-target pixels. Required. A zero or
|
||||
* negative width or height fills nothing and is not reported.
|
||||
* @param radius Corner radius in pixels. Zero or negative falls through to
|
||||
* akgl_draw_filled_rect() -- a square corner is not an error.
|
||||
* Larger than half the shorter side is clamped to it, which at
|
||||
* the limit turns a square into a circle rather than folding the
|
||||
* corners over each other.
|
||||
* @param color Colour to fill with, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is
|
||||
* `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if a band
|
||||
* or corner cannot be drawn. A failure partway leaves the shape
|
||||
* partially drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Stroke a circular arc of a given thickness between two angles.
|
||||
*
|
||||
* Angles are in degrees, 0 pointing along +x (three o'clock) and increasing
|
||||
* clockwise on screen -- the same convention as the rotation argument the
|
||||
* render backend's `draw_texture` takes. The stroke runs from the arc's outer
|
||||
* edge at @p radius inward by @p thickness, drawn as one triangle strip, not
|
||||
* anti-aliased.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x Horizontal position of the arc's centre.
|
||||
* @param y Vertical position of the arc's centre.
|
||||
* @param radius Outer radius in pixels. Must be positive.
|
||||
* @param start_deg Angle the arc starts at.
|
||||
* @param end_deg Angle the arc ends at. At or below @p start_deg nothing is
|
||||
* drawn and nothing is reported, matching the zero-area
|
||||
* rectangle fills above. Neither angle is normalized, so an
|
||||
* arc crossing three o'clock is 350 to 370, not 350 to 10; a
|
||||
* sweep beyond 360 degrees is clamped to one full turn.
|
||||
* @param thickness Stroke width in pixels, measured inward from @p radius.
|
||||
* Must be positive; at or above @p radius the arc becomes a
|
||||
* filled wedge to the centre.
|
||||
* @param color Colour to stroke in, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p radius or @p thickness is not positive. The
|
||||
* message reports the offending value.
|
||||
* @throws AKGL_ERR_SDL If the strip cannot be drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Restrict every subsequent draw to a rectangle of the render target.
|
||||
*
|
||||
* Everything drawn through @p self after this -- primitives here, textures,
|
||||
* text -- is clipped to @p rect until the restriction is cleared. This is the
|
||||
* only entry point in this header where a `NULL` rectangle is legal rather
|
||||
* than an error: it means "clear the restriction", matching what
|
||||
* `SDL_SetRenderClipRect` does with it, and there is no other value that
|
||||
* could.
|
||||
*
|
||||
* The clip is renderer state, not saved and restored around anything: a
|
||||
* caller that sets it owns putting it back, the way a `CLEANUP` block puts
|
||||
* back a draw colour. Leaving it set clips the next frame's world too.
|
||||
*
|
||||
* @param self The backend to clip. Required, along with its `sdl_renderer`.
|
||||
* @param rect Rectangle to clip to, in render-target pixels -- integer, since
|
||||
* a clip boundary is a pixel boundary. `NULL` clears the clip.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the clip cannot be set or cleared.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect);
|
||||
|
||||
#endif //_AKGL_DRAW_H_
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
// AKERR_OUTOFBOUNDS on truncation instead. That is the better answer: the
|
||||
// compiler was right both times, and silencing it left the truncation
|
||||
// happening and unreported. Kept because they are public and a consumer may
|
||||
// have them; see TODO.md.
|
||||
// have them; see issue #20.
|
||||
#define DISABLE_GCC_WARNING_FORMAT_TRUNCATION \
|
||||
_Pragma("GCC diagnostic push") \
|
||||
_Pragma("GCC diagnostic ignored \"-Wformat-truncation\"")
|
||||
@@ -76,11 +76,12 @@
|
||||
#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 */
|
||||
|
||||
// One past the last libakgl status. The reservation is all-or-nothing -- a
|
||||
// subset or superset of an existing one is refused -- so this must stay one
|
||||
// past the highest code above.
|
||||
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6)
|
||||
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 7)
|
||||
#define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE)
|
||||
|
||||
/**
|
||||
|
||||
@@ -217,7 +217,7 @@ void akgl_game_update_fps(void);
|
||||
*
|
||||
* @note This is a partial implementation: the name tables are written but the
|
||||
* objects themselves are not, so the file is not yet enough to restore a
|
||||
* session. See also TODO.md, "Known and still open" item 7 -- the writer
|
||||
* session. See also TODO.md, "Defects" -- the writer
|
||||
* and the reader disagree on the name-field widths, so a save with any
|
||||
* registered spritesheet cannot be read back.
|
||||
*/
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
* reference for you, and the other four do not -- their caller is
|
||||
* expected to take it, which in practice the `*_initialize` function
|
||||
* does. Until one is taken the slot is still free and the next
|
||||
* allocation hands out the same pointer. TODO.md, "Known and still
|
||||
* open" item 8.
|
||||
* allocation hands out the same pointer. Whether to make them symmetric
|
||||
* or document the split is issue #14.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_HEAP_H_
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* no-op akgl_set_property and an akgl_get_property that always hands
|
||||
* back the caller's default, which in turn means akgl_render_2d_init
|
||||
* and akgl_physics_init_arcade quietly ignore their configuration.
|
||||
* TODO.md, "Known and still open" item 3.
|
||||
* TODO.md, "Formatting and hygiene".
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_REGISTRY_H_
|
||||
@@ -170,7 +170,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_load_properties(char *fname);
|
||||
* @note The name table it reads disagrees with actor.h in two places: bits 11
|
||||
* and 12 are named `UNDEFINED_11`/`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.
|
||||
* TODO.md, "AKGL_ACTOR_STATE_STRING_NAMES".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_actor_state_strings(void);
|
||||
/**
|
||||
|
||||
@@ -123,8 +123,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_unloadallfonts(void);
|
||||
* @note The empty string is **refused**, not drawn as nothing: SDL_ttf reports
|
||||
* "Text has zero width" and this passes that on as `AKERR_NULLPOINTER`.
|
||||
* akgl_text_measure() accepts it, so the two disagree. A caller drawing a
|
||||
* line of text that may be empty has to check for it. TODO.md, "Known and
|
||||
* still open".
|
||||
* line of text that may be empty has to check for it. Stale: the two agree
|
||||
* as of 0.5.0 -- an empty string returns success without rasterizing. See
|
||||
* TODO.md, "Found while rewriting the Doxygen comments", and issue #63.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_rendertextat(TTF_Font *font, char *text, SDL_Color color, int wraplength, int x, int y);
|
||||
/**
|
||||
|
||||
@@ -484,7 +484,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_tilesets(akgl_Tilemap *dest
|
||||
* than `layers[i].texture`, so tileset textures are destroyed twice and
|
||||
* image-layer textures never. Nothing is set to `NULL` either, so a
|
||||
* second call is a use-after-free. It also does not release the actors
|
||||
* the map's object layers created. TODO.md, "Known and still open"
|
||||
* the map's object layers created. TODO.md, "Defects"
|
||||
* item 2.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_release(akgl_Tilemap *dest);
|
||||
|
||||
552
include/akgl/ui.h
Normal file
552
include/akgl/ui.h
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* @file ui.h
|
||||
* @brief Screen-space user interface layouts, built on the clay layout engine.
|
||||
*
|
||||
* This subsystem exists because every game eventually wants a dialog box, a
|
||||
* HUD counter, or a menu, and hand-rolling them out of rectangles and
|
||||
* akgl_text_rendertextat() is tedious the first time and unmaintainable by the
|
||||
* fourth (`examples/jrpg/textbox.c` is the honest record of the first time).
|
||||
* clay computes the layout; libakgl owns everything that has to touch SDL or
|
||||
* the rest of the library -- the arena clay allocates from, the text
|
||||
* measurement it calls back into, the render commands it emits, and the
|
||||
* pointer state it is fed.
|
||||
*
|
||||
* There are two ways in, and they compose:
|
||||
*
|
||||
* - Write clay's declarative `CLAY({...}) { ... }` blocks yourself between
|
||||
* akgl_ui_frame_begin() and akgl_ui_frame_end(). The whole of clay's API
|
||||
* is available -- this header includes `<clay.h>` on purpose, the same way
|
||||
* SDL types appear undisguised elsewhere in this library.
|
||||
* - Call the widget helpers, which emit those blocks for you.
|
||||
*
|
||||
* The subsystem is optional the way collision is optional: always compiled in,
|
||||
* inert until akgl_ui_init() runs, and costing nothing but static storage
|
||||
* before that.
|
||||
*
|
||||
* @warning libakgl compiles clay into `libakgl.so` (the `CLAY_IMPLEMENTATION`
|
||||
* translation unit is `src/ui_clay.c`) and exports its symbols,
|
||||
* because the `CLAY()` macros in *your* translation units expand to
|
||||
* calls into them. Do not define `CLAY_IMPLEMENTATION` yourself and
|
||||
* do not link another copy of clay: two definitions of the same
|
||||
* symbols and the loader picks one silently, which is the exact
|
||||
* defect AGENTS.md records against an exported `renderer`.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_UI_H_
|
||||
#define _AKGL_UI_H_
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <clay.h>
|
||||
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
/**
|
||||
* @brief Most elements one layout may declare.
|
||||
*
|
||||
* Passed to `Clay_SetMaxElementCount` before the arena is sized, so it is a
|
||||
* compile-time ceiling in the same sense as the `AKGL_MAX_HEAP_*` family:
|
||||
* define it before including this header to raise it, and see
|
||||
* #AKGL_UI_ARENA_BYTES, which must grow with it.
|
||||
*/
|
||||
#ifndef AKGL_UI_MAX_ELEMENTS
|
||||
#define AKGL_UI_MAX_ELEMENTS 1024
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Most whitespace-separated words clay's text measurement cache holds.
|
||||
*
|
||||
* Clay measures text one word at a time and caches the result; a cache miss
|
||||
* costs one call back into SDL_ttf. This bounds the cache, not the text -- a
|
||||
* layout with more distinct words than this still renders, it just measures
|
||||
* some of them every frame and clay reports the overflow through the error
|
||||
* handler.
|
||||
*/
|
||||
#ifndef AKGL_UI_MAX_MEASURE_WORDS
|
||||
#define AKGL_UI_MAX_MEASURE_WORDS 4096
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Bytes of static storage clay lays its internal structures out in.
|
||||
*
|
||||
* The arena is a fixed array in `src/ui.c` -- libakgl does not call `malloc`
|
||||
* at runtime, and clay never allocates behind the arena's back. What clay
|
||||
* needs is a function of #AKGL_UI_MAX_ELEMENTS and #AKGL_UI_MAX_MEASURE_WORDS
|
||||
* and is only computable at runtime, so akgl_ui_init() checks
|
||||
* `Clay_MinMemorySize()` against this and refuses with both numbers in the
|
||||
* message when it does not fit. A too-small arena is a loud failure at
|
||||
* startup, never a corruption at frame forty thousand.
|
||||
*/
|
||||
#ifndef AKGL_UI_ARENA_BYTES
|
||||
#define AKGL_UI_ARENA_BYTES (1024 * 1024)
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Most fonts akgl_ui_font_register() will map to clay fontIds.
|
||||
*
|
||||
* clay identifies a font as a `uint16_t`; libakgl identifies one as a name in
|
||||
* #AKGL_REGISTRY_FONT. The table joining them is this many fixed slots. A
|
||||
* size is baked into each registered font handle, so a game using one face at
|
||||
* three sizes is using three slots.
|
||||
*/
|
||||
#ifndef AKGL_UI_MAX_FONTS
|
||||
#define AKGL_UI_MAX_FONTS 8
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Bytes each fontId table slot holds for its registry name, terminator included.
|
||||
*/
|
||||
#ifndef AKGL_UI_FONT_NAME_LENGTH
|
||||
#define AKGL_UI_FONT_NAME_LENGTH 64
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Bytes of scratch one text render command may occupy, terminator included.
|
||||
*
|
||||
* clay hands text to a renderer as a length-and-pointer slice, one command
|
||||
* per wrapped line; akgl_text_rendertextat() takes a C string, so each line
|
||||
* is copied through a bounded buffer on the way. A line longer than this
|
||||
* fails the frame loudly rather than drawing a truncation that reads as the
|
||||
* author's text.
|
||||
*/
|
||||
#ifndef AKGL_UI_MAX_TEXT_BYTES
|
||||
#define AKGL_UI_MAX_TEXT_BYTES 1024
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Most entries one akgl_UiMenu may carry.
|
||||
*
|
||||
* A menu deeper than this wants scrolling and sub-screens, which is `CLAY()`
|
||||
* territory rather than a widget's.
|
||||
*/
|
||||
#ifndef AKGL_UI_MENU_MAX_ITEMS
|
||||
#define AKGL_UI_MENU_MAX_ITEMS 16
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Height of the akgl_ui_dialog() panel, in pixels.
|
||||
*
|
||||
* Two lines of 12pt monospace plus padding -- the JRPG textbox's height, kept
|
||||
* so the widget's output is comparable with the hand-rolled panel it
|
||||
* replaces.
|
||||
*/
|
||||
#ifndef AKGL_UI_DIALOG_HEIGHT
|
||||
#define AKGL_UI_DIALOG_HEIGHT 56
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief One look, shared by every widget: colours, spacing, corners, font.
|
||||
*
|
||||
* Passing `NULL` wherever a style is taken means the library default, which
|
||||
* is deliberately the JRPG textbox's palette -- near-black fill (24,20,37,235)
|
||||
* with parchment edge and ink (240,236,214,255), 8 pixels of padding, square
|
||||
* corners, fontId 0. A caller wanting one change copies those values and
|
||||
* changes one; there is no per-field "zero means default" magic, because a
|
||||
* transparent fill and a zero radius are things a style legitimately says.
|
||||
*/
|
||||
typedef struct {
|
||||
SDL_Color fill; /**< Panel background. */
|
||||
SDL_Color edge; /**< Border colour. Drawn one pixel wide where a widget has a border. */
|
||||
SDL_Color ink; /**< Text colour. */
|
||||
float32_t padding; /**< Pixels between a panel's edge and its content, and between a widget and the screen edge it anchors to. */
|
||||
float32_t corner_radius; /**< Corner rounding in pixels. 0 is square. */
|
||||
uint16_t fontid; /**< The clay fontId text renders with, from akgl_ui_font_register(). */
|
||||
} akgl_UiStyle;
|
||||
|
||||
/**
|
||||
* @brief Where on the screen akgl_ui_label() pins itself.
|
||||
*/
|
||||
typedef enum {
|
||||
AKGL_UI_ANCHOR_TOP_LEFT, /**< Inset by the style's padding from the top-left corner. */
|
||||
AKGL_UI_ANCHOR_TOP_RIGHT, /**< Inset from the top-right corner. */
|
||||
AKGL_UI_ANCHOR_BOTTOM_LEFT, /**< Inset from the bottom-left corner. */
|
||||
AKGL_UI_ANCHOR_BOTTOM_RIGHT, /**< Inset from the bottom-right corner. */
|
||||
AKGL_UI_ANCHOR_CENTER /**< Dead centre, no inset. */
|
||||
} akgl_UiAnchor;
|
||||
|
||||
/**
|
||||
* @brief One vertical menu: its entries, its selection, and whether it fired.
|
||||
*
|
||||
* Caller-owned, the way the JRPG's textbox state is a static in the game --
|
||||
* there is no heap layer behind this because it owns no texture, no font and
|
||||
* no registry entry; it is a struct of borrowed pointers and two integers.
|
||||
* Declare it static, fill in `id`, `items` and `count`, and leave the rest
|
||||
* zeroed.
|
||||
*
|
||||
* `selected` is yours to read and the menu's to move: keyboard and gamepad
|
||||
* move it through akgl_ui_menu_handle_event(), and the mouse moves it by
|
||||
* hovering while akgl_ui_menu() declares the rows. `activated` latches true
|
||||
* on Return, gamepad South, or a click on a row; the caller acts on it and
|
||||
* sets it back to false -- the menu never clears it, so an unread activation
|
||||
* is not lost between frames.
|
||||
*/
|
||||
typedef struct {
|
||||
char *id; /**< Element id prefix, unique per menu. Required. Borrowed. */
|
||||
char *items[AKGL_UI_MENU_MAX_ITEMS]; /**< The entries, top to bottom. Borrowed; they must outlive the frame. */
|
||||
int32_t count; /**< How many of @c items are set. 1 to #AKGL_UI_MENU_MAX_ITEMS. */
|
||||
int32_t selected; /**< Index of the highlighted entry. In and out. */
|
||||
bool activated; /**< Out: the selected entry was confirmed. Caller clears. */
|
||||
akgl_UiStyle *style; /**< Look to draw with, or `NULL` for the library default. */
|
||||
} akgl_UiMenu;
|
||||
|
||||
/**
|
||||
* @brief Bring the UI subsystem up.
|
||||
*
|
||||
* Bounds clay to the `AKGL_UI_*` ceilings, checks that the arena is big
|
||||
* enough for them, and hands it to `Clay_Initialize` with an error handler
|
||||
* that routes clay's layout-time complaints into the libakgl error protocol
|
||||
* (they surface from akgl_ui_frame_end(), which is the first call after they
|
||||
* can happen).
|
||||
*
|
||||
* The layout dimensions are taken as parameters rather than read from the
|
||||
* camera or the window, so a headless program -- a test, a layout tool -- can
|
||||
* bring the UI up without a renderer existing at all. A windowed game passes
|
||||
* its screen size and lets akgl_ui_handle_event() track resizes from there.
|
||||
*
|
||||
* @param width Layout width in pixels. Must be positive.
|
||||
* @param height Layout height in pixels. Must be positive.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive.
|
||||
* @throws AKGL_ERR_UI If the subsystem is already initialized -- call
|
||||
* akgl_ui_shutdown() first; if the arena cannot hold clay's
|
||||
* structures at the current ceilings -- the message carries the bytes
|
||||
* required and the bytes available, so the operator knows what to
|
||||
* raise #AKGL_UI_ARENA_BYTES to; or if `Clay_Initialize` refuses,
|
||||
* which the preceding checks should make unreachable.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_init(int width, int height);
|
||||
|
||||
/**
|
||||
* @brief Return the UI subsystem to the inert state.
|
||||
*
|
||||
* clay has no teardown of its own -- everything it built lives in the arena,
|
||||
* and the next akgl_ui_init() lays a new context out over it. This forgets
|
||||
* the context and clears the stashed error state, and is idempotent, because
|
||||
* shutdown paths run after partial startups.
|
||||
*
|
||||
* @warning Anything that captured clay state -- and any `CLAY()` block that
|
||||
* runs after this -- is left talking to a context this subsystem has
|
||||
* disowned. Stop declaring layouts before shutting down, the same
|
||||
* way fonts stop being drawn before akgl_text_unloadallfonts().
|
||||
*
|
||||
* @return `NULL`. Shutting down an uninitialized subsystem is success.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_shutdown(void);
|
||||
|
||||
/**
|
||||
* @brief Tell the layout engine the render target changed size.
|
||||
*
|
||||
* akgl_ui_handle_event() calls this for `SDL_EVENT_WINDOW_RESIZED`, so a game
|
||||
* that routes its events through there never calls it directly. It is public
|
||||
* for the host that owns its own event loop.
|
||||
*
|
||||
* @param width New layout width in pixels. Must be positive.
|
||||
* @param height New layout height in pixels. Must be positive.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height);
|
||||
|
||||
/**
|
||||
* @brief Map a font-registry name to the clay fontId that text elements name it by.
|
||||
*
|
||||
* The table stores the *name* and resolves it through #AKGL_REGISTRY_FONT on
|
||||
* every use, rather than caching the `TTF_Font *` -- fonts are not reference
|
||||
* counted, so a cached handle would dangle the moment akgl_text_unloadfont()
|
||||
* took the font away, while a name honestly reports "no longer registered".
|
||||
* Registering a name that is already in the table returns its existing id
|
||||
* rather than spending a second slot.
|
||||
*
|
||||
* clay's `Clay_TextElementConfig.fontSize` is **ignored** by this subsystem:
|
||||
* a libakgl font bakes its size in at akgl_text_loadfont() time, so the size
|
||||
* a text element renders at is the size the font behind its fontId was loaded
|
||||
* at. One face at two sizes is two loads, two registrations, two ids.
|
||||
*
|
||||
* @param name Registry key the font was published under. Required, and it
|
||||
* must already be in the registry -- registering first and
|
||||
* loading later would leave a window where a fontId resolves to
|
||||
* nothing.
|
||||
* @param fontid Receives the id to put in `Clay_TextElementConfig.fontId`.
|
||||
* Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p name or @p fontid is `NULL`.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized; if no font is
|
||||
* registered under @p name; or if all #AKGL_UI_MAX_FONTS slots are
|
||||
* taken. Each message says which.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p name does not fit in a table slot --
|
||||
* #AKGL_UI_FONT_NAME_LENGTH bytes including the terminator.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_font_register(char *name, uint16_t *fontid);
|
||||
|
||||
/**
|
||||
* @brief Feed one SDL event to the UI, and learn whether the UI claimed it.
|
||||
*
|
||||
* The mouse half of the input story -- keyboard and gamepad stay with the
|
||||
* control maps and the widget helpers, because keyboard focus is something
|
||||
* the application declares, not something a pointer position implies. This
|
||||
* handles mouse motion, left-button presses and releases, the wheel, and
|
||||
* window resizes; every other event, and every event while the subsystem is
|
||||
* not initialized, reports @p consumed false and succeeds, so a host passes
|
||||
* every event through unconditionally -- the same contract
|
||||
* akgl_controller_handle_event() has.
|
||||
*
|
||||
* Call it *before* akgl_controller_handle_event() in the event loop, and skip
|
||||
* the rest of the chain when it consumed:
|
||||
*
|
||||
* while ( SDL_PollEvent(&event) ) {
|
||||
* PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed));
|
||||
* if ( consumed ) { continue; }
|
||||
* PASS(errctx, akgl_controller_handle_event(&app, &event));
|
||||
* }
|
||||
*
|
||||
* A press or release is 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, and one frame of staleness is the accepted cost of the standard
|
||||
* model. Before any frame has been laid out, nothing is over anything and
|
||||
* nothing is consumed.
|
||||
*
|
||||
* @param appstate The application state pointer the event loop owns.
|
||||
* Required, though this implementation does not read it --
|
||||
* the parameter exists so the signature matches
|
||||
* akgl_controller_handle_event() and can grow the same way.
|
||||
* @param event The event SDL handed the loop. Required.
|
||||
* @param consumed Receives whether the UI claimed the event. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate, @p event, or @p consumed is
|
||||
* `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If a resize event carries a non-positive size,
|
||||
* from akgl_ui_resize().
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed);
|
||||
|
||||
/**
|
||||
* @brief Open the UI frame: clear the error stash and begin the clay layout.
|
||||
*
|
||||
* Call once per rendered frame, after akgl_game_update() and before any
|
||||
* `CLAY()` block or widget helper. Everything declared between this and
|
||||
* akgl_ui_frame_end() is this frame's interface; a dialog is "open" because
|
||||
* the frame declares it, not because a mode was toggled somewhere.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized, or if a frame is
|
||||
* already open -- a begin/begin sequence means a frame_end went
|
||||
* missing, and absorbing it would hide the layout of one frame inside
|
||||
* another.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_begin(void);
|
||||
|
||||
/**
|
||||
* @brief Close the UI frame: compute the layout and draw it.
|
||||
*
|
||||
* Runs `Clay_EndLayout` and walks the render commands it produces through
|
||||
* @p self -- fills and rounded fills, borders, clip rectangles, text through
|
||||
* akgl_text_rendertextat(), images through the backend's `draw_texture`.
|
||||
* Draws in screen coordinates on whatever is already on the target, so call
|
||||
* it between akgl_game_update() and the backend's `frame_end`: the UI lands
|
||||
* on top of the world.
|
||||
*
|
||||
* The frame is considered closed even when this fails: the next
|
||||
* akgl_ui_frame_begin() is legal, so one bad frame is one bad frame rather
|
||||
* than a wedged subsystem.
|
||||
*
|
||||
* If clay reported errors during layout -- through its handler, or the
|
||||
* measure callback failing -- this raises the first of them here and does not
|
||||
* draw, because a layout that failed is not a layout, and the raise names how
|
||||
* many more followed it.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer` and `draw_texture`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, or its
|
||||
* `draw_texture` is `NULL`.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized; if no frame is
|
||||
* open; if clay reported layout errors; if a text command's line
|
||||
* exceeds #AKGL_UI_MAX_TEXT_BYTES; or if a fontId cannot be resolved.
|
||||
* @throws AKERR_* Whatever the drawing primitives underneath raise.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self);
|
||||
|
||||
/**
|
||||
* @brief Declare a dialog panel: the JRPG textbox in one call.
|
||||
*
|
||||
* A panel across the bottom of the screen, inset by the style's padding,
|
||||
* #AKGL_UI_DIALOG_HEIGHT pixels tall, with a one-pixel border and the text
|
||||
* wrapped inside it. Legal only between akgl_ui_frame_begin() and
|
||||
* akgl_ui_frame_end(). Showing and hiding is the frame's business: call this
|
||||
* while somebody is talking and don't while nobody is -- there is no
|
||||
* `visible` flag, because a declarative frame *is* the flag.
|
||||
*
|
||||
* @param id Element id for the panel, unique within the frame. Required.
|
||||
* Borrowed until frame_end.
|
||||
* @param text What the dialog says. Required, and borrowed until frame_end
|
||||
* -- clay keeps the pointer, not a copy. May be empty, which
|
||||
* draws an empty panel.
|
||||
* @param style Look to draw with, or `NULL` for the textbox palette.
|
||||
*
|
||||
* @note The panel's height is fixed at #AKGL_UI_DIALOG_HEIGHT and the text is
|
||||
* not clipped to it: a message that wraps to more lines than fit runs
|
||||
* past the bottom border, visibly. That is the same behaviour as the
|
||||
* hand-rolled panel this widget mirrors, and it is deliberate --
|
||||
* overflow you can see beats truncation you cannot. Size the height for
|
||||
* your longest message, or raise the override.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is
|
||||
* open.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style);
|
||||
|
||||
/**
|
||||
* @brief Declare a HUD label: one line of text on a padded panel, pinned to a corner.
|
||||
*
|
||||
* Fit-sized around its text, anchored per @p anchor and inset by the style's
|
||||
* padding. The score counter and the lives counter are two calls to this with
|
||||
* two ids. Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end().
|
||||
*
|
||||
* The text is borrowed until frame_end, which matters for the obvious use:
|
||||
* format the score into a buffer that outlives the frame bracket, not into a
|
||||
* scope that closes before the draw.
|
||||
*
|
||||
* @param id Element id for the label, unique within the frame. Required.
|
||||
* @param text What the label says. Required; empty draws an empty chip.
|
||||
* @param anchor Which corner (or the centre) to pin to.
|
||||
* @param style Look to draw with, or `NULL` for the library default.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is
|
||||
* open.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p anchor is not one of the enum's values.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style);
|
||||
|
||||
/**
|
||||
* @brief Declare a vertical menu from caller-owned state, centred on screen.
|
||||
*
|
||||
* One row per entry, the selected row drawn inverted (ink on fill swaps to
|
||||
* fill on ink). Moving the mouse onto a row selects it; a left click on a
|
||||
* row selects and sets `activated`. A *stationary* pointer claims nothing --
|
||||
* parking the mouse over the menu must not pin the selection against the
|
||||
* keyboard, which is fighting sixty times a second and losing. Keyboard and
|
||||
* gamepad go through
|
||||
* akgl_ui_menu_handle_event(), which the application calls for whichever
|
||||
* menu currently has its attention -- that routing *is* the focus model.
|
||||
* Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end().
|
||||
*
|
||||
* @param menu The menu state. Required, with a non-`NULL` `id`, a `count` in
|
||||
* range, every used entry non-`NULL`, and `selected` pointing at
|
||||
* an entry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p menu, its `id`, or any of its first
|
||||
* `count` items is `NULL`. The message names the index.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is
|
||||
* open.
|
||||
* @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu(akgl_UiMenu *menu);
|
||||
|
||||
/**
|
||||
* @brief Drive a menu from a keyboard or gamepad event.
|
||||
*
|
||||
* Up and Down arrows and the D-pad move the selection, wrapping at both
|
||||
* ends; Return and the gamepad South button set `activated`. Anything else
|
||||
* -- and anything while the subsystem is inert -- reports @p consumed false
|
||||
* and succeeds, so it slots into the same pass-everything event chain as
|
||||
* akgl_ui_handle_event(), after it and before the control maps:
|
||||
*
|
||||
* PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed));
|
||||
* if ( consumed ) { continue; }
|
||||
* if ( menu_active ) {
|
||||
* PASS(errctx, akgl_ui_menu_handle_event(&menu, &event, &consumed));
|
||||
* if ( consumed ) { continue; }
|
||||
* }
|
||||
* PASS(errctx, akgl_controller_handle_event(&app, &event));
|
||||
*
|
||||
* @param menu The menu the application is routing input to. Required,
|
||||
* with `count` in range.
|
||||
* @param event The event SDL handed the loop. Required.
|
||||
* @param consumed Receives whether the menu claimed the event. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p menu, @p event, or @p consumed is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed);
|
||||
|
||||
/*
|
||||
* The following is part of the internal API. It is exposed so the test suite
|
||||
* can reach it and is not meant to be called by a game.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief The measure callback akgl_ui_init() hands to clay.
|
||||
*
|
||||
* clay's callback signature returns dimensions by value and has no error
|
||||
* channel, so this cannot raise: on any failure -- an unregistered fontId, a
|
||||
* font gone from the registry, SDL_ttf refusing the string -- it reports zero
|
||||
* by zero and stashes the failure where akgl_ui_frame_end() will raise it.
|
||||
* The slice is measured at its stated length through SDL_ttf directly, so it
|
||||
* is never copied and need not be NUL-terminated.
|
||||
*
|
||||
* @param text The slice of text clay wants measured.
|
||||
* @param config The text element's configuration; only `fontId` is
|
||||
* consulted (see akgl_ui_font_register() on `fontSize`).
|
||||
* @param userData Unused. clay passes back whatever init registered.
|
||||
* @return The slice's size in pixels, or zero by zero on failure.
|
||||
*/
|
||||
Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData);
|
||||
|
||||
/**
|
||||
* @brief Draw one frame's render commands through a backend.
|
||||
*
|
||||
* The renderer half of akgl_ui_frame_end(), separated so a test can hand it
|
||||
* a command array and read pixels back without a full frame bracket. Handles
|
||||
* RECTANGLE, BORDER, TEXT, IMAGE and the SCISSOR pair; CUSTOM commands and
|
||||
* image tint colours are skipped in this version, and per-corner radii are
|
||||
* collapsed to the top-left value -- the widget helpers only produce uniform
|
||||
* corners.
|
||||
*
|
||||
* An IMAGE command's `imageData` is an `akgl_Sprite *`; its first frame is
|
||||
* drawn stretched to the command's bounding box.
|
||||
*
|
||||
* Any clip rectangle a command set is cleared before this returns, success or
|
||||
* failure -- the UI must not leave the next frame's world clipped.
|
||||
*
|
||||
* @note TEXT commands draw through akgl_text_rendertextat(), whose contract
|
||||
* is the *global* `akgl_renderer` -- so a host drawing its UI through
|
||||
* some other backend must keep the two pointed at the same renderer,
|
||||
* or its text lands somewhere else.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer` and `draw_texture`.
|
||||
* @param commands The commands `Clay_EndLayout` returned. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, its
|
||||
* `draw_texture`, or @p commands is `NULL`.
|
||||
* @throws AKGL_ERR_UI If a text line exceeds #AKGL_UI_MAX_TEXT_BYTES, a
|
||||
* fontId does not resolve, or an IMAGE command carries no sprite.
|
||||
* @throws AKERR_* Whatever the drawing primitives underneath raise.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands);
|
||||
|
||||
/**
|
||||
* @brief Narrow the arena, so a test can drive akgl_ui_init() into refusal.
|
||||
*
|
||||
* The interesting behaviour is the refusal message naming both numbers, and
|
||||
* the only other way to reach it is rebuilding the library with a smaller
|
||||
* #AKGL_UI_ARENA_BYTES, which CI cannot do and a reader cannot repeat. Same
|
||||
* contract as akgl_ccd_arena_set_limit().
|
||||
*
|
||||
* @param limit Usable bytes. 0, or anything above #AKGL_UI_ARENA_BYTES,
|
||||
* restores the full arena.
|
||||
*/
|
||||
void akgl_ui_arena_limit(size_t limit);
|
||||
|
||||
#endif // _AKGL_UI_H_
|
||||
@@ -49,8 +49,20 @@ rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log
|
||||
# The run itself is allowed to fail: a suite that valgrind makes slow enough to
|
||||
# trip an assertion still produced a log worth reading, and the log is what
|
||||
# decides the exit status below.
|
||||
#
|
||||
# docs_examples and docs_screenshots are excluded, and the exclusion deserves
|
||||
# its reasons stated: those two tests are *shell scripts* that drive gcc and
|
||||
# run the compiled snippets as child processes. Valgrind wraps the test
|
||||
# command, does not trace children, and so spends the whole test memchecking
|
||||
# /bin/bash -- 413 of bash's own by-design leaks on the first CI run that got
|
||||
# this far, and not one byte of libakgl. The snippet programs themselves are
|
||||
# ordinary akgl consumers whose code paths the suites and example games
|
||||
# already cover below, under valgrind, as real binaries. A caller's own -E
|
||||
# overrides this one (ctest takes the last), which is fine: a narrowed run
|
||||
# chose its scope on purpose.
|
||||
set +e
|
||||
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure "$@"
|
||||
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure \
|
||||
-E '^(docs_examples|docs_screenshots)$' "$@"
|
||||
AKGL_CTEST_STATUS=$?
|
||||
set -e
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ typedef struct {
|
||||
* There is no rotation, here or anywhere in this library yet -- `util.h` says so
|
||||
* and both example games depend on it. Adding it means rotating @p dir into the
|
||||
* shape's local frame at the top of this function and rotating the answer back,
|
||||
* and nothing else in the narrowphase changes. See `TODO.md`, "Actor rotation".
|
||||
* and nothing else in the narrowphase changes. See issue #62.
|
||||
*/
|
||||
static void collision_support(const void *obj, const ccd_vec3_t *dir, ccd_vec3_t *dest)
|
||||
{
|
||||
|
||||
226
src/draw.c
226
src/draw.c
@@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fill one quarter-circle corner as a triangle fan.
|
||||
*
|
||||
* `SDL_RenderGeometry` colours from its vertices rather than the renderer's
|
||||
* draw colour, so this neither pushes nor pops it.
|
||||
*
|
||||
* @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`;
|
||||
* the caller has already checked both.
|
||||
* @param cx Horizontal position of the corner's centre -- the point the
|
||||
* fan radiates from, one radius inside the rectangle.
|
||||
* @param cy Vertical position of the centre.
|
||||
* @param radius Radius of the quarter circle. Assumed positive.
|
||||
* @param start_deg Angle the quarter starts at; it sweeps 90 degrees clockwise
|
||||
* from there.
|
||||
* @param color Colour to fill with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If the fan cannot be drawn.
|
||||
*/
|
||||
static akerr_ErrorContext *fill_corner_fan(akgl_RenderBackend *self, float32_t cx, float32_t cy, float32_t radius, float32_t start_deg, SDL_Color color)
|
||||
{
|
||||
SDL_Vertex vertices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2];
|
||||
int indices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3];
|
||||
SDL_FColor fcolor;
|
||||
float32_t angle = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
fcolor.r = (float)color.r / 255.0f;
|
||||
fcolor.g = (float)color.g / 255.0f;
|
||||
fcolor.b = (float)color.b / 255.0f;
|
||||
fcolor.a = (float)color.a / 255.0f;
|
||||
|
||||
vertices[0].position.x = cx;
|
||||
vertices[0].position.y = cy;
|
||||
vertices[0].color = fcolor;
|
||||
vertices[0].tex_coord.x = 0.0f;
|
||||
vertices[0].tex_coord.y = 0.0f;
|
||||
for ( i = 0; i <= AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
|
||||
angle = (start_deg + ((90.0f / AKGL_DRAW_ROUNDED_RECT_SEGMENTS) * (float)i)) * (SDL_PI_F / 180.0f);
|
||||
vertices[i + 1].position.x = cx + (radius * SDL_cosf(angle));
|
||||
vertices[i + 1].position.y = cy + (radius * SDL_sinf(angle));
|
||||
vertices[i + 1].color = fcolor;
|
||||
vertices[i + 1].tex_coord.x = 0.0f;
|
||||
vertices[i + 1].tex_coord.y = 0.0f;
|
||||
}
|
||||
for ( i = 0; i < AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
|
||||
indices[(i * 3) + 0] = 0;
|
||||
indices[(i * 3) + 1] = i + 1;
|
||||
indices[(i * 3) + 2] = i + 2;
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderGeometry(
|
||||
self->sdl_renderer,
|
||||
NULL,
|
||||
vertices,
|
||||
AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2,
|
||||
indices,
|
||||
AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
SDL_FRect band;
|
||||
float32_t half = 0.0f;
|
||||
bool pushed = false;
|
||||
bool drawfailed = false;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL rectangle");
|
||||
if ( (rect->w <= 0.0f) || (rect->h <= 0.0f) ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( radius <= 0.0f ) {
|
||||
PASS(errctx, akgl_draw_filled_rect(self, rect, color));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
half = (((rect->w < rect->h) ? rect->w : rect->h) / 2.0f);
|
||||
if ( radius > half ) {
|
||||
radius = half;
|
||||
}
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
pushed = true;
|
||||
|
||||
// The body is three bands: one the full width between the corner rows,
|
||||
// and one between the corners along each of the top and bottom edges.
|
||||
// A radius of exactly half the shorter side leaves one or more of them
|
||||
// with no area, which SDL fills as nothing -- at the limit the whole
|
||||
// shape is the four fans.
|
||||
band.x = rect->x;
|
||||
band.y = rect->y + radius;
|
||||
band.w = rect->w;
|
||||
band.h = rect->h - (radius * 2.0f);
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
band.x = rect->x + radius;
|
||||
band.y = rect->y;
|
||||
band.w = rect->w - (radius * 2.0f);
|
||||
band.h = radius;
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
band.y = rect->y + rect->h - radius;
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + radius, radius, 180.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + radius, radius, 270.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + rect->h - radius, radius, 0.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + rect->h - radius, radius, 90.0f, color));
|
||||
} CLEANUP {
|
||||
if ( pushed == true ) {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color)
|
||||
{
|
||||
SDL_Vertex vertices[AKGL_DRAW_ARC_MAX_POINTS];
|
||||
int indices[(AKGL_DRAW_ARC_MAX_POINTS - 2) * 3];
|
||||
SDL_FColor fcolor;
|
||||
float32_t span = 0.0f;
|
||||
float32_t inner = 0.0f;
|
||||
float32_t angle = 0.0f;
|
||||
int segments = 0;
|
||||
int base = 0;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_OUTOFBOUNDS, "Arc radius %f is not positive", (double)radius);
|
||||
FAIL_NONZERO_RETURN(errctx, (thickness <= 0.0f), AKERR_OUTOFBOUNDS, "Arc thickness %f is not positive", (double)thickness);
|
||||
span = end_deg - start_deg;
|
||||
if ( span <= 0.0f ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( span > 360.0f ) {
|
||||
span = 360.0f;
|
||||
}
|
||||
inner = radius - thickness;
|
||||
if ( inner < 0.0f ) {
|
||||
inner = 0.0f;
|
||||
}
|
||||
|
||||
// Steps in proportion to the angle swept, so a sliver of arc does not
|
||||
// spend the whole vertex budget and a full circle uses all of it: the
|
||||
// quarter-circle density of AKGL_DRAW_ROUNDED_RECT_SEGMENTS, capped by
|
||||
// what AKGL_DRAW_ARC_MAX_POINTS vertices can carry at two per step.
|
||||
segments = (int)((span / 90.0f) * (float)AKGL_DRAW_ROUNDED_RECT_SEGMENTS) + 1;
|
||||
if ( segments > ((AKGL_DRAW_ARC_MAX_POINTS / 2) - 1) ) {
|
||||
segments = (AKGL_DRAW_ARC_MAX_POINTS / 2) - 1;
|
||||
}
|
||||
|
||||
fcolor.r = (float)color.r / 255.0f;
|
||||
fcolor.g = (float)color.g / 255.0f;
|
||||
fcolor.b = (float)color.b / 255.0f;
|
||||
fcolor.a = (float)color.a / 255.0f;
|
||||
for ( i = 0; i <= segments; i++ ) {
|
||||
angle = (start_deg + ((span / (float)segments) * (float)i)) * (SDL_PI_F / 180.0f);
|
||||
vertices[i * 2].position.x = x + (radius * SDL_cosf(angle));
|
||||
vertices[i * 2].position.y = y + (radius * SDL_sinf(angle));
|
||||
vertices[i * 2].color = fcolor;
|
||||
vertices[i * 2].tex_coord.x = 0.0f;
|
||||
vertices[i * 2].tex_coord.y = 0.0f;
|
||||
vertices[(i * 2) + 1].position.x = x + (inner * SDL_cosf(angle));
|
||||
vertices[(i * 2) + 1].position.y = y + (inner * SDL_sinf(angle));
|
||||
vertices[(i * 2) + 1].color = fcolor;
|
||||
vertices[(i * 2) + 1].tex_coord.x = 0.0f;
|
||||
vertices[(i * 2) + 1].tex_coord.y = 0.0f;
|
||||
}
|
||||
for ( i = 0; i < segments; i++ ) {
|
||||
base = i * 2;
|
||||
indices[(i * 6) + 0] = base;
|
||||
indices[(i * 6) + 1] = base + 2;
|
||||
indices[(i * 6) + 2] = base + 1;
|
||||
indices[(i * 6) + 3] = base + 1;
|
||||
indices[(i * 6) + 4] = base + 2;
|
||||
indices[(i * 6) + 5] = base + 3;
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderGeometry(
|
||||
self->sdl_renderer,
|
||||
NULL,
|
||||
vertices,
|
||||
(segments + 1) * 2,
|
||||
indices,
|
||||
segments * 6),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
// NULL rect deliberately passes straight through: it is how the clip is
|
||||
// cleared, and the header says so.
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderClipRect(self->sdl_renderer, rect),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ akerr_ErrorContext *akgl_error_init(void)
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_UI, "UI Error"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ static void save_spritename_iterator(void *userdata, SDL_PropertiesID props, con
|
||||
* save_actorname_iterator.
|
||||
* @note This is the table the loader disagrees with: it reads every table at
|
||||
* #AKGL_ACTOR_MAX_NAME_LENGTH, so a save containing any spritesheet cannot
|
||||
* be read back. TODO.md, "Known and still open" item 7.
|
||||
* be read back. TODO.md, "Defects".
|
||||
*/
|
||||
static void save_spritesheetname_iterator(void *userdata, SDL_PropertiesID props, const char *name)
|
||||
{
|
||||
|
||||
21
src/ui_clay.c
Normal file
21
src/ui_clay.c
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file ui_clay.c
|
||||
* @brief The one translation unit that compiles clay's implementation.
|
||||
*
|
||||
* clay is a single-header library: every other file that includes <clay.h>
|
||||
* gets declarations only, and exactly one file in the whole program defines
|
||||
* CLAY_IMPLEMENTATION before the include to get the definitions. This is that
|
||||
* file, and it must stay the only one -- a second definition anywhere,
|
||||
* including in a consuming game, is two copies of clay's globals and the
|
||||
* loader picking between them silently. The warning in akgl/ui.h says so to
|
||||
* consumers; this comment says so to us.
|
||||
*
|
||||
* Nothing else belongs here. libakgl's own UI code is src/ui.c, which
|
||||
* includes <clay.h> like any other consumer. CMakeLists.txt compiles this
|
||||
* file with -w on the same terms as deps/semver and deps/libccd: 99% of what
|
||||
* this TU contains is vendored code, and a future clay bump must not be able
|
||||
* to fail the build on a warning we do not own.
|
||||
*/
|
||||
|
||||
#define CLAY_IMPLEMENTATION
|
||||
#include <clay.h>
|
||||
@@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/ui.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/**
|
||||
|
||||
180
tests/draw.c
180
tests/draw.c
@@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_filled_rounded_rect(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_FRect rect = { 8.0f, 8.0f, 40.0f, 24.0f };
|
||||
SDL_FRect square = { 40.0f, 40.0f, 16.0f, 16.0f };
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 8.0f, testred),
|
||||
"filling a rounded rectangle");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 28, 20, testred),
|
||||
"the middle of the rounded rectangle is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 28, 8, testred),
|
||||
"the top edge between the corners is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 20, testred),
|
||||
"the left edge between the corners is unfilled");
|
||||
// 12,12 is inside the corner's quarter circle (5.7px from its centre
|
||||
// at 16,16); 8,8 and 9,9 are outside it (11.3px and 9.9px). The fan's
|
||||
// triangles are chords of the circle, so they cover nothing beyond it.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 12, 12, testred),
|
||||
"the inside of the rounded corner is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testblack),
|
||||
"the square corner pixel was filled despite the rounding");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 9, 9, testblack),
|
||||
"a pixel outside the corner arc was filled");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A radius beyond half the shorter side clamps to it, which on a
|
||||
// square is a circle: centre filled, corners untouched.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &square, 100.0f, testgreen),
|
||||
"filling a rounded rectangle with an oversized radius");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 48, 48, testgreen),
|
||||
"the centre of the clamped-radius square is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 41, 41, testblack),
|
||||
"clamping the radius still filled the square's corner");
|
||||
|
||||
// Radius zero is the plain fill, corners and all.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 0.0f, testred),
|
||||
"a zero radius falling through to the plain fill");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
|
||||
"a zero radius did not fill the square corner");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_filled_rounded_rect(NULL, &rect, 4.0f, testred),
|
||||
"rounded fill through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_filled_rounded_rect(akgl_renderer, NULL, 4.0f, testred),
|
||||
"rounded fill of a NULL rectangle");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_arc(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
// A full ring: outer radius 20, stroke 4, so mid-stroke is radius 18.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 360.0f, 4.0f, testred),
|
||||
"stroking a full circle");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 50, 32, testred),
|
||||
"the ring is missing at three o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 50, testred),
|
||||
"the ring is missing at six o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 14, 32, testred),
|
||||
"the ring is missing at nine o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testred),
|
||||
"the ring is missing at twelve o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
|
||||
"the centre of the ring was filled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testblack),
|
||||
"the stroke bled inside its inner radius");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A quarter arc sweeps clockwise from three o'clock to six o'clock;
|
||||
// 44,44 is on its mid-stroke at 45 degrees, twelve o'clock is not in
|
||||
// the sweep at all.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testgreen),
|
||||
"stroking a quarter arc");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 44, 44, testgreen),
|
||||
"the quarter arc is missing at its midpoint");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testblack),
|
||||
"the quarter arc drew outside its sweep");
|
||||
|
||||
// An empty sweep draws nothing and reports nothing.
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 90.0f, 90.0f, 4.0f, testred),
|
||||
"an arc with no sweep");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 0.0f, 0.0f, 90.0f, 4.0f, testred),
|
||||
"an arc with no radius");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, -1.0f, testred),
|
||||
"an arc with a negative thickness");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_arc(NULL, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testred),
|
||||
"an arc through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_set_clip(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_Rect clip = { 0, 0, 16, 16 };
|
||||
SDL_FRect everything = { 0.0f, 0.0f, (float)TEST_TARGET_SIZE, (float)TEST_TARGET_SIZE };
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, &clip),
|
||||
"setting the clip rectangle");
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testred),
|
||||
"filling the whole target under a clip");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
|
||||
"the fill did not reach inside the clip rectangle");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
|
||||
"the fill escaped the clip rectangle");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, NULL),
|
||||
"clearing the clip rectangle");
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testgreen),
|
||||
"filling the whole target after clearing the clip");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testgreen),
|
||||
"clearing the clip did not restore the full target");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_set_clip(NULL, &clip),
|
||||
"clipping through a NULL backend");
|
||||
} CLEANUP {
|
||||
// The clip is renderer state; leaving it set would clip every test
|
||||
// after this one.
|
||||
IGNORE(akgl_draw_set_clip(akgl_renderer, NULL));
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -668,6 +845,9 @@ int main(void)
|
||||
CATCH(errctx, test_draw_preserves_render_draw_color());
|
||||
CATCH(errctx, test_draw_backend_without_a_renderer());
|
||||
CATCH(errctx, test_draw_background());
|
||||
CATCH(errctx, test_draw_filled_rounded_rect());
|
||||
CATCH(errctx, test_draw_arc());
|
||||
CATCH(errctx, test_draw_set_clip());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
|
||||
@@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void)
|
||||
{ AKGL_ERR_HEAP, "Heap Error" },
|
||||
{ AKGL_ERR_BEHAVIOR, "Behavior Error" },
|
||||
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" },
|
||||
{ AKGL_ERR_COLLISION, "Collision Error" }
|
||||
{ AKGL_ERR_COLLISION, "Collision Error" },
|
||||
{ AKGL_ERR_UI, "UI Error" }
|
||||
};
|
||||
bool named = true;
|
||||
int i = 0;
|
||||
|
||||
@@ -66,7 +66,7 @@ static akerr_ErrorContext *load_fixture(void)
|
||||
* usually because something is not releasing -- arrived as a segfault inside
|
||||
* strncpy.
|
||||
*
|
||||
* This is what TODO.md Performance item 29 looked like from the outside: not
|
||||
* This is what the tilemap string-pool leak looked like from the outside: not
|
||||
* "the tilemap loader leaks five strings a load", but a crash somewhere else
|
||||
* entirely, fifty levels later.
|
||||
*/
|
||||
|
||||
@@ -483,7 +483,7 @@ static akerr_ErrorContext *bench_asset_load(void)
|
||||
// The 52nd load would find the pool empty, and what happens then is
|
||||
// not an AKGL_ERR_HEAP: akgl_get_json_string_value finishes with
|
||||
// pass-up false, swallows the failed claim, and dereferences the
|
||||
// pointer it never set. Both defects are filed in TODO.md; this loop
|
||||
// pointer it never set. Both defects are recorded in TODO.md; this loop
|
||||
// works around the first so it never reaches the second.
|
||||
for ( j = 0; j < AKGL_MAX_HEAP_STRING; j++ ) {
|
||||
akgl_heap_strings[j].refcount = 0;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* inside simulate produced a longer step and a red suite. It failed
|
||||
* exactly once, under a parallel ctest, which is the worst way to find
|
||||
* out. That the engine cannot be stepped exactly is itself a finding;
|
||||
* see TODO.md.
|
||||
* see issues #29 through #32.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* akgl_render_2d_init() is not covered here: it creates a window from the
|
||||
* property registry and writes the `camera` global, which is what the offscreen
|
||||
* harness described in TODO.md exists to make testable.
|
||||
* harness described in issue #6 exists to make testable.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -240,6 +240,10 @@ int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
@@ -740,6 +740,10 @@ int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
|
||||
1033
tests/ui.c
Normal file
1033
tests/ui.c
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user