/** * @file jrpg.c * @brief A small top-down JRPG: startup, the frame loop, and teardown. * * Chapter 20 of the manual quotes this program rather than restating it, so * what is here is what the chapter teaches. Run it with no arguments for a * window you can walk around in: * * ./examples/jrpg/jrpg * * Arrow keys walk, space talks to whoever is standing next to you and dismisses * the box again. `--frames N` stops after N frames, which is what makes this * runnable as a headless smoke test; `--demo` drives the arrow keys from a * script and steps the physics clock by a fixed interval so the run is * deterministic and takes no wall-clock time. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "jrpg.h" /** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */ #define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60) /** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */ #define JRPG_FRAME_BUDGET_MS 16 /* * The scripted walk `--demo` drives. The player starts at (224, 256) and the * elder stands at (272, 112), so this is: right for a second, up for two and a * bit, talk, try to walk while frozen, dismiss, walk away. Every one of those * is a path the interactive game has and a headless run would otherwise never * touch. * * Frame Key Down What it is for */ 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 } }; #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; static bool lowfps_warned = false; /** * @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. * * akgl_game.fps is a completed-second average, so it reads 0 for the first * second of every process -- which is under the threshold, so the default hook * logs a line on every frame until the first second is up. The hook exists to * be replaced; a real game sheds work here rather than talking about it. */ static void lowfps_quiet(void) { if ( lowfps_warned == false ) { lowfps_warned = true; SDL_Log("Frame rate is under 30 and this game does nothing about it"); } } /** * @brief Read `--frames N` and `--demo` 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 `--frames` 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 frames = 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], &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]" " [--screenshot PATH] [--screenshot-frame N]"); } } SUCCEED_RETURN(errctx); } /** * @brief Bring the library up, load the town, and bind the controls. * * The order below is the one game.h documents and the only one that works. * Three things in it are not obvious and are what chapter 20 spends its time * on: the properties have to be set between akgl_game_init and * akgl_render_2d_init, because that is what reads them; the physics backend has * to be initialized by hand, because akgl_game_init does not; and the assets * have to be loaded sprites-then-characters-then-map. * * @return `NULL` on success, otherwise an error context owned by the caller. */ static akerr_ErrorContext *startup(void) { PREPARE_ERROR(errctx); akgl_Control talk; // Required before akgl_game_init, which refuses to run without all three: // the window title, SDL's application metadata and the savegame // compatibility check are built from them. PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", 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.jrpg", sizeof(akgl_game.uri) - 1)); PASS(errctx, akgl_game_init()); akgl_game.lowfpsfunc = &lowfps_quiet; PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH)); PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT)); PASS(errctx, akgl_render_2d_init(akgl_renderer)); // akgl_game_init points akgl_physics at akgl_default_physics and stops // there. That storage is BSS, so all four of its method pointers are NULL, // and akgl_game_update calls `akgl_physics->simulate(...)` without checking // it: a NULL function pointer, and a segmentation fault on frame one rather // than an error context. // // town.tmj happens to declare its own physics, and jrpg_world_load switches // to the backend that builds -- so removing this line alone leaves *this* // program working, and removing it along with that switch is a SIGSEGV on // frame one. Measured, by removing both. This line is what makes the // program correct for a map that declares no physics, which is most of // them. Nothing calls the factory for you either way. A top-down game wants // the arcade backend with no gravity, which is what the property defaults // already give. PASS(errctx, akgl_physics_init_arcade(akgl_physics)); PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE)); PASS(errctx, jrpg_world_load()); PASS(errctx, jrpg_world_populate()); // The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in // one call. Keyboard id 0 and gamepad id 0 mean "the first of each". PASS(errctx, akgl_controller_default(0, "player", 0, 0)); // And one binding of our own on the same map. A binding is a struct, copied // in, so a stack local is fine. handler_off has to be non-NULL -- // akgl_controller_handle_event does not check it before calling it -- and // the button is set to something no gamepad reports, because the gamepad // and keyboard arms of the match are evaluated together. PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk))); talk.event_on = SDL_EVENT_KEY_DOWN; talk.event_off = SDL_EVENT_KEY_UP; talk.key = SDLK_SPACE; talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID; talk.handler_on = &jrpg_cmhf_talk; talk.handler_off = &jrpg_cmhf_ignore; PASS(errctx, akgl_controller_pushmap(0, &talk)); SUCCEED_RETURN(errctx); } /** * @brief Feed the scripted keystrokes due on this frame into the control maps. * * Synthesized `SDL_Event`s rather than direct calls to the handlers, so the * demo goes through akgl_controller_handle_event, the control map lookup and * the binding -- the same path a real key takes. A test that skipped that would * not be testing the thing that breaks. * * @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 < JRPG_DEMO_STEPS; i++ ) { if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) { continue; } 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)); } 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. * * akgl_game_update is update-every-actor, step the physics, draw the world. It * does *not* clear or present the target, so the frame is bracketed by the * backend's own frame_start and frame_end, and anything drawn between the * update and frame_end lands on top of the world. * * @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; akgl_Iterator opflags = { .flags = AKGL_ITERATOR_OP_UPDATE, .layerid = 0 }; while ( SDL_PollEvent(&event) ) { if ( event.type == SDL_EVENT_QUIT ) { running = false; } PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); } if ( demo ) { PASS(errctx, demo_step(frameno)); // Drive the clock rather than sleeping on it. akgl_physics_simulate // measures dt from gravity_time, which is a public field, so setting it // one fixed step into the past makes every frame worth exactly 1/60 of // a second of simulated time no matter how fast the loop actually runs. // tests/physics_sim.c does the same, for the same reason: a simulated // second should not cost a real one. akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS; } PASS(errctx, jrpg_camera_follow()); PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); // Every failure path out of akgl_game_update returns with the game state // mutex still held. SDL's mutexes are recursive and this loop is single // threaded, so the next frame would not deadlock -- but it would be running // 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); } /** * @brief Give back what has to be given back, in the order that works. * * There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three * lines here are load-bearing and the third is a deliberate omission: * * - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept in * an SDL property registry, and `SDL_Quit` destroys the registry -- taking * the last reference to every font still in it with no way left to close * them. * - 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 * 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. */ static void teardown(void) { 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); akgl_Actor *player = NULL; long frameno = 0; uint64_t started = 0; uint64_t spent = 0; ATTEMPT { CATCH(errctx, parse_args(argc, argv)); CATCH(errctx, startup()); // Taken here rather than after FINISH: the registry is an SDL property // set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards // finds nothing. The actor itself is in a static pool and outlives both. player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); // The loop is the last thing in this ATTEMPT block on purpose. CATCH // reports failure by `break`ing, and a `break` inside a loop binds to // the loop rather than to the block -- so a failing frame leaves the // loop and falls straight into CLEANUP, which is what is wanted. Put // anything after this loop and it would run after a failure instead. // src/tilemap.c carries the same note over the same shape. 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 < JRPG_FRAME_BUDGET_MS ) { SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent)); } } } } CLEANUP { teardown(); } PROCESS(errctx) { } HANDLE_DEFAULT(errctx) { LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish"); // Set a flag and act on it after FINISH. A bare `return` from inside a // HANDLE block leaves before RELEASE_ERROR and leaks the context's pool // slot; AGENTS.md spells that out, and an example is a bad place to // teach it wrong. exitstatus = 1; } FINISH_NORETURN(errctx); // Enough for the smoke run to be read rather than merely passed. A run that // exits 0 having drawn nothing looks exactly like one that worked. if ( player != NULL ) { printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y); } else { printf("jrpg: %ld frames, no player\n", frameno); } return exitstatus; }