Files
libakgl/include/akgl/game.h
Andrew Kesterson 913834a3af Draw actors at their sprite's height, and update each one once a frame
Closes Defects item 26 and Performance item 32.

akgl_actor_render set dest.h from curSprite->width, so every actor was drawn
square and a non-square sprite was stretched or squashed. Invisible in the
fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and
a render backend whose draw_texture records the rectangle it is handed instead
of drawing it. That recording backend is the first coverage akgl_actor_render
has had at all -- every other test in the file stubs renderfunc out.

akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep
nested inside it and never compared an actor's layer to the layer it was on, so
every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted
out: updating an actor is not a per-layer operation, and
akgl_render_2d_draw_world already walks the layers for the half that is.
AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who
wants one layer can still ask, and gets each of those actors once.

Counting is the assertion, deliberately. The defect was invisible in the frame
total because the tilemap blits are three orders of magnitude larger, so a
timing test would have measured the rasterizer. tests/game.c counts calls into
a stub updatefunc and reports 16 against the old code.

PERFORMANCE.md records the timing side as what it honestly is: the gap between
the akgl_game_update and draw_world rows of the same run, 92 us before and
noise in both directions after. The absolute table is not re-taken -- a later
run on this machine read every row about 15% high, including rows nothing has
touched.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:27 -04:00

362 lines
19 KiB
C

/**
* @file game.h
* @brief Process-wide game state, the startup sequence, and the per-frame tick.
*
* This is the top of the library. `akgl_game_init` brings up SDL, the pools, the
* registries and the audio and font engines; `akgl_game_update` is one frame --
* update every actor, step the physics, draw the world.
*
* There is exactly one of everything. `akgl_renderer`, `akgl_physics`, `akgl_camera`, and
* `akgl_gamemap` are globals pointing at the `akgl_default_*` storage below them, so a
* program can swap in its own instance by reassigning the pointer without the
* rest of the library knowing. That is the whole extent of the indirection:
* there is no notion of two worlds at once.
*
* The startup order that actually works:
*
* 1. fill in `akgl_game.name`, `akgl_game.version`, and `akgl_game.uri` -- akgl_game_init
* refuses to run without them;
* 2. akgl_game_init();
* 3. akgl_registry_load_properties() or akgl_set_property(), to configure
* screen size, physics constants and so on;
* 4. akgl_render_2d_init(renderer) and akgl_physics_factory(physics, ...), both
* of which read that configuration;
* 5. load assets, then loop on akgl_game_update().
*
* @warning None of this is thread-safe beyond the `akgl_game.state` mutex, and that
* mutex protects the state flags, not the pools or the registries.
*/
#ifndef _AKGL_GAME_H_
#define _AKGL_GAME_H_
#include <stdint.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <akgl/types.h>
#include <akgl/tilemap.h>
#include <akgl/renderer.h>
#include <akgl/physics.h>
// AKGL_VERSION used to be defined here by hand, which is how akgl.pc came to
// ship an empty Version field: nothing tied the two together.
#include <akgl/version.h>
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
#define AKGL_GAME_AUDIO_TRACK_BGM 1
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
/** @brief Nanoseconds in one second. The unit `SDL_GetTicksNS` reports in. */
#define AKGL_TIME_ONESEC_NS 1000000000
/**
* @brief Nanoseconds in one millisecond. The scale factor from JSON durations to internal ones.
*
* Sprite and character definitions state frame durations in milliseconds
* because that is what a human writing one wants to type; everything that
* compares them against `SDL_GetTicksNS` needs nanoseconds.
*
* This was called `AKGL_TIME_ONESEC_MS` until 0.5.0, which said "one second in
* milliseconds" and held 1000000 -- a name and a value that described two
* different quantities. Both callers that meant the scale factor were right;
* the one that read it as a one-second budget was wrong by a factor of a
* thousand. See akgl_game_state_lock.
*/
#define AKGL_TIME_ONEMS_NS 1000000
/**
* @brief How long akgl_game_state_lock keeps trying for the state mutex, in milliseconds.
*
* A deliberate ceiling rather than a blocking wait: a deadlock here reports an
* error the caller can act on instead of hanging the process.
*/
#define AKGL_GAME_STATE_LOCK_BUDGET_MS 1000
/** @brief How long akgl_game_state_lock sleeps between attempts, in milliseconds. */
#define AKGL_GAME_STATE_LOCK_RETRY_MS 100
/* ==================== GAME STATE VARIABLES =================== */
/** @brief Stores application-defined game-state flags. */
typedef struct {
int32_t flags; /**< Meaning is entirely the application's; the library never reads it. Guard changes with akgl_game_state_lock. */
} akgl_GameState;
/** @brief Stores game metadata, timing, synchronization, and FPS accounting. */
typedef struct {
char libversion[32]; /**< libakgl's version, stamped by akgl_game_init. Compared on load to refuse a save from another build. */
char version[32]; /**< The *application's* version. Caller-supplied, required, and must be a semver string. */
char name[256]; /**< Application name. Caller-supplied and required; also becomes SDL's app metadata. */
char uri[256]; /**< Application URI, e.g. a reverse-DNS identifier. Caller-supplied, required, and used as the window title. */
akgl_GameState state; /**< The application's own state flags. */
SDL_Mutex *statelock; /**< Guards `state`. Created by akgl_game_init. */
int16_t fps; /**< Frames drawn during the last completed second. Recomputed once per second, not per frame. */
SDL_Time gameStartTime; /**< `SDL_GetTicksNS()` at akgl_game_init. */
SDL_Time lastIterTime; /**< Timestamp of the most recent akgl_game_update_fps call. */
SDL_Time lastFPSTime; /**< When `fps` was last recomputed. */
int16_t framesSinceUpdate; /**< Frames counted so far in the current second. */
void (*lowfpsfunc)(void); /**< Called every frame while `fps` is under 30. akgl_game_init installs akgl_game_lowfps, and akgl_game_update_fps installs it too if it finds this NULL -- an embedder that binds its own renderer rather than calling akgl_game_init used to crash here on frame one. Replace it to do something more useful than log. */
} akgl_Game;
/** @brief The SDL window, created by akgl_render_2d_init. `NULL` until then. */
extern SDL_Window *akgl_window;
/** @brief The background music, loaded by akgl_load_start_bgm. `NULL` until then. */
extern MIX_Audio *akgl_bgm;
/** @brief The mixer device, created by akgl_game_init. Everything audio goes through it. */
extern MIX_Mixer *akgl_mixer;
/** @brief Playback tracks by slot. #AKGL_GAME_AUDIO_TRACK_BGM is the music track; the rest are the application's to assign. */
extern MIX_Track *akgl_tracks[AKGL_GAME_AUDIO_MAX_TRACKS];
/** @brief Storage behind the default `akgl_camera`. Point `akgl_camera` elsewhere rather than reaching for this. */
extern SDL_FRect akgl_default_camera;
/** @brief The one game object: metadata, timing, and FPS accounting. */
extern akgl_Game akgl_game;
/** @brief Storage behind the default `akgl_renderer`. */
extern akgl_RenderBackend akgl_default_renderer;
/** @brief Storage behind the default `akgl_physics`. */
extern akgl_PhysicsBackend akgl_default_physics;
/** @brief Storage behind the default `akgl_gamemap`. */
extern akgl_Tilemap akgl_default_gamemap;
/** @brief Currently active tilemap. */
extern akgl_Tilemap *akgl_gamemap;
/** @brief Currently active renderer. */
extern akgl_RenderBackend *akgl_renderer;
/** @brief Currently active physics backend. */
extern akgl_PhysicsBackend *akgl_physics;
/** @brief Currently active camera. */
extern SDL_FRect *akgl_camera;
/**
* @brief True when every bit of `y` is set in `x`. Not "any of them" -- all of them.
*
* Fully parenthesized, so it composes: `!AKGL_BITMASK_HAS(a, b)` and
* `AKGL_BITMASK_HAS(a, b) && cond` both mean what they read as. Until 0.5.0 it
* expanded to a bare `(x & y) == y`, so a negation bound to the `&` and parsed
* as `!(a & b) == b`. Nothing in the tree negated it -- #AKGL_BITMASK_HASNOT
* exists for that -- which is the only reason it was latent rather than live.
*/
#define AKGL_BITMASK_HAS(x, y) ((((x) & (y)) == (y)))
/** @brief True when at least one bit of `y` is missing from `x`. */
#define AKGL_BITMASK_HASNOT(x, y) ((((x) & (y)) != (y)))
/** @brief Set every bit of `y` in `x`. Modifies `x`. */
#define AKGL_BITMASK_ADD(x, y) ((x) |= (y))
/** @brief Clear every bit of `y` in `x`. Modifies `x`. */
#define AKGL_BITMASK_DEL(x, y) ((x) &= ~(y))
/**
* @brief Clear every bit of `x`. Modifies `x`.
*
* Carries no trailing semicolon: write `AKGL_BITMASK_CLEAR(flags);` like any
* other statement. It used to include one, so every ordinary use produced an
* empty statement after it and a use as the whole body of an unbraced `if`
* would have taken the following statement with it.
*/
#define AKGL_BITMASK_CLEAR(x) ((x) = 0)
/**
* @brief Bring the whole library up: error codes, pools, registries, SDL, audio, fonts, gamepads.
*
* In order: claim the libakgl status band (so every later error has a name),
* stamp the library version, start the frame clock, create the state mutex,
* check that the caller filled in the three required `game` fields, zero the
* pools, create the registries, hand SDL the app metadata, clear the control
* maps, `SDL_Init` video/gamepad/audio, load the bundled controller database,
* open any attached gamepads, start SDL_mixer and SDL_ttf, and finally point
* `akgl_renderer`, `akgl_physics`, `akgl_camera`, and `akgl_gamemap` at their default storage.
*
* What it does *not* do: create the window, choose a physics backend, or load
* any configuration. Those read properties, so they come after the caller has
* set them. See the sequence at the top of this file.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If `akgl_game.name`, `akgl_game.version`, or `akgl_game.uri` is
* empty. All three are required and there are no defaults -- the
* window title, SDL's app metadata, and the savegame compatibility
* check are all built from them.
* @throws AKGL_ERR_SDL If the state mutex cannot be created, if `SDL_Init`
* fails, if a controller-database entry is rejected, if SDL_mixer
* cannot start or open the default playback device, or if SDL_ttf
* cannot start. Each message carries `SDL_GetError()`.
* @throws AKERR_STATUS_RANGE_OVERLAP If another component already owns part of
* the libakgl status band. See akgl_error_init.
* @throws AKERR_* Whatever the heap, registry, and controller initializers raise.
*
* @note It takes the state lock on the way in and releases it on the way out, so
* a failure part-way through leaves the mutex held.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_init(void);
/**
* @brief Count this frame, and recompute the frame rate once a second has passed.
*
* Called at the top of akgl_game_update, so a caller running its own loop needs
* to call it itself. `akgl_game.fps` is only refreshed when a full second has
* elapsed, so it is a completed-second average rather than an instantaneous
* figure -- and it reads 0 for the first second of the process, which is under
* the low-FPS threshold and so fires `lowfpsfunc` on every frame until the first
* second is up.
*/
void akgl_game_update_fps(void);
/**
* @brief Write the game state and the name-to-pointer tables to a save file.
*
* Writes the `akgl_Game` struct verbatim, then four name tables -- actors,
* sprites, spritesheets, characters -- each mapping a registered name to the
* address the object had at save time, and each terminated by a zeroed name and
* a zeroed pointer. The tables are what let akgl_game_load reconnect pointers
* between objects that will be at different addresses next run.
*
* @param fpath Path to write. Required. Opened with `"wb"`; an existing file is
* truncated.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p fpath is `NULL`.
* @throws ENOENT, EACCES Or whatever else `fopen(3)` reports, with the path in
* the message.
* @throws AKERR_IO On a stream error or a short write.
*
* @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
* and the reader disagree on the name-field widths, so a save with any
* registered spritesheet cannot be read back.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save(char *fpath);
/**
* @brief Read a save file back, refusing one that does not match this build.
*
* Reads the saved `akgl_Game`, then rejects the file unless the library version,
* the game version, the game name, and the game URI all match the running
* program -- versions by exact semver equality, name and URI by string compare.
* Only then does it copy the saved state over `game` and rebuild the four
* old-address-to-current-object maps.
*
* @param fpath Path to read. Required. Opened with `"rb"`.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p fpath is `NULL`.
* @throws ENOENT, EACCES Or whatever else `fopen(3)` reports.
* @throws AKERR_EOF If a read runs off the end of the file -- which is what a
* truncated or corrupt name table looks like.
* @throws AKERR_IO On a stream error.
* @throws AKERR_VALUE If either version string in the save file, or either in
* the running game, is not valid semver.
* @throws AKERR_API If the save file is from a different library version, game
* version, game name, or game URI.
*
* @note Like akgl_game_save, this is partial: it rebuilds the pointer maps but
* does not yet read back any objects. The four `SDL_CreateProperties`
* sets it builds are never destroyed, so each call leaks them.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load(char *fpath);
/**
* @brief The default `akgl_game.lowfpsfunc`: log the current frame rate.
*
* Called from akgl_game_update_fps on every frame where `akgl_game.fps` is under 30.
* It is a placeholder -- the point of the hook is that a game can replace it
* with something that actually sheds work.
*/
void akgl_game_lowfps(void);
/**
* @brief Take the game-state mutex, retrying rather than blocking.
*
* Polls with `SDL_TryLockMutex` on a 100 ms cadence instead of blocking
* outright, so a deadlock reports an error rather than hanging the process.
*
* @return `NULL` once the lock is held, otherwise an error context owned by the
* caller.
* @throws AKGL_ERR_SDL If #AKGL_GAME_STATE_LOCK_BUDGET_MS elapses with the lock
* still held elsewhere. `SDL_GetError()` is appended for whatever it is
* worth, but after a failed `SDL_TryLockMutex` it is usually stale or
* empty -- contention is reported by the return value, not by an error
* string. The status is the signal, not the text.
*
* @note Before 0.5.0 the loop counted against a constant named
* `AKGL_TIME_ONESEC_MS` that held 1000000, so it retried 10,000 times at
* 100 ms and blocked for roughly sixteen minutes rather than the one
* second documented here.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_state_lock(void);
/**
* @brief Release the game-state mutex.
*
* @return `NULL`. `SDL_UnlockMutex` reports nothing, so there is no failure path
* -- including for the case that matters, unlocking a mutex this thread
* does not hold, which is undefined behaviour in SDL rather than an
* error here.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_state_unlock(void);
/**
* @brief One frame: update every actor layer by layer, step the physics, draw the world.
*
* Takes the state lock, counts the frame, then calls each live actor's
* `updatefunc` **exactly once**, optionally rescaling it to the tilemap first.
* Then it steps `akgl_physics` and draws through `akgl_renderer`, and releases
* the lock.
*
* Until 0.5.0 the sweep sat inside a walk over #AKGL_TILEMAP_MAX_LAYERS that
* never compared an actor's `layer` to the layer it was on, so every actor
* updated sixteen times a frame. Updating an actor is not a per-layer
* operation; drawing is, and akgl_render_2d_draw_world walks the layers itself.
*
* @param opflags Iterator flags. Optional -- `NULL` selects
* #AKGL_ITERATOR_OP_UPDATE alone, which is every live actor
* once. Two bits are read here:
* #AKGL_ITERATOR_OP_TILEMAPSCALE, without which every actor's
* `scale` is forced to 1.0; and #AKGL_ITERATOR_OP_LAYERMASK,
* which restricts the sweep to the actors whose `layer` matches
* `layerid`. Note that the flags are *not* forwarded to the
* physics or render calls, both of which are passed `NULL`.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_SDL If the state lock cannot be taken.
* @throws AKERR_* Whatever an actor's `updatefunc`, akgl_tilemap_scale_actor,
* the physics backend, or the renderer raises.
*
* @warning Every failure path returns with the state lock still held, and a live
* actor's `updatefunc` is called without a `NULL` check -- a hand-built
* actor that never went through akgl_actor_initialize crashes here.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_update(akgl_Iterator *opflags);
/*
* These functions are part of the internal API and should not be called by the
* user. They are only exposed here for unit testing.
*/
/**
* @brief Write all four name-to-address tables, each with its terminator.
*
* Actors, sprites, spritesheets, characters, in that order -- which is the order
* akgl_game_load reads them back in. Each table is the registry enumerated one
* entry at a time, followed by a zeroed name field and a zeroed pointer that the
* reader stops on.
*
* @param fp The open save-game stream. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p fp is `NULL`.
* @throws AKERR_IO On a stream error or a short write while emitting a
* terminator.
*
* @note Only the terminators are written through the error-reporting path. The
* entries themselves go through `SDL_EnumerateProperties`, whose callbacks
* cannot report failure upward and instead terminate the process -- so a
* write error mid-table never reaches this function's return value.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp);
/**
* @brief Refuse a save file unless its version matches the running one exactly.
*
* Both strings are parsed as semver and compared with `"="` -- exact equality,
* not compatibility. That is deliberate and strict: a save file is a raw memory
* image of `akgl_Game` and the object tables, so any change to a struct layout
* invalidates it, and semver has no way to say "the layout did not move".
*
* @param versiontype What is being compared -- `"library"` or `"game"`. Used
* only to build the message, but required, since a bare
* "incompatible version" error would not say which.
* @param newversion The version read out of the save file. Required.
* @param curversion The version of the running program or library. Required.
* @return `NULL` when the two match, otherwise an error context owned by the
* caller.
* @throws AKERR_NULLPOINTER If any of the three arguments is `NULL`.
* @throws AKERR_VALUE If either string is not valid semver. The message says
* which side it came from.
* @throws AKERR_API If both parse but are not equal. The message quotes both.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_versioncmp(char *versiontype, char *newversion, char *curversion);
#endif //_AKGL_GAME_H_