Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs #3
@@ -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
|
||||
$<TARGET_FILE:uidemo>
|
||||
--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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
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