/** * @file main.c * @brief Startup, the frame loop, and teardown for the sidescroller tutorial. * * The order everything happens in is the point of this file. libakgl has one * startup sequence that works, documented at the top of `include/akgl/game.h`, * and three of its steps are ones a reader gets wrong the first time: * * - the configuration properties have to be set *before* the renderer and the * physics backend are initialized, because both read them; * - `akgl_game_init` does **not** choose a physics backend, so the application * has to; * - characters name sprites and maps name characters, so the assets load * sprites, then characters, then the map -- and the map creates the actors. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "sidescroller.h" /** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */ #ifndef SS_ASSET_DIR #define SS_ASSET_DIR "." #endif /** @brief Longest asset path this program will build. */ #define SS_PATH_MAX 1024 ss_Game ss_game; /** * @brief The sprites, loaded in this order. * * Sprites first and characters second is not a preference. A character's JSON * names its sprites by registry name and `akgl_character_load_json` looks each * one up as it reads the mapping, so a character loaded first fails with * `AKERR_NULLPOINTER` on the first sprite it cannot find. */ static char *ss_sprite_files[] = { "sprite_ss_player_idle_left.json", /* one frame, held */ "sprite_ss_player_idle_right.json", "sprite_ss_player_run_left.json", /* four frames at 90 ms */ "sprite_ss_player_run_right.json", "sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */ "sprite_ss_player_jump_right.json", "sprite_ss_coin.json", "sprite_ss_hazard_blob.json", "sprite_ss_hazard_moth.json", NULL }; /** @brief The characters. Each one binds state bitmasks to the sprites above. */ static char *ss_character_files[] = { "character_ss_player.json", "character_ss_coin.json", "character_ss_hazard_blob.json", "character_ss_hazard_moth.json", NULL }; /** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */ static int ss_failed = 0; /** * @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame. * * The default is `akgl_game_lowfps`, and it fires on **every frame** the frame * rate is under 30 -- including every frame of the first second of the process, * because `akgl_game.fps` is a completed-second average and reads 0 until the * first second is up. The hook exists to be replaced; the point of it is that a * game can shed work rather than log about it. This one has nothing to shed. */ static void ss_lowfps(void) { } /** * @brief Join the asset directory and a file name into @p dest. * * `aksl_snprintf` rather than `snprintf`, because a path that does not fit has * to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it * reports itself later as a missing file with a name nobody wrote. */ static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size) { int count = 0; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir"); FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name)); SUCCEED_RETURN(errctx); } /** * @brief Bring the library up and open the window. * * `akgl_game.name`, `.version` and `.uri` are filled in first because * `akgl_game_init` refuses to run without all three: the window title, SDL's * application metadata and the savegame compatibility check are built from them. */ static akerr_ErrorContext *startup(void) { PREPARE_ERROR(errctx); PASS(errctx, aksl_strncpy( (char *)&akgl_game.name, sizeof(akgl_game.name), "libakgl sidescroller tutorial", sizeof(akgl_game.name) - 1)); PASS(errctx, aksl_strncpy( (char *)&akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1)); PASS(errctx, aksl_strncpy( (char *)&akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.sidescroller", sizeof(akgl_game.uri) - 1)); PASS(errctx, akgl_game_init()); akgl_game.lowfpsfunc = &ss_lowfps; /* Properties before the renderer: akgl_render_2d_init reads both of these * out of the registry, and an unset one defaults to the string "0", which * asks SDL for a zero-sized window. */ PASS(errctx, akgl_set_property("game.screenwidth", "960")); PASS(errctx, akgl_set_property("game.screenheight", "480")); PASS(errctx, akgl_render_2d_init(akgl_renderer)); /* * libakgl draws in map pixels and has no scale factor of its own -- the only * scaling it applies is the tilemap's perspective band, which this map does * not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more * than that. SDL's logical presentation is the answer: the game renders a * 480x240 view and SDL scales it up by whole multiples to fill the window. */ FAIL_ZERO_RETURN( errctx, SDL_SetRenderLogicalPresentation( akgl_renderer->sdl_renderer, SS_VIEW_WIDTH, SS_VIEW_HEIGHT, SDL_LOGICAL_PRESENTATION_INTEGER_SCALE), AKGL_ERR_SDL, "%s", SDL_GetError() ); /* akgl_render_2d_init sized the camera from the window. The view is what the * camera looks through, so it has to say the same thing. */ akgl_camera->x = 0.0f; akgl_camera->y = 0.0f; akgl_camera->w = (float32_t)SS_VIEW_WIDTH; akgl_camera->h = (float32_t)SS_VIEW_HEIGHT; /* * akgl_game_init does NOT choose a physics backend. It points akgl_physics * at akgl_default_physics, which is zeroed storage -- all four of its method * pointers are NULL -- and never initializes it. There is no * `physics.engine` property either, whatever physics.h says. Skip this call * and the first akgl_game_update calls through a NULL `simulate`. * * The map replaces this backend below with one of its own. It is still done * here, because a game that loads a map without physics properties gets a * working backend rather than a crash. */ PASS(errctx, akgl_physics_init_arcade(akgl_physics)); SUCCEED_RETURN(errctx); } /** * @brief Load the sprites, the characters and the level, in that order. */ static akerr_ErrorContext *load_level(char *assetdir) { char path[SS_PATH_MAX]; akgl_Actor *player = NULL; int i = 0; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); for ( i = 0; ss_sprite_files[i] != NULL; i++ ) { PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path))); PASS(errctx, akgl_sprite_load_json((char *)&path)); } for ( i = 0; ss_character_files[i] != NULL; i++ ) { PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path))); PASS(errctx, akgl_character_load_json((char *)&path)); } /* * `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static * storage in the library. That is not an accident and it is not a * micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB * thread stack, so a local one is a segfault before the loader writes a * byte. * * Loading the map creates every `actor` object in its object layers, binds * each to the character its properties name, and publishes it in * AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first. */ PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path))); PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap)); /* * The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`, * so the loader built a backend of its own and set `use_own_physics`. * Nothing in the library acts on that flag: akgl_game_update steps the * global akgl_physics and never looks at the map's. Honouring it is this * line, and it is what lets a swimming level and a walking level differ by * data rather than by code. */ if ( akgl_gamemap->use_own_physics == true ) { akgl_physics = &akgl_gamemap->physics; SDL_Log( "Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s", akgl_physics->gravity_y, akgl_physics->drag_y, (akgl_physics->gravity_y / akgl_physics->drag_y)); } PASS(errctx, ss_collide_bind(akgl_gamemap)); player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL); FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player"); PASS(errctx, ss_player_bind(player)); PASS(errctx, ss_actors_bind()); PASS(errctx, ss_player_controls(0, "player")); /* * Re-stamp the clock the simulation measures against. Everything above -- * nine sprite files, four characters, a map and its tileset image -- happened * between the backend being created and the first step, and dt is measured * from `gravity_time`. The bound on `physics.max_timestep` would catch it, * at the cost of one visibly slow-motion frame. */ akgl_physics->gravity_time = SDL_GetTicksNS(); SUCCEED_RETURN(errctx); } /** * @brief Centre the camera on the player, clamped to the level. * * The camera is a plain `SDL_FRect` in map pixels that the library reads; moving * it is the whole of scrolling. It is floored to a whole pixel because * `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles * to show, and a camera that is fractionally different every frame makes the * tile grid shimmer. */ static akerr_ErrorContext *update_camera(void) { float32_t limit = 0.0f; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player"); FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera"); limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w; akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f); if ( akgl_camera->x > limit ) { akgl_camera->x = limit; } if ( akgl_camera->x < 0.0f ) { akgl_camera->x = 0.0f; } akgl_camera->x = (float32_t)((int)akgl_camera->x); akgl_camera->y = 0.0f; SUCCEED_RETURN(errctx); } /** * @brief One frame: events, then the camera, then the library's own tick. * * `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It * does not clear or present, so the frame is bracketed by the backend's * `frame_start` and `frame_end` here. */ static akerr_ErrorContext *frame(bool *running) { SDL_Event event; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); while ( SDL_PollEvent(&event) == true ) { if ( event.type == SDL_EVENT_QUIT ) { *running = false; } /* Every event, unconditionally: one that no control map binds is not an * error, it is a call that did nothing. */ PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); } ss_game.frame += 1; if ( ss_game.autoplay == true ) { PASS(errctx, ss_player_autoplay(ss_game.frame)); } PASS(errctx, update_camera()); PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); /* * Note the failure contract: akgl_game_update takes the game state lock and * every one of its failure paths returns with the lock still held. SDL * mutexes are recursive so a single-threaded loop does not deadlock on the * next frame, but a frame that failed has left the world half-stepped. * Treat it as terminal, which is what PASS does here. */ PASS(errctx, akgl_game_update(NULL)); PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); SUCCEED_RETURN(errctx); } static akerr_ErrorContext *run(int frames) { bool running = true; PREPARE_ERROR(errctx); while ( running == true ) { PASS(errctx, frame(&running)); if ( (frames > 0) && (ss_game.frame >= frames) ) { running = false; } /* A crude frame limiter. A game with a window on a real display should * ask SDL for vsync instead; this one has to work under the dummy video * driver, where there is nothing to sync to. */ SDL_Delay(16); } SUCCEED_RETURN(errctx); } /** * @brief Give back what the process is holding. * * **There is no `akgl_game_shutdown`.** Teardown is the application's, and the * order matters in one place: `akgl_text_unloadallfonts` has to run before * `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the * registry that still points at them. * * The pools are not walked beyond the actors. They are static storage in a * process that is exiting, and the sprites and characters are still referenced * by each other; a game that loads a second level has to unwind that properly, * and this one does not pretend to. */ static void shutdown_game(void) { int i = 0; IGNORE(akgl_text_unloadallfonts()); for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { if ( akgl_heap_actors[i].refcount > 0 ) { IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i])); } } /* Guarded: this runs from CLEANUP, which is reached even when startup failed * before akgl_game_init pointed akgl_gamemap at anything. */ if ( akgl_gamemap != NULL ) { IGNORE(akgl_tilemap_release(akgl_gamemap)); } TTF_Quit(); MIX_Quit(); SDL_Quit(); } static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames) { const char *env = NULL; int i = 0; PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames"); env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES"); if ( env != NULL ) { PASS(errctx, aksl_atoi((char *)env, frames)); } for ( i = 1; i < argc; i++ ) { if ( strcmp(argv[i], "--autoplay") == 0 ) { ss_game.autoplay = true; } else if ( strcmp(argv[i], "--frames") == 0 ) { i += 1; FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count"); PASS(errctx, aksl_atoi(argv[i], frames)); } else if ( strcmp(argv[i], "--assets") == 0 ) { i += 1; FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory"); *assetdir = argv[i]; } else { FAIL_RETURN( errctx, AKERR_VALUE, "usage: sidescroller [--assets DIR] [--frames N] [--autoplay]" ); } } SUCCEED_RETURN(errctx); } int main(int argc, char *argv[]) { char *assetdir = SS_ASSET_DIR; int frames = 0; PREPARE_ERROR(errctx); ATTEMPT { CATCH(errctx, parse_args(argc, argv, &assetdir, &frames)); CATCH(errctx, startup()); CATCH(errctx, load_level(assetdir)); CATCH(errctx, run(frames)); } CLEANUP { shutdown_game(); } PROCESS(errctx) { } HANDLE_DEFAULT(errctx) { LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run"); /* * Set a flag rather than returning: leaving a HANDLE block early skips * FINISH's RELEASE_ERROR and leaks the context's pool slot, and the * 129th such call aborts the process. AGENTS.md spells it out. */ ss_failed = 1; /* * FINISH_NORETURN rather than FINISH: FINISH expands a * `return __err_context` that an int-returning function cannot compile, * even on a branch that cannot be reached. */ } FINISH_NORETURN(errctx); SDL_Log( "sidescroller: %d frames, %d of %d coins, %d deaths", ss_game.frame, ss_game.coins_taken, SS_COIN_COUNT, ss_game.deaths); return ss_failed; }