Files
libakgl/examples/jrpg/textbox.c
Andrew Kesterson 88aaa184e4 Add two tutorial games that build, run in CI, and cannot drift
examples/sidescroller and examples/jrpg are complete programs, built with the
library and exercised headless by ctest. The chapters quote them with
`c excerpt=examples/...` blocks rather than restating the code, so a chapter
cannot drift from a program that compiles -- the excerpt check fails the moment
the source moves. 34 excerpts in one chapter, 21 in the other.

The two are complementary. The sidescroller is the physics tutorial: gravity, a
jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map,
NPCs spawned from map objects, four-way per-facing animation, a text box, a
follower.

Both smoke tests drive real SDL_Events through akgl_controller_handle_event and
step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run
is deterministic and finishes in under five seconds.

Writing them is what turned up most of the defects recorded in the next commit,
because a game exercises paths a unit test does not. Each workaround says in the
chapter which library gap forced it:

- collision is written in a custom movementlogicfunc, because
  akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never
  calls collide at all;
- the sidescroller cancels the step's own gravity when it blocks downward,
  because otherwise a quarter-pixel of penetration makes the *horizontal* sweep
  report blocked and the character walks backwards a tile at a time;
- both clear movement_controls_face on every map-spawned actor, because the
  default facefunc leaves a stopped actor with no facing bit, no sprite, and no
  draw;
- the JRPG's follower gets a renderfunc that nulls obj->parent for the duration
  of the draw, because a child's offset is counted twice.

Assets are CC0 from three Kenney packs, vendored with per-pack licence text,
per-file provenance, and the geometry contract in
docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a
reader who copies a tutorial into their own game inherits no obligation.
scripts/fetch_tutorial_assets.sh refreshes them in the shape
mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status,
refuses a pack page that does not say CC0, verifies the archive and the staged
dimensions, and leaves the tracked bytes untouched on any failure. Both failure
paths were tested, and a no-op refresh is byte-identical.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00

126 lines
3.9 KiB
C

/**
* @file textbox.c
* @brief The dialogue panel: two rectangles and a string, drawn over the world.
*
* Text in libakgl is immediate mode. Every akgl_text_rendertextat call
* rasterizes the string through SDL_ttf, uploads it as a texture, blits it and
* destroys the texture again -- there is no cached glyph atlas and no text
* object to hold on to. That is the wrong shape for a page of static prose
* redrawn sixty times a second and exactly the right shape for this: one short
* line, on screen only while somebody is talking.
*/
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.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/text.h>
#include "jrpg.h"
/*
* Colour R G B A
*/
static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 };
static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 };
static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 };
/** @brief Pixels between the panel and the edges of the screen. */
#define TEXTBOX_MARGIN 8.0f
/** @brief Panel height in pixels. Two lines of 12pt monospace, plus padding. */
#define TEXTBOX_HEIGHT 56.0f
/** @brief Pixels between the panel's edge and the text inside it. */
#define TEXTBOX_PADDING 8.0f
static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
static bool textbox_visible = false;
bool jrpg_textbox_showing(void)
{
return textbox_visible;
}
void jrpg_textbox_close(void)
{
textbox_visible = false;
}
akerr_ErrorContext *jrpg_textbox_open(char *text)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
// aksl_strncpy, not strncpy: this is a fixed-width field, and strncpy at
// exactly the field width leaves it unterminated. Bounded at one less than
// the field so an over-long line truncates rather than being refused.
PASS(errctx,
aksl_strncpy(
textbox_text,
sizeof(textbox_text),
text,
sizeof(textbox_text) - 1
));
textbox_visible = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *jrpg_textbox_draw(void)
{
PREPARE_ERROR(errctx);
TTF_Font *font = NULL;
SDL_FRect panel;
if ( textbox_visible == false ) {
SUCCEED_RETURN(errctx);
}
// akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text
// has zero width" and the library passes that on as AKERR_NULLPOINTER,
// while akgl_text_measure accepts it. The two disagree, so anything drawing
// a line that might be empty checks for it. TODO.md, "Known and still
// open".
if ( textbox_text[0] == '\0' ) {
SUCCEED_RETURN(errctx);
}
// Fonts live in a registry under a name, not in an akgl type. There is no
// handle to keep, and no reference counting either: whoever calls
// akgl_text_unloadfont invalidates every pointer anybody else fetched.
// Fetching it per frame is the cheap way to stay honest about that.
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
FAIL_ZERO_RETURN(
errctx,
font,
AKGL_ERR_REGISTRY,
"No font registered as \"%s\"",
JRPG_FONT_NAME
);
// Screen coordinates, from the camera's size rather than from a second copy
// of the window dimensions. akgl_render_2d_init put them there.
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
panel.h = TEXTBOX_HEIGHT;
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
PASS(errctx,
akgl_text_rendertextat(
font,
textbox_text,
TEXTBOX_INK,
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
(int)(panel.x + TEXTBOX_PADDING),
(int)(panel.y + TEXTBOX_PADDING)
));
SUCCEED_RETURN(errctx);
}