/** * @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; }