diff --git a/CMakeLists.txt b/CMakeLists.txt index b20778f..a1ecffa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -807,6 +807,35 @@ add_custom_target(${AKGL_DOCS_SCREENSHOTS_TARGET} # examples/CMakeLists.txt, one game at a time, for the same reason. if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") 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 + $ + --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 + $ + --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() # Mutation testing copies the repository to scratch space, applies one small diff --git a/docs/20-tutorial-sidescroller.md b/docs/20-tutorial-sidescroller.md index c0d804f..cb7917c 100644 --- a/docs/20-tutorial-sidescroller.md +++ b/docs/20-tutorial-sidescroller.md @@ -582,8 +582,7 @@ that has to happen inside the physics step has only the one hook. ``` ```c excerpt=examples/sidescroller/main.c - PASS(errctx, akgl_game_update(NULL)); - PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); ``` `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It does **not** diff --git a/docs/21-tutorial-jrpg.md b/docs/21-tutorial-jrpg.md index fc657c7..b1bd055 100644 --- a/docs/21-tutorial-jrpg.md +++ b/docs/21-tutorial-jrpg.md @@ -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_game_update(&opflags)); 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 diff --git a/docs/images/jrpg.png b/docs/images/jrpg.png new file mode 100644 index 0000000..d460d20 Binary files /dev/null and b/docs/images/jrpg.png differ diff --git a/docs/images/sidescroller.png b/docs/images/sidescroller.png new file mode 100644 index 0000000..d2c7809 Binary files /dev/null and b/docs/images/sidescroller.png differ diff --git a/examples/jrpg/jrpg.c b/examples/jrpg/jrpg.c index e6015a0..96154dd 100644 --- a/examples/jrpg/jrpg.c +++ b/examples/jrpg/jrpg.c @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -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); diff --git a/examples/sidescroller/main.c b/examples/sidescroller/main.c index 809f265..e60a8fe 100644 --- a/examples/sidescroller/main.c +++ b/examples/sidescroller/main.c @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -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]" ); } }