diff --git a/CMakeLists.txt b/CMakeLists.txt index 27bc9dd..0d07f9b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -861,7 +861,15 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") --demo --frames 240 --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png" --screenshot-frame 230 - DEPENDS sidescroller jrpg + # Frame 100 of the UI demo's scripted tour is the play screen with the + # dialog up and the score mid-count -- all three widgets in one frame. + 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/uidemo.png" + --screenshot-frame 100 + DEPENDS sidescroller jrpg uidemo COMMENT "Regenerating the tutorial figures in docs/images" VERBATIM ) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 4b444df..adf1cfb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,4 +1,4 @@ -# The two tutorial games. +# The tutorial games and demos. # # Each one is a complete, running program that the matching chapter quotes with # `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot @@ -9,7 +9,7 @@ # passes and land at different times, and a configure that fails because one of # them is not there yet would block the other. There is nothing clever about the # guard; it exists so an incomplete tree still builds. -foreach(_example sidescroller jrpg) +foreach(_example sidescroller jrpg uidemo) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt") add_subdirectory(${_example}) endif() diff --git a/examples/uidemo/CMakeLists.txt b/examples/uidemo/CMakeLists.txt new file mode 100644 index 0000000..65d87d6 --- /dev/null +++ b/examples/uidemo/CMakeLists.txt @@ -0,0 +1,59 @@ +# The UI demo, quoted by the UI chapter's `c excerpt=` blocks. +# +# A real target built by `all`, on the same terms as the tutorial games: a +# chapter whose program does not compile is the failure the documentation +# harness exists to stop. + +add_executable(uidemo + uidemo.c +) + +target_link_libraries(uidemo + PRIVATE akstdlib::akstdlib akerror::akerror akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer + jansson::jansson -lm) +target_include_directories(uidemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_options(uidemo PRIVATE ${AKGL_WARNING_FLAGS}) + +# The demo needs no art -- its "world" is akgl_draw_background -- but it does +# need one font, and it uses the same test-asset face the JRPG does, compiled +# in as an absolute path for the same run-from-anywhere reason. +get_filename_component(UIDEMO_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +target_compile_definitions(uidemo PRIVATE + UIDEMO_FONT_FILE="${UIDEMO_REPO_ROOT}/tests/assets/akgl_test_mono.ttf" +) + +if(AKGL_VENDORED_DEPENDENCIES) + set_target_properties(uidemo PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") +endif() + +# The headless smoke run. `--demo` tours every screen through the real event +# chain -- a mouse click on the title menu (the consumed path), keyboard into +# and out of the raw-CLAY options screen, the play screen's dialog opened and +# dismissed, and Quit confirmed from the menu -- rather than just proving that +# main() returns. There is no physics here, so nothing needs a driven clock; +# `--frames` is a backstop above the script's last step, not the exit path. +add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo) +set_tests_properties(example_uidemo PROPERTIES + TIMEOUT 120 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +) + +# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has +# to go in the same property rather than a second one. Only needed when the +# dependencies were vendored; an installed build resolves them normally. +if(AKGL_VENDORED_DEPENDENCIES) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + set(UIDEMO_TEST_ENV_MOD "") + foreach(dir IN LISTS AKGL_TEST_LIBPATH) + list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") + endforeach() + set_tests_properties(example_uidemo + PROPERTIES ENVIRONMENT_MODIFICATION "${UIDEMO_TEST_ENV_MOD}") + else() + string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") + set_tests_properties(example_uidemo PROPERTIES + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + ) + endif() +endif() diff --git a/examples/uidemo/uidemo.c b/examples/uidemo/uidemo.c new file mode 100644 index 0000000..d8152cf --- /dev/null +++ b/examples/uidemo/uidemo.c @@ -0,0 +1,609 @@ +/** + * @file uidemo.c + * @brief The UI demo: a title menu, an options screen, and a HUD, three ways. + * + * The chapter on the UI subsystem quotes this program rather than restating + * it. Run it with no arguments for a window: + * + * ./examples/uidemo/uidemo + * + * The title menu answers to the arrow keys, Return, the D-pad, and the mouse. + * Start opens a stand-in play screen -- a checkerboard where a game would be + * -- with a score counter ticking in a HUD label; Space opens and closes a + * dialog panel there, and Escape backs out. Options is a screen written in + * raw CLAY() declarations, because the widgets are a convenience, not a + * boundary. `--frames N` bounds the run and `--demo` drives the whole tour + * from a script, which is what makes this runnable as a headless smoke test. + * + * What is deliberately absent: a tilemap, actors, physics. The world here is + * one draw call, so everything left is the subject -- what a UI costs and + * where it goes in a frame. + */ + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "uidemo.h" + +/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */ +#define UIDEMO_FRAME_BUDGET_MS 16 + +/* + * The scripted tour `--demo` drives. Every route through the demo, by every + * input device it supports: the mouse clicks the title menu open (dead centre + * lands on the middle row by symmetry -- the menu is centred and Options is + * its middle entry), the keyboard walks back out, into the play screen, + * through the dialog, and finally down to Quit. + * + * Frame Event Key / position What it proves + */ +static const uidemo_ScriptStep UIDEMO_SCRIPT[] = { + { 5, SDL_EVENT_MOUSE_MOTION, 0, 320.0f, 240.0f }, /* hover the menu */ + { 10, SDL_EVENT_MOUSE_BUTTON_DOWN, 0, 320.0f, 240.0f }, /* consumed click */ + { 11, SDL_EVENT_MOUSE_BUTTON_UP, 0, 320.0f, 240.0f }, /* -> Options screen */ + { 40, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 50, SDL_EVENT_KEY_DOWN, SDLK_UP, 0.0f, 0.0f }, /* select Start */ + { 60, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f }, /* -> play screen */ + { 80, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* open the dialog */ + { 120, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* close it again */ + { 140, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 150, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Options */ + { 160, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Quit */ + { 170, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f } /* and out */ +}; + +#define UIDEMO_SCRIPT_STEPS (sizeof(UIDEMO_SCRIPT) / sizeof(UIDEMO_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 Which screen the frame declares. */ +static uidemo_State state = UIDEMO_STATE_TITLE; + +/** + * @brief The title menu. Caller-owned state, zero machinery: this struct and + * the akgl_ui_menu call each frame are the whole main menu. + */ +static akgl_UiMenu title_menu = { + .id = "title", + .items = { "Start", "Options", "Quit" }, + .count = 3, +}; + +/** @brief The options screen's two settings. What the toggles flip. */ +static bool opt_music = true; +static bool opt_sound = true; + +/** @brief The play screen's HUD numbers. The score ticks so the label visibly updates. */ +static int score = 0; +static int lives = 3; +/** @brief Whether the play screen's dialog is up. Space flips it. */ +static bool dialog_open = false; + +/** + * @brief A left click arrived this frame, wherever it landed. + * + * The raw CLAY() options screen needs a press edge to pair with + * Clay_Hovered(), and the application is the right owner of it: it sees + * every event before the UI does. Set in route_event, cleared at the end of + * each frame. The widget helpers keep their own edge internally -- this one + * exists precisely because the options screen does not use them. + */ +static bool clicked = false; + +/** @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. */ +static void lowfps_quiet(void) +{ + if ( lowfps_warned == false ) { + lowfps_warned = true; + SDL_Log("Frame rate is under 30 and this demo does nothing about it"); + } +} + +/** + * @brief Read `--frames N`, `--demo` and the screenshot flags 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 a flag needing an argument 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 number = 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], &number)); + frame_limit = number; + } 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], &number)); + shotframe = number; + } else { + FAIL_RETURN(errctx, AKERR_VALUE, + "usage: uidemo [--frames N] [--demo]" + " [--screenshot PATH] [--screenshot-frame N]"); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Bring the library and the UI subsystem up. + * + * The UI half is three calls: load a font, akgl_ui_init at the screen size, + * and register the font for a clay fontId. The id comes back 0 because it is + * the first registered, which is what the widgets' default style uses -- so + * nothing here ever mentions a fontId again. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + uint16_t fontid = 0; + + PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl UI demo", 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.uidemo", sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &lowfps_quiet; + + PASS(errctx, akgl_set_property("game.screenwidth", UIDEMO_SCREEN_WIDTH)); + PASS(errctx, akgl_set_property("game.screenheight", UIDEMO_SCREEN_HEIGHT)); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE)); + PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT)); + PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid)); + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Route one event: the UI first, then whatever the current screen wants. + * + * This is the call-order contract from akgl/ui.h in the flesh. The UI gets + * first refusal; a consumed event goes no further, which is what keeps a + * click on a menu from also being a click in the game. Only then does the + * current screen read the keyboard -- and *that* routing is the whole focus + * model: the title menu hears keys because this function sends them there + * while the title screen is up, not because anything owns "focus". + * + * @param event The event to route. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *route_event(SDL_Event *event) +{ + PREPARE_ERROR(errctx); + bool consumed = false; + + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + + if ( event->type == SDL_EVENT_QUIT ) { + running = false; + SUCCEED_RETURN(errctx); + } + // The options screen's press edge, recorded before the UI can consume + // the event -- a click on a toggle row is *always* consumed (the row is + // UI), so waiting until afterwards would record nothing. + if ( (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) + && (event->button.button == SDL_BUTTON_LEFT) ) { + clicked = true; + } + + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed)); + break; + case UIDEMO_STATE_OPTIONS: + if ( (event->type == SDL_EVENT_KEY_DOWN) && (event->key.key == SDLK_ESCAPE) ) { + state = UIDEMO_STATE_TITLE; + } + break; + case UIDEMO_STATE_PLAY: + if ( event->type == SDL_EVENT_KEY_DOWN ) { + if ( event->key.key == SDLK_ESCAPE ) { + state = UIDEMO_STATE_TITLE; + } else if ( event->key.key == SDLK_SPACE ) { + dialog_open = !dialog_open; + } + } + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Feed the scripted events due on this frame through the real routing. + * + * Synthesized SDL_Events, not direct state changes: the demo run proves the + * event chain -- consumed and not, menu and screen -- because it travels it. + * + * @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 < UIDEMO_SCRIPT_STEPS; i++ ) { + if ( UIDEMO_SCRIPT[i].frame != frameno ) { + continue; + } + PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event))); + event.type = UIDEMO_SCRIPT[i].type; + switch ( UIDEMO_SCRIPT[i].type ) { + case SDL_EVENT_KEY_DOWN: + event.key.key = UIDEMO_SCRIPT[i].key; + break; + case SDL_EVENT_MOUSE_MOTION: + event.motion.x = UIDEMO_SCRIPT[i].x; + event.motion.y = UIDEMO_SCRIPT[i].y; + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + event.button.button = SDL_BUTTON_LEFT; + event.button.x = UIDEMO_SCRIPT[i].x; + event.button.y = UIDEMO_SCRIPT[i].y; + break; + default: + break; + } + PASS(errctx, route_event(&event)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the title screen: one widget call. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_title(void) +{ + PREPARE_ERROR(errctx); + PASS(errctx, akgl_ui_label("heading", "libakgl UI demo", AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + PASS(errctx, akgl_ui_menu(&title_menu)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the options screen in raw CLAY(), no widgets anywhere. + * + * This screen exists to show the other way in: a centred panel, a column of + * rows, hover highlighting via Clay_Hovered() *inside* the declaration, and + * clicks paired with the application's own press edge. Everything the widgets + * do is done with these same pieces; a screen the widgets cannot express is + * written like this rather than waiting for a widget to exist. + * + * The row labels live in statics because clay borrows the pointer until + * frame_end -- a local buffer here would be dangling by the time the text is + * drawn. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_options(void) +{ + PREPARE_ERROR(errctx); + static char musicrow[32]; + static char soundrow[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, musicrow, sizeof(musicrow), "Music: %s", opt_music ? "ON" : "OFF")); + PASS(errctx, aksl_snprintf(&count, soundrow, sizeof(soundrow), "Sound: %s", opt_sound ? "ON" : "OFF")); + + CLAY({ + .id = CLAY_ID("options"), + .layout = { + .padding = { 16, 16, 16, 16 }, + .childGap = 8, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = { 24, 20, 37, 235 }, + .border = { .color = { 240, 236, 214, 255 }, .width = { 1, 1, 1, 1, 0 } }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(CLAY_STRING("OPTIONS"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + + CLAY({ + .id = CLAY_ID("options-music"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_music = !opt_music; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-sound"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_sound = !opt_sound; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(soundrow), .chars = soundrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-back"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + state = UIDEMO_STATE_TITLE; + } + CLAY_TEXT(CLAY_STRING("Back (Esc)"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the play screen's HUD: two labels, and the dialog while it is up. + * + * The score buffer is static for the same borrowed-until-frame_end reason as + * the options rows. Note what is *not* here: no visible flag, no draw call, + * no geometry. The dialog is open because this frame declares it. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_play(void) +{ + PREPARE_ERROR(errctx); + static char scoretext[32]; + static char livestext[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score)); + PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives)); + PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + if ( dialog_open ) { + PASS(errctx, akgl_ui_dialog("dialog", + "This panel is one call. Space dismisses it; " + "compare examples/jrpg/textbox.c.", + NULL)); + } + SUCCEED_RETURN(errctx); +} + +/** @brief Read the render target back and write it out as a PNG, for the chapter figure. */ +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, the world stand-in, the UI bracket, present. + * + * The shape to compare with the JRPG's frame(): where that program calls + * akgl_game_update between frame_start and frame_end, this one paints a + * checkerboard -- and the UI bracket sits in the same overlay slot the + * hand-rolled textbox draw did. + * + * @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; + + while ( SDL_PollEvent(&event) ) { + PASS(errctx, route_event(&event)); + } + if ( demo ) { + PASS(errctx, demo_step(frameno)); + } + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + if ( state == UIDEMO_STATE_PLAY ) { + // Where a game would call akgl_game_update. The score ticking is the + // stand-in for play, so the HUD label visibly earns its redraw. + PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT)); + score += 1; + } + + PASS(errctx, akgl_ui_frame_begin()); + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, declare_title()); + break; + case UIDEMO_STATE_OPTIONS: + PASS(errctx, declare_options()); + break; + case UIDEMO_STATE_PLAY: + PASS(errctx, declare_play()); + break; + default: + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + + if ( (shotpath != NULL) && (frameno == shotframe) ) { + PASS(errctx, save_screenshot(shotpath)); + } + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + + // The press edge lives exactly one frame: the declarations above have + // seen it, so the next frame starts clean. + clicked = false; + + // Menu activation is read after the frame, so it catches both routes in: + // Return through akgl_ui_menu_handle_event before the declarations, and a + // click through the declaration itself. + if ( title_menu.activated ) { + title_menu.activated = false; + switch ( title_menu.selected ) { + case 0: + state = UIDEMO_STATE_PLAY; + score = 0; + dialog_open = false; + break; + case 1: + state = UIDEMO_STATE_OPTIONS; + break; + case 2: + default: + running = false; + break; + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Give back what has to be given back, in the order that works. + * + * akgl_ui_shutdown first because it is the newest thing up, then the fonts + * **before** SDL_Quit -- the registry the fonts live in is an SDL property + * set, and SDL_Quit destroys it with them inside. The pools and the clay + * arena are static storage; there is nothing to free. + */ +static void teardown(void) +{ + IGNORE(akgl_ui_shutdown()); + 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); + long frameno = 0; + uint64_t started = 0; + uint64_t spent = 0; + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv)); + CATCH(errctx, startup()); + + // The loop is the last thing in this ATTEMPT block on purpose: CATCH + // reports failure by `break`ing out of the loop, which falls straight + // into CLEANUP. See examples/jrpg/jrpg.c for the long form of this + // note. + 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 < UIDEMO_FRAME_BUDGET_MS ) { + SDL_Delay((uint32_t)(UIDEMO_FRAME_BUDGET_MS - spent)); + } + } + } + } CLEANUP { + teardown(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the UI demo could not finish"); + exitstatus = 1; + } FINISH_NORETURN(errctx); + + // Enough for the smoke run to be read rather than merely passed: the + // script quits from the title menu, so a run that finished its tour says + // so here. + printf("uidemo: %ld frames, score %d, music %s, sound %s\n", + frameno, score, opt_music ? "on" : "off", opt_sound ? "on" : "off"); + return exitstatus; +} diff --git a/examples/uidemo/uidemo.h b/examples/uidemo/uidemo.h new file mode 100644 index 0000000..ba6e1e3 --- /dev/null +++ b/examples/uidemo/uidemo.h @@ -0,0 +1,58 @@ +/** + * @file uidemo.h + * @brief Shared constants and types for the UI demo. + * + * There is deliberately little here: the demo is one translation unit, and + * this header exists so the chapter can quote the constants and the script + * type without quoting the code around them. + */ + +#ifndef _UIDEMO_H_ +#define _UIDEMO_H_ + +#include + +/** @brief Window and layout width, in pixels. */ +#define UIDEMO_SCREEN_WIDTH "640" +/** @brief Window and layout height, in pixels. */ +#define UIDEMO_SCREEN_HEIGHT "480" +/** @brief The same width as a number, for akgl_ui_init. */ +#define UIDEMO_WIDTH 640 +/** @brief The same height as a number. */ +#define UIDEMO_HEIGHT 480 + +/** @brief Registry name the demo's one font is loaded under. */ +#define UIDEMO_FONT_NAME "uidemo" +/** @brief Point size the font is loaded at. The size is baked into the handle. */ +#define UIDEMO_FONT_SIZE 16 + +/** + * @brief Which screen the demo is on. + * + * Three states, three ways of building an interface: the title screen is the + * menu *widget*, the options screen is raw `CLAY()` layout, and the play + * screen is the label and dialog widgets over a moving world stand-in. + */ +typedef enum { + UIDEMO_STATE_TITLE, /**< The akgl_UiMenu title menu. */ + UIDEMO_STATE_OPTIONS, /**< The hand-written CLAY() options panel. */ + UIDEMO_STATE_PLAY /**< HUD labels and a dialog over the "game". */ +} uidemo_State; + +/** + * @brief One scripted input in a `--demo` run. + * + * Key steps carry a keycode; mouse steps carry a position. Every step is + * synthesized as a real SDL_Event and pushed through the same routing the + * interactive loop uses, so the demo exercises the event chain rather than + * poking state behind its back. + */ +typedef struct { + long frame; /**< The frame this step fires on. */ + uint32_t type; /**< SDL_EVENT_KEY_DOWN, _MOUSE_MOTION, _MOUSE_BUTTON_DOWN or _MOUSE_BUTTON_UP. */ + SDL_Keycode key; /**< For key steps. */ + float x; /**< For mouse steps. */ + float y; /**< For mouse steps. */ +} uidemo_ScriptStep; + +#endif // _UIDEMO_H_