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

@@ -20,6 +20,7 @@
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.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]))
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;
@@ -116,8 +121,23 @@ static akerr_ErrorContext *parse_args(int argc, char *argv[])
i += 1;
PASS(errctx, aksl_atoi(argv[i], &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 {
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);
@@ -230,6 +250,36 @@ static akerr_ErrorContext *demo_step(long frameno)
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.
*
@@ -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.
PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw());
if ( (shotpath != NULL) && (frameno == shotframe) ) {
PASS(errctx, save_screenshot(shotpath));
}
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);

View File

@@ -17,6 +17,7 @@
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <SDL3_ttf/SDL_ttf.h>
@@ -46,6 +47,40 @@
ss_Game ss_game;
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)
{
SDL_FRect feet;
@@ -354,6 +389,9 @@ static akerr_ErrorContext *frame(bool *running)
* Treat it as terminal, which is what PASS does here.
*/
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));
SUCCEED_RETURN(errctx);
}
@@ -441,11 +479,20 @@ static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, i
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
*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 {
FAIL_RETURN(
errctx,
AKERR_VALUE,
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
" [--screenshot PATH] [--screenshot-frame N]"
);
}
}