Take the tutorial figures out of the games themselves

Both examples gain --screenshot PATH and --screenshot-frame N. The capture sits
between the world being drawn and the frame being presented, because
SDL_RenderPresent is where the target stops being readable, and it works under
the dummy video driver and the software renderer like the rest of the headless
path does.

`cmake --build build --target docs_game_figures` regenerates
docs/images/sidescroller.png and docs/images/jrpg.png by running each game to a
chosen frame. Same contract as docs_screenshots: deliberate, never part of a
build, because the PNGs are tracked. There is no --check counterpart, and the
target says why -- the sidescroller drives its physics from the wall clock, so a
byte comparison would fail for reasons that have nothing to do with the
documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 08:38:18 -04:00
parent 4e32328681
commit 0972472cfa
7 changed files with 131 additions and 4 deletions

View File

@@ -807,6 +807,35 @@ add_custom_target(${AKGL_DOCS_SCREENSHOTS_TARGET}
# examples/CMakeLists.txt, one game at a time, for the same reason. # examples/CMakeLists.txt, one game at a time, for the same reason.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt")
add_subdirectory(examples) add_subdirectory(examples)
# The figures at the top of chapters 20 and 21 are frames out of the games
# themselves, taken by --screenshot between the world being drawn and the
# frame being presented. Same contract as docs_screenshots above: deliberate,
# never part of a build, because the PNGs are tracked.
#
# There is no --check counterpart. docs_screenshots can compare because its
# figures are one deterministic frame of drawing calls; a game frame is not.
# The sidescroller drives its physics from the wall clock, so which pixel the
# player occupies depends on how fast the machine ran, and a byte comparison
# would fail for reasons that have nothing to do with the documentation.
add_custom_target(docs_game_figures
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:sidescroller>
--assets "${CMAKE_CURRENT_SOURCE_DIR}/docs/tutorials/assets/sidescroller"
--autoplay --frames 110
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/sidescroller.png"
--screenshot-frame 100
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:jrpg>
--demo --frames 240
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
--screenshot-frame 230
DEPENDS sidescroller jrpg
COMMENT "Regenerating the tutorial figures in docs/images"
VERBATIM
)
endif() endif()
# Mutation testing copies the repository to scratch space, applies one small # Mutation testing copies the repository to scratch space, applies one small

View File

@@ -582,8 +582,7 @@ that has to happen inside the physics step has only the one hook.
``` ```
```c excerpt=examples/sidescroller/main.c ```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_game_update(NULL)); PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
``` ```
`akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It does **not** `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It does **not**

View File

@@ -547,7 +547,6 @@ zero gravity, which is what the property defaults already give you.
PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, akgl_game_update(&opflags)); PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw()); PASS(errctx, jrpg_textbox_draw());
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
``` ```
`akgl_game_update` is update-every-actor, step the physics, draw the world. It `akgl_game_update` is update-every-actor, step the physics, draw the world. It

BIN
docs/images/jrpg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -20,6 +20,7 @@
#include <string.h> #include <string.h>
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h> #include <akerror.h>
#include <akstdlib.h> #include <akstdlib.h>
@@ -68,6 +69,10 @@ static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0])) #define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
static long frame_limit = 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 demo = false;
static bool running = true; static bool running = true;
static int exitstatus = 0; static int exitstatus = 0;
@@ -116,8 +121,23 @@ static akerr_ErrorContext *parse_args(int argc, char *argv[])
i += 1; i += 1;
PASS(errctx, aksl_atoi(argv[i], &frames)); PASS(errctx, aksl_atoi(argv[i], &frames));
frame_limit = frames; frame_limit = frames;
} 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], &frames));
shotframe = frames;
} else { } else {
FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]"); FAIL_RETURN(errctx, AKERR_VALUE,
"usage: jrpg [--frames N] [--demo]"
" [--screenshot PATH] [--screenshot-frame N]");
} }
} }
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
@@ -230,6 +250,36 @@ static akerr_ErrorContext *demo_step(long frameno)
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
/**
* @brief Read the render target back and write it out as a PNG.
*
* Called between the box being drawn and the frame being presented, because
* SDL_RenderPresent is where the target stops being readable. This is how the
* figure in chapter 21 is generated: it is output from this program rather than
* a picture somebody took once, so it cannot show a version of the game that no
* longer exists.
*/
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, camera, the library's own update, the box, present. * @brief One frame: events, camera, the library's own update, the box, present.
* *
@@ -276,6 +326,9 @@ static akerr_ErrorContext *frame(long frameno)
// on top of a frame that did not finish. Treat a failed frame as terminal. // on top of a frame that did not finish. Treat a failed frame as terminal.
PASS(errctx, akgl_game_update(&opflags)); PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw()); PASS(errctx, jrpg_textbox_draw());
if ( (shotpath != NULL) && (frameno == shotframe) ) {
PASS(errctx, save_screenshot(shotpath));
}
PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);

View File

@@ -17,6 +17,7 @@
#include <string.h> #include <string.h>
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_mixer/SDL_mixer.h> #include <SDL3_mixer/SDL_mixer.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
@@ -46,6 +47,40 @@
ss_Game ss_game; ss_Game ss_game;
akgl_CollisionWorld ss_collision; akgl_CollisionWorld ss_collision;
/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
static char *ss_shotpath = NULL;
static int ss_shotframe = 0;
/**
* @brief Read the render target back and write it out as a PNG.
*
* Called between the world being drawn and the frame being presented, because
* SDL_RenderPresent is where the target stops being readable. This is how the
* figure in chapter 20 is generated: it is output from this program rather than
* a picture somebody took once, so it cannot show a version of the game that no
* longer exists.
*/
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);
}
akerr_ErrorContext *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest) akerr_ErrorContext *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest)
{ {
SDL_FRect feet; SDL_FRect feet;
@@ -354,6 +389,9 @@ static akerr_ErrorContext *frame(bool *running)
* Treat it as terminal, which is what PASS does here. * Treat it as terminal, which is what PASS does here.
*/ */
PASS(errctx, akgl_game_update(NULL)); PASS(errctx, akgl_game_update(NULL));
if ( (ss_shotpath != NULL) && (ss_game.frame == ss_shotframe) ) {
PASS(errctx, save_screenshot(ss_shotpath));
}
PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
@@ -441,11 +479,20 @@ static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, i
i += 1; i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory"); FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
*assetdir = argv[i]; *assetdir = argv[i];
} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot needs a path");
ss_shotpath = argv[i];
} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot-frame needs a number");
PASS(errctx, aksl_atoi(argv[i], &ss_shotframe));
} else { } else {
FAIL_RETURN( FAIL_RETURN(
errctx, errctx,
AKERR_VALUE, AKERR_VALUE,
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]" "usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
" [--screenshot PATH] [--screenshot-frame N]"
); );
} }
} }