Add the UI demo: title menu, raw-CLAY options screen, HUD and dialog
examples/uidemo is the program the UI chapter quotes. Three screens, three ways of building an interface: the title screen is one akgl_UiMenu plus a heading label; the options screen is written in raw CLAY() declarations -- hover highlighting inside the declaration, clicks paired with the application's own press edge -- because the widgets are a convenience, not a boundary; and the play screen is two HUD labels and the one-call dialog over a checkerboard standing in for a game world. No tilemap, no actors, no physics: everything left on screen is the subject. Its route_event() is the documented call-order contract in the flesh -- UI first, consumed events stop there, then the current screen's keys -- and the screen routing is the focus model, stated rather than invented. The headless smoke run (example_uidemo, --frames 240 --demo) tours every path through the real event chain: a mouse click on the centred menu (the consumed path, landing on the middle row by symmetry), keyboard into and out of the options screen, the dialog opened and dismissed, and Quit confirmed from the menu. The run prints its score and settings so a pass can be read, not just counted. docs_game_figures gains a uidemo frame -- the play screen with the dialog up -- for the chapter to come. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
59
examples/uidemo/CMakeLists.txt
Normal file
59
examples/uidemo/CMakeLists.txt
Normal file
@@ -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()
|
||||
609
examples/uidemo/uidemo.c
Normal file
609
examples/uidemo/uidemo.c
Normal file
@@ -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 <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/ui.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
58
examples/uidemo/uidemo.h
Normal file
58
examples/uidemo/uidemo.h
Normal file
@@ -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 <SDL3/SDL.h>
|
||||
|
||||
/** @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_
|
||||
Reference in New Issue
Block a user