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>
This commit is contained in:
72
examples/jrpg/CMakeLists.txt
Normal file
72
examples/jrpg/CMakeLists.txt
Normal file
@@ -0,0 +1,72 @@
|
||||
# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md.
|
||||
#
|
||||
# It is a real target built by `all`, not a snippet: a tutorial whose program
|
||||
# does not compile is the failure this whole documentation effort exists to
|
||||
# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves
|
||||
# under them.
|
||||
|
||||
add_executable(jrpg
|
||||
jrpg.c
|
||||
world.c
|
||||
textbox.c
|
||||
)
|
||||
|
||||
target_link_libraries(jrpg
|
||||
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(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS})
|
||||
|
||||
# The game finds its data through two absolute paths baked in here. A game that
|
||||
# ships would install its assets and resolve them against SDL_GetBasePath(); a
|
||||
# tutorial has to run from the build tree, from the source tree, and from
|
||||
# whatever working directory CTest hands it, so the paths are compiled in.
|
||||
get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
target_compile_definitions(jrpg PRIVATE
|
||||
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
|
||||
# tests/assets/akgl_test_mono.ttf, which ships beside its licence file.
|
||||
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
|
||||
)
|
||||
|
||||
# The vendored SDL satellite libraries land in per-project subdirectories that
|
||||
# are not on the loader's default search path, exactly as they do for the test
|
||||
# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only
|
||||
# defined when something was actually vendored.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||
endif()
|
||||
|
||||
# The headless smoke run. `--demo` drives the arrow keys from a script and steps
|
||||
# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes
|
||||
# in well under a second; `--frames` bounds it. Between them the run walks the
|
||||
# player into a wall, opens the text box, and exercises the follower's render
|
||||
# hook -- rather than just proving that main() returns.
|
||||
#
|
||||
# add_test() reaches the real command here: the top-level CMakeLists.txt shadows
|
||||
# it to suppress the vendored projects' registrations and lifts the suppression
|
||||
# again long before examples/ is added.
|
||||
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
|
||||
set_tests_properties(example_jrpg 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(JRPG_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(example_jrpg
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
|
||||
else()
|
||||
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
369
examples/jrpg/jrpg.c
Normal file
369
examples/jrpg/jrpg.c
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* @file jrpg.c
|
||||
* @brief A small top-down JRPG: startup, the frame loop, and teardown.
|
||||
*
|
||||
* Chapter 20 of the manual quotes this program rather than restating it, so
|
||||
* what is here is what the chapter teaches. Run it with no arguments for a
|
||||
* window you can walk around in:
|
||||
*
|
||||
* ./examples/jrpg/jrpg
|
||||
*
|
||||
* Arrow keys walk, space talks to whoever is standing next to you and dismisses
|
||||
* the box again. `--frames N` stops after N frames, which is what makes this
|
||||
* runnable as a headless smoke test; `--demo` drives the arrow keys from a
|
||||
* script and steps the physics clock by a fixed interval so the run is
|
||||
* deterministic and takes no wall-clock time.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
|
||||
#include "jrpg.h"
|
||||
|
||||
/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */
|
||||
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
|
||||
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
|
||||
#define JRPG_FRAME_BUDGET_MS 16
|
||||
|
||||
/*
|
||||
* The scripted walk `--demo` drives. The player starts at (224, 256) and the
|
||||
* elder stands at (272, 112), so this is: right for a second, up for two and a
|
||||
* bit, talk, try to walk while frozen, dismiss, walk away. Every one of those
|
||||
* is a path the interactive game has and a headless run would otherwise never
|
||||
* touch.
|
||||
*
|
||||
* Frame Key Down What it is for
|
||||
*/
|
||||
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
|
||||
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
|
||||
{ 70, SDLK_RIGHT, false },
|
||||
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
|
||||
{ 205, SDLK_UP, false },
|
||||
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
|
||||
{ 216, SDLK_SPACE, false },
|
||||
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
|
||||
{ 245, SDLK_LEFT, false },
|
||||
{ 255, SDLK_SPACE, true }, /* dismiss */
|
||||
{ 256, SDLK_SPACE, false },
|
||||
{ 265, SDLK_DOWN, true }, /* and walk away */
|
||||
{ 285, SDLK_DOWN, false }
|
||||
};
|
||||
|
||||
#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
|
||||
|
||||
static long frame_limit = 0;
|
||||
static bool demo = false;
|
||||
static bool running = true;
|
||||
static int exitstatus = 0;
|
||||
static bool lowfps_warned = false;
|
||||
|
||||
/**
|
||||
* @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second.
|
||||
*
|
||||
* akgl_game.fps is a completed-second average, so it reads 0 for the first
|
||||
* second of every process -- which is under the threshold, so the default hook
|
||||
* logs a line on every frame until the first second is up. The hook exists to
|
||||
* be replaced; a real game sheds work here rather than talking about it.
|
||||
*/
|
||||
static void lowfps_quiet(void)
|
||||
{
|
||||
if ( lowfps_warned == false ) {
|
||||
lowfps_warned = true;
|
||||
SDL_Log("Frame rate is under 30 and this game does nothing about it");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Read `--frames N` and `--demo` 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 `--frames` 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 frames = 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], &frames));
|
||||
frame_limit = frames;
|
||||
} else {
|
||||
FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]");
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Bring the library up, load the town, and bind the controls.
|
||||
*
|
||||
* The order below is the one game.h documents and the only one that works.
|
||||
* Three things in it are not obvious and are what chapter 20 spends its time
|
||||
* on: the properties have to be set between akgl_game_init and
|
||||
* akgl_render_2d_init, because that is what reads them; the physics backend has
|
||||
* to be initialized by hand, because akgl_game_init does not; and the assets
|
||||
* have to be loaded sprites-then-characters-then-map.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *startup(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Control talk;
|
||||
|
||||
// Required before akgl_game_init, which refuses to run without all three:
|
||||
// the window title, SDL's application metadata and the savegame
|
||||
// compatibility check are built from them.
|
||||
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", 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.jrpg", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
PASS(errctx, akgl_game_init());
|
||||
akgl_game.lowfpsfunc = &lowfps_quiet;
|
||||
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT));
|
||||
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
|
||||
// akgl_game_init points akgl_physics at akgl_default_physics and stops
|
||||
// there. That storage is BSS, so all four of its method pointers are NULL,
|
||||
// and akgl_game_update calls `akgl_physics->simulate(...)` without checking
|
||||
// it: a NULL function pointer, and a segmentation fault on frame one rather
|
||||
// than an error context.
|
||||
//
|
||||
// town.tmj happens to declare its own physics, and jrpg_world_load switches
|
||||
// to the backend that builds -- so removing this line alone leaves *this*
|
||||
// program working, and removing it along with that switch is a SIGSEGV on
|
||||
// frame one. Measured, by removing both. This line is what makes the
|
||||
// program correct for a map that declares no physics, which is most of
|
||||
// them. Nothing calls the factory for you either way. A top-down game wants
|
||||
// the arcade backend with no gravity, which is what the property defaults
|
||||
// already give.
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
|
||||
PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
|
||||
|
||||
PASS(errctx, jrpg_world_load());
|
||||
PASS(errctx, jrpg_world_populate());
|
||||
|
||||
// The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in
|
||||
// one call. Keyboard id 0 and gamepad id 0 mean "the first of each".
|
||||
PASS(errctx, akgl_controller_default(0, "player", 0, 0));
|
||||
|
||||
// And one binding of our own on the same map. A binding is a struct, copied
|
||||
// in, so a stack local is fine. handler_off has to be non-NULL --
|
||||
// akgl_controller_handle_event does not check it before calling it -- and
|
||||
// the button is set to something no gamepad reports, because the gamepad
|
||||
// and keyboard arms of the match are evaluated together.
|
||||
PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk)));
|
||||
talk.event_on = SDL_EVENT_KEY_DOWN;
|
||||
talk.event_off = SDL_EVENT_KEY_UP;
|
||||
talk.key = SDLK_SPACE;
|
||||
talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID;
|
||||
talk.handler_on = &jrpg_cmhf_talk;
|
||||
talk.handler_off = &jrpg_cmhf_ignore;
|
||||
PASS(errctx, akgl_controller_pushmap(0, &talk));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Feed the scripted keystrokes due on this frame into the control maps.
|
||||
*
|
||||
* Synthesized `SDL_Event`s rather than direct calls to the handlers, so the
|
||||
* demo goes through akgl_controller_handle_event, the control map lookup and
|
||||
* the binding -- the same path a real key takes. A test that skipped that would
|
||||
* not be testing the thing that breaks.
|
||||
*
|
||||
* @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 < JRPG_DEMO_STEPS; i++ ) {
|
||||
if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) {
|
||||
continue;
|
||||
}
|
||||
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
|
||||
if ( JRPG_DEMO_SCRIPT[i].down ) {
|
||||
event.type = SDL_EVENT_KEY_DOWN;
|
||||
} else {
|
||||
event.type = SDL_EVENT_KEY_UP;
|
||||
}
|
||||
event.key.which = 0;
|
||||
event.key.key = JRPG_DEMO_SCRIPT[i].key;
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief One frame: events, camera, the library's own update, the box, present.
|
||||
*
|
||||
* akgl_game_update is update-every-actor, step the physics, draw the world. It
|
||||
* does *not* clear or present the target, so the frame is bracketed by the
|
||||
* backend's own frame_start and frame_end, and anything drawn between the
|
||||
* update and frame_end lands on top of the world.
|
||||
*
|
||||
* @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;
|
||||
akgl_Iterator opflags = {
|
||||
.flags = AKGL_ITERATOR_OP_UPDATE,
|
||||
.layerid = 0
|
||||
};
|
||||
|
||||
while ( SDL_PollEvent(&event) ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
running = false;
|
||||
}
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||
}
|
||||
if ( demo ) {
|
||||
PASS(errctx, demo_step(frameno));
|
||||
// Drive the clock rather than sleeping on it. akgl_physics_simulate
|
||||
// measures dt from gravity_time, which is a public field, so setting it
|
||||
// one fixed step into the past makes every frame worth exactly 1/60 of
|
||||
// a second of simulated time no matter how fast the loop actually runs.
|
||||
// tests/physics_sim.c does the same, for the same reason: a simulated
|
||||
// second should not cost a real one.
|
||||
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
|
||||
}
|
||||
|
||||
PASS(errctx, jrpg_camera_follow());
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
// Every failure path out of akgl_game_update returns with the game state
|
||||
// mutex still held. SDL's mutexes are recursive and this loop is single
|
||||
// threaded, so the next frame would not deadlock -- but it would be running
|
||||
// on top of a frame that did not finish. Treat a failed frame as terminal.
|
||||
PASS(errctx, akgl_game_update(&opflags));
|
||||
PASS(errctx, jrpg_textbox_draw());
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give back what has to be given back, in the order that works.
|
||||
*
|
||||
* There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three
|
||||
* lines here are load-bearing and the third is a deliberate omission:
|
||||
*
|
||||
* - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept in
|
||||
* an SDL property registry, and `SDL_Quit` destroys the registry -- taking
|
||||
* the last reference to every font still in it with no way left to close
|
||||
* them.
|
||||
* - akgl_tilemap_release is **not** called. Its layer loop destroys
|
||||
* `tilesets[i].texture` rather than `layers[i].texture`, so it double-frees
|
||||
* every tileset texture and never frees an image layer's, and it NULLs
|
||||
* nothing, so a second call is a use-after-free. TODO.md, "Known and still
|
||||
* open" item 2. `SDL_Quit` reclaims the textures correctly; calling the
|
||||
* function that is supposed to would be worse than not.
|
||||
* - The pools are static storage. There is nothing to free and the process is
|
||||
* about to exit.
|
||||
*/
|
||||
static void teardown(void)
|
||||
{
|
||||
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);
|
||||
akgl_Actor *player = NULL;
|
||||
long frameno = 0;
|
||||
uint64_t started = 0;
|
||||
uint64_t spent = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, parse_args(argc, argv));
|
||||
CATCH(errctx, startup());
|
||||
// Taken here rather than after FINISH: the registry is an SDL property
|
||||
// set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards
|
||||
// finds nothing. The actor itself is in a static pool and outlives both.
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
|
||||
// The loop is the last thing in this ATTEMPT block on purpose. CATCH
|
||||
// reports failure by `break`ing, and a `break` inside a loop binds to
|
||||
// the loop rather than to the block -- so a failing frame leaves the
|
||||
// loop and falls straight into CLEANUP, which is what is wanted. Put
|
||||
// anything after this loop and it would run after a failure instead.
|
||||
// src/tilemap.c carries the same note over the same shape.
|
||||
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 < JRPG_FRAME_BUDGET_MS ) {
|
||||
SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent));
|
||||
}
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
teardown();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish");
|
||||
// Set a flag and act on it after FINISH. A bare `return` from inside a
|
||||
// HANDLE block leaves before RELEASE_ERROR and leaks the context's pool
|
||||
// slot; AGENTS.md spells that out, and an example is a bad place to
|
||||
// teach it wrong.
|
||||
exitstatus = 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
// Enough for the smoke run to be read rather than merely passed. A run that
|
||||
// exits 0 having drawn nothing looks exactly like one that worked.
|
||||
if ( player != NULL ) {
|
||||
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
|
||||
} else {
|
||||
printf("jrpg: %ld frames, no player\n", frameno);
|
||||
}
|
||||
return exitstatus;
|
||||
}
|
||||
188
examples/jrpg/jrpg.h
Normal file
188
examples/jrpg/jrpg.h
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file jrpg.h
|
||||
* @brief Shared declarations for the JRPG tutorial game.
|
||||
*
|
||||
* Every function with external linkage in this program is declared here, the
|
||||
* way AGENTS.md requires of the library itself. It is a three-translation-unit
|
||||
* program and it would compile without the header; declaring them anyway is
|
||||
* what keeps a signature from drifting between the definition and the call.
|
||||
*
|
||||
* Everything this program exports carries the `jrpg_` prefix. `static` helpers
|
||||
* drop it, because the prefix exists only to avoid collisions with libakgl,
|
||||
* SDL and libc -- the same rule, for the same reason.
|
||||
*/
|
||||
|
||||
#ifndef _JRPG_JRPG_H_
|
||||
#define _JRPG_JRPG_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
/*
|
||||
* Where the game's data lives. Both are absolute paths baked in by
|
||||
* examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build
|
||||
* tree, from the source tree, and from wherever CTest happens to put its
|
||||
* working directory. The fallbacks are what a reader compiling this by hand
|
||||
* from the repository root would want.
|
||||
*/
|
||||
#ifndef JRPG_ASSET_DIR
|
||||
#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
|
||||
#endif
|
||||
#ifndef JRPG_FONT_FILE
|
||||
#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf"
|
||||
#endif
|
||||
|
||||
/*
|
||||
* The screen size is written as text because that is the only form
|
||||
* akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera`
|
||||
* on the way past. Everything below reads the camera rather than a second copy
|
||||
* of the same two numbers.
|
||||
*/
|
||||
#define JRPG_SCREEN_WIDTH "320"
|
||||
#define JRPG_SCREEN_HEIGHT "240"
|
||||
|
||||
/** @brief Registry name of the one font this game loads. */
|
||||
#define JRPG_FONT_NAME "jrpg"
|
||||
/** @brief Point size the font is rasterized at. A size is baked into the handle. */
|
||||
#define JRPG_FONT_SIZE 12
|
||||
|
||||
/**
|
||||
* @brief Index of the tile layer this game treats as solid.
|
||||
*
|
||||
* An index, not a name, because akgl_TilemapLayer has no `name` member: the
|
||||
* loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
|
||||
* and drops the name Tiled wrote. A map cannot say "the layer called
|
||||
* collision", so the game and the map agree on a number. See chapter 20.
|
||||
*/
|
||||
#define JRPG_LAYER_SOLID 1
|
||||
|
||||
/** @brief Longest line an NPC can say, terminator included. */
|
||||
#define JRPG_TEXTBOX_MAX_TEXT 256
|
||||
|
||||
/** @brief Registry name of the party member this game attaches to the player. */
|
||||
#define JRPG_FOLLOWER_NAME "companion"
|
||||
|
||||
/**
|
||||
* @brief One entry in the scripted demo the headless smoke run drives.
|
||||
*
|
||||
* The keystrokes go in through akgl_controller_handle_event like any other
|
||||
* event, so the smoke run exercises the same binding, state and physics path a
|
||||
* player does rather than a separate one written to be testable.
|
||||
*/
|
||||
typedef struct {
|
||||
long frame; /**< Frame number this step fires on. */
|
||||
SDL_Keycode key; /**< Key to synthesize. */
|
||||
bool down; /**< True for a press, false for a release. */
|
||||
} jrpg_ScriptStep;
|
||||
|
||||
/**
|
||||
* @brief Load every sprite, then every character, then the town map.
|
||||
*
|
||||
* In that order, and the order is not a preference: akgl_character_load_json
|
||||
* resolves each sprite name through #AKGL_REGISTRY_SPRITE, and
|
||||
* akgl_tilemap_load resolves each `character` property through
|
||||
* #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
|
||||
/**
|
||||
* @brief Fix up the actors the map spawned, and attach the party member.
|
||||
*
|
||||
* Clears `movement_controls_face` on everything -- without which the default
|
||||
* `facefunc` erases the facing bit the map set and every NPC stops being drawn
|
||||
* on frame one -- installs the blocking movement logic on the player, and
|
||||
* builds the follower as a child actor.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
|
||||
/**
|
||||
* @brief Centre the camera on the player, clamped to the edges of the map.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If there is no actor named "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
|
||||
/**
|
||||
* @brief The player's `movementlogicfunc`: the library default, plus walls.
|
||||
*
|
||||
* libakgl implements no collision at all -- akgl_physics_arcade_collide raises
|
||||
* AKERR_API, akgl_physics_simulate never calls `collide`, and
|
||||
* akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
|
||||
* writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
|
||||
* which is the documented way to tell the simulator to skip an actor's tick.
|
||||
*
|
||||
* @param obj The actor to compute acceleration for. Required, along with its
|
||||
* `basechar`.
|
||||
* @param dt Seconds this step covers.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
* @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
|
||||
/**
|
||||
* @brief The follower's `renderfunc`: akgl_actor_render with the parent detached.
|
||||
*
|
||||
* The guard for a defect. akgl_physics_simulate writes a child's `x` as
|
||||
* `parent->x + vx` -- an absolute world position -- and akgl_actor_render then
|
||||
* draws it at `parent->x + obj->x`, adding the parent's position a second time.
|
||||
* Nulling `parent` for the duration of the draw takes the branch that does not
|
||||
* add it. See chapter 20.
|
||||
*
|
||||
* @param obj The child actor to draw. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
|
||||
/**
|
||||
* @brief Control handler: talk to whoever is standing nearby, or close the box.
|
||||
* @param obj The actor the control map drives -- the player. Required.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Control handler that does nothing, for the release half of a binding.
|
||||
*
|
||||
* akgl_controller_handle_event does not check a matched binding's handler
|
||||
* pointer before calling it, so a binding with a `NULL` `handler_off` is a
|
||||
* crash rather than an error. Every binding gets both halves.
|
||||
*
|
||||
* @param obj The actor the control map drives. Required.
|
||||
* @param event The event that triggered this. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
|
||||
|
||||
/**
|
||||
* @brief Put a line of dialogue on screen and freeze the world.
|
||||
* @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p text is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
|
||||
/** @brief Dismiss the text box. */
|
||||
void jrpg_textbox_close(void);
|
||||
/** @brief True while a line of dialogue is on screen. */
|
||||
bool jrpg_textbox_showing(void);
|
||||
/**
|
||||
* @brief Draw the panel and its text, if there is anything to draw.
|
||||
*
|
||||
* Called after akgl_game_update, so it lands on top of the world rather than
|
||||
* under it, and in screen coordinates rather than world ones --
|
||||
* akgl_text_rendertextat does not go through the camera, which is what a HUD
|
||||
* wants.
|
||||
*
|
||||
* @return `NULL` on success -- including when the box is closed -- otherwise an
|
||||
* error context owned by the caller.
|
||||
* @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
|
||||
|
||||
#endif // _JRPG_JRPG_H_
|
||||
125
examples/jrpg/textbox.c
Normal file
125
examples/jrpg/textbox.c
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
458
examples/jrpg/world.c
Normal file
458
examples/jrpg/world.c
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* @file world.c
|
||||
* @brief The content pipeline: sprites, characters, the town map, and what the
|
||||
* map's actors do once they exist.
|
||||
*
|
||||
* Almost nothing here is game logic. It is the four steps between a directory
|
||||
* of JSON and a world with people standing in it, in the one order that works,
|
||||
* plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game
|
||||
* has to supply because the library does not.
|
||||
*/
|
||||
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/tilemap.h>
|
||||
|
||||
#include "jrpg.h"
|
||||
|
||||
/*
|
||||
* The three character templates and the twenty-four sprites that dress them.
|
||||
* The file names are a product of these three tables rather than a list of
|
||||
* twenty-four strings, because that is what the naming convention *is*: one
|
||||
* sprite per character, per motion, per facing. A missing combination shows up
|
||||
* as a load failure naming the file, not as art that silently never appears.
|
||||
*/
|
||||
|
||||
/* Character template Sprite name prefix */
|
||||
static char *CAST[] = {
|
||||
"player", /* jrpg_player jrpg_player_* */
|
||||
"elder", /* jrpg_elder jrpg_elder_* */
|
||||
"shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */
|
||||
};
|
||||
|
||||
static char *MOTIONS[] = { "idle", "walk" };
|
||||
static char *FACINGS[] = { "up", "down", "left", "right" };
|
||||
|
||||
#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0]))
|
||||
#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0]))
|
||||
#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0]))
|
||||
|
||||
/** @brief One person in the town who has something to say. */
|
||||
typedef struct {
|
||||
char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */
|
||||
char *line; /**< What they say. */
|
||||
} jrpg_Townsfolk;
|
||||
|
||||
/*
|
||||
* Actor (the map object's name) What they say
|
||||
*/
|
||||
static jrpg_Townsfolk TOWNSFOLK[] = {
|
||||
{ "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." },
|
||||
{ "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." }
|
||||
};
|
||||
|
||||
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
|
||||
|
||||
/** @brief How close the player has to stand, in pixels between sprite centres. */
|
||||
#define TALK_RANGE 64.0f
|
||||
|
||||
/*
|
||||
* The player's footprint, in pixels from the top-left of a 32x32 frame. A
|
||||
* character stands on the bottom third of their sprite, so that is the part
|
||||
* that has to fit through a doorway -- testing the whole frame would make every
|
||||
* corridor two tiles wide.
|
||||
*
|
||||
* 0 32
|
||||
* +------------------------------+ 0
|
||||
* | |
|
||||
* | (art) |
|
||||
* | |
|
||||
* | +----------------+ | 20 FEET_TOP
|
||||
* | | footprint | |
|
||||
* +------+----------------+------+ 30 FEET_TOP + FEET_H
|
||||
* 6 26
|
||||
* FEET_X FEET_X + FEET_W
|
||||
*/
|
||||
#define FEET_X 6.0f
|
||||
#define FEET_W 20.0f
|
||||
#define FEET_TOP 20.0f
|
||||
#define FEET_H 10.0f
|
||||
|
||||
/** @brief Where the party member walks, in pixels relative to the player. */
|
||||
#define FOLLOWER_OFFSET_X (-14.0f)
|
||||
#define FOLLOWER_OFFSET_Y (10.0f)
|
||||
|
||||
/**
|
||||
* @brief Load one sprite definition by its three naming components.
|
||||
*
|
||||
* @param cast Character stem, e.g. `"player"`.
|
||||
* @param motion `"idle"` or `"walk"`.
|
||||
* @param facing `"up"`, `"down"`, `"left"` or `"right"`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
char path[PATH_MAX];
|
||||
int written = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
|
||||
FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion");
|
||||
FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing");
|
||||
|
||||
PASS(errctx,
|
||||
aksl_snprintf(
|
||||
&written,
|
||||
path,
|
||||
sizeof(path),
|
||||
"%s/sprite_jrpg_%s_%s_%s.json",
|
||||
JRPG_ASSET_DIR,
|
||||
cast,
|
||||
motion,
|
||||
facing
|
||||
));
|
||||
PASS(errctx, akgl_sprite_load_json(path));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Load one character definition by its stem.
|
||||
* @param cast Character stem, e.g. `"player"`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
static akerr_ErrorContext *character_load(char *cast)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
char path[PATH_MAX];
|
||||
int written = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
|
||||
PASS(errctx,
|
||||
aksl_snprintf(
|
||||
&written,
|
||||
path,
|
||||
sizeof(path),
|
||||
"%s/character_jrpg_%s.json",
|
||||
JRPG_ASSET_DIR,
|
||||
cast
|
||||
));
|
||||
PASS(errctx, akgl_character_load_json(path));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_world_load(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
char path[PATH_MAX];
|
||||
int written = 0;
|
||||
size_t c = 0;
|
||||
size_t m = 0;
|
||||
size_t f = 0;
|
||||
|
||||
// Sprites first. A character JSON names its sprites by registry key and
|
||||
// akgl_character_load_json refuses one it cannot find, so loading the two
|
||||
// the other way round fails on every mapping in the file.
|
||||
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||
for ( m = 0; m < MOTION_COUNT; m++ ) {
|
||||
for ( f = 0; f < FACING_COUNT; f++ ) {
|
||||
PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f]));
|
||||
}
|
||||
}
|
||||
}
|
||||
for ( c = 0; c < CAST_COUNT; c++ ) {
|
||||
PASS(errctx, character_load(CAST[c]));
|
||||
}
|
||||
|
||||
// And the map last: loading it creates an actor for every `actor` object in
|
||||
// its object layer, and each of those resolves a character by name.
|
||||
PASS(errctx,
|
||||
aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR));
|
||||
PASS(errctx, akgl_tilemap_load(path, akgl_gamemap));
|
||||
|
||||
// The map declares its own physics -- `physics.model` and zero gravity on
|
||||
// both axes -- and akgl_tilemap_load builds a backend for it and sets
|
||||
// use_own_physics. It does not *switch* to it: akgl_game_update simulates
|
||||
// through the global akgl_physics, whatever that points at. Honouring the
|
||||
// map is the caller's job, and this is it.
|
||||
if ( akgl_gamemap->use_own_physics ) {
|
||||
akgl_physics = &akgl_gamemap->physics;
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_world_populate(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *player = NULL;
|
||||
akgl_Actor *follower = NULL;
|
||||
int i = 0;
|
||||
|
||||
// Every actor the map spawned, including the player.
|
||||
//
|
||||
// akgl_actor_initialize sets movement_controls_face, and the default
|
||||
// facefunc it installs clears every facing bit and then sets the one
|
||||
// matching a *movement* bit. An NPC has no movement bits, so on the first
|
||||
// frame its state falls from ALIVE|FACE_DOWN (17) to ALIVE (16), which is
|
||||
// a state combination no character JSON maps a sprite to -- and an actor
|
||||
// with no sprite for its state is skipped rather than reported. Every NPC
|
||||
// in the town disappears on frame one, silently, and so does the player as
|
||||
// soon as they stop walking.
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
akgl_heap_actors[i].movement_controls_face = false;
|
||||
}
|
||||
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
player,
|
||||
AKGL_ERR_REGISTRY,
|
||||
"town.tmj spawned no actor named \"player\""
|
||||
);
|
||||
player->movementlogicfunc = &jrpg_actor_logic_walk;
|
||||
|
||||
// The party member. Nothing in the map creates it, and nothing has to: an
|
||||
// actor is a pool object with a name, and the character it instantiates is
|
||||
// already registered. This one borrows the elder's template, which is the
|
||||
// whole point of splitting instance from template.
|
||||
PASS(errctx, akgl_heap_next_actor(&follower));
|
||||
PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME));
|
||||
PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder"));
|
||||
follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
follower->movement_controls_face = false;
|
||||
follower->visible = true;
|
||||
follower->layer = player->layer;
|
||||
follower->renderfunc = &jrpg_follower_render;
|
||||
PASS(errctx, player->addchild(player, follower));
|
||||
|
||||
// A child's velocity fields are read as a fixed offset from the parent
|
||||
// rather than as a velocity: akgl_physics_simulate snaps a child to
|
||||
// `parent->x + vx` and never simulates it. Set after addchild, because
|
||||
// addchild is what makes them mean an offset.
|
||||
follower->vx = FOLLOWER_OFFSET_X;
|
||||
follower->vy = FOLLOWER_OFFSET_Y;
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Is the map cell containing this pixel one the player cannot enter?
|
||||
*
|
||||
* Two rules, and both of them are the game's rather than the library's. The
|
||||
* outer ring of the map is solid, because nothing else stops an actor walking
|
||||
* off the edge of the world. Everything else is the decoration layer: a cell
|
||||
* with a tile in it is a building, a tree or a lamp post, and a cell with 0 in
|
||||
* it is open ground.
|
||||
*
|
||||
* @param px Position in map pixels along x.
|
||||
* @param py Position in map pixels along y.
|
||||
* @return True when the cell blocks movement.
|
||||
*/
|
||||
static bool cell_solid(float32_t px, float32_t py)
|
||||
{
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
|
||||
tx = (int)(px / (float32_t)akgl_gamemap->tilewidth);
|
||||
ty = (int)(py / (float32_t)akgl_gamemap->tileheight);
|
||||
|
||||
if ( (tx <= 0) || (ty <= 0) ||
|
||||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
|
||||
return true;
|
||||
}
|
||||
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Would an actor whose frame is at (x, y) have its feet in a wall?
|
||||
*
|
||||
* Tests the four corners of the footprint. Four corners is enough only because
|
||||
* the footprint is smaller than a tile in both directions; a bigger one would
|
||||
* step over a single-cell wall between two of its corners.
|
||||
*
|
||||
* @param x Candidate frame position along x, in map pixels.
|
||||
* @param y Candidate frame position along y, in map pixels.
|
||||
* @return True when any corner of the footprint lands in a solid cell.
|
||||
*/
|
||||
static bool feet_blocked(float32_t x, float32_t y)
|
||||
{
|
||||
float32_t left = x + FEET_X;
|
||||
float32_t right = x + FEET_X + FEET_W;
|
||||
float32_t top = y + FEET_TOP;
|
||||
float32_t bottom = y + FEET_TOP + FEET_H;
|
||||
|
||||
return (cell_solid(left, top) ||
|
||||
cell_solid(right, top) ||
|
||||
cell_solid(left, bottom) ||
|
||||
cell_solid(right, bottom));
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "obj->basechar");
|
||||
|
||||
// A conversation freezes the world. AKGL_ERR_LOGICINTERRUPT raised from a
|
||||
// movementlogicfunc means "skip the rest of this tick for this actor", and
|
||||
// akgl_physics_simulate swallows it in a HANDLE block -- so gravity, drag,
|
||||
// the velocity recompute and the move are all skipped and the frame carries
|
||||
// on. Thrust has already been integrated by the time this runs, so it is
|
||||
// zeroed here; leaving it would let it accumulate while frozen and lurch on
|
||||
// the first step after the box closes. The movement bits go too, so the
|
||||
// walk animation stops rather than marching on the spot -- which does mean
|
||||
// a direction held across the close has to be pressed again.
|
||||
if ( jrpg_textbox_showing() ) {
|
||||
obj->tx = 0.0f;
|
||||
obj->ty = 0.0f;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL);
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKGL_ERR_LOGICINTERRUPT,
|
||||
"%s does not move while a conversation is open",
|
||||
(char *)obj->name
|
||||
);
|
||||
}
|
||||
|
||||
// Everything the default hook does -- re-copy the character's speed limits,
|
||||
// turn the movement bits into signed acceleration -- is still wanted. This
|
||||
// hook adds to it rather than replacing it.
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
// Where this step would put us. akgl_physics_simulate has already
|
||||
// integrated thrust for this step and capped it against the character's
|
||||
// speed ellipse, and it computes velocity as `e + t` -- and with the town's
|
||||
// zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what
|
||||
// makes the prediction exact rather than approximate. A map with gravity
|
||||
// would have to account for `ey` here as well.
|
||||
//
|
||||
// Axis at a time, so walking into a wall diagonally slides along it rather
|
||||
// than stopping dead.
|
||||
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) {
|
||||
obj->tx = 0.0f;
|
||||
obj->ax = 0.0f;
|
||||
}
|
||||
if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) {
|
||||
obj->ty = 0.0f;
|
||||
obj->ay = 0.0f;
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_follower_render(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *parent = NULL;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
// The guard. akgl_physics_simulate has already written this actor's x and y
|
||||
// as `parent->x + vx` and `parent->y + vy`, which is an absolute world
|
||||
// position; akgl_actor_render adds the parent's position to it a second
|
||||
// time. Detaching the parent for the duration of the draw takes the branch
|
||||
// that does not. CLEANUP puts it back on every path, including the failing
|
||||
// one -- an actor left with a NULL parent would be simulated as a free
|
||||
// agent on the very next step.
|
||||
parent = obj->parent;
|
||||
obj->parent = NULL;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_actor_render(obj));
|
||||
} CLEANUP {
|
||||
obj->parent = parent;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_camera_follow(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *player = NULL;
|
||||
float32_t mapw = 0.0f;
|
||||
float32_t maph = 0.0f;
|
||||
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKGL_ERR_REGISTRY, "No actor named \"player\"");
|
||||
|
||||
mapw = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth);
|
||||
maph = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight);
|
||||
|
||||
akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
||||
akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f);
|
||||
|
||||
if ( akgl_camera->x < 0.0f ) {
|
||||
akgl_camera->x = 0.0f;
|
||||
}
|
||||
if ( akgl_camera->y < 0.0f ) {
|
||||
akgl_camera->y = 0.0f;
|
||||
}
|
||||
if ( akgl_camera->x > (mapw - akgl_camera->w) ) {
|
||||
akgl_camera->x = mapw - akgl_camera->w;
|
||||
}
|
||||
if ( akgl_camera->y > (maph - akgl_camera->h) ) {
|
||||
akgl_camera->y = maph - akgl_camera->h;
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *npc = NULL;
|
||||
size_t i = 0;
|
||||
float32_t dx = 0.0f;
|
||||
float32_t dy = 0.0f;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
|
||||
if ( jrpg_textbox_showing() ) {
|
||||
jrpg_textbox_close();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) {
|
||||
npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL);
|
||||
if ( npc == NULL ) {
|
||||
continue;
|
||||
}
|
||||
dx = npc->x - obj->x;
|
||||
dy = npc->y - obj->y;
|
||||
if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) {
|
||||
continue;
|
||||
}
|
||||
PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
48
examples/sidescroller/CMakeLists.txt
Normal file
48
examples/sidescroller/CMakeLists.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# The sidescroller tutorial game.
|
||||
#
|
||||
# A real target, built by default, so docs/19-tutorial-sidescroller.md cannot
|
||||
# quote a program that does not compile -- and registered as a headless smoke
|
||||
# test, so it cannot quote one that does not run.
|
||||
|
||||
get_filename_component(SS_ASSET_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../docs/tutorials/assets/sidescroller" ABSOLUTE)
|
||||
|
||||
add_executable(sidescroller
|
||||
main.c
|
||||
collision.c
|
||||
player.c
|
||||
actors.c
|
||||
)
|
||||
|
||||
target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_compile_options(sidescroller PRIVATE ${AKGL_WARNING_FLAGS})
|
||||
|
||||
# Baked in rather than looked up at runtime, so the smoke test can be launched
|
||||
# from any working directory. `--assets DIR` overrides it for a reader who has
|
||||
# moved the art somewhere else.
|
||||
target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"")
|
||||
|
||||
target_link_libraries(sidescroller
|
||||
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
|
||||
SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
|
||||
# A vendored build leaves the SDL satellite libraries in per-project
|
||||
# subdirectories that are not on the loader's default search path, which is the
|
||||
# same problem AKGL_VENDORED_RPATH solves for the test executables.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
set_target_properties(sidescroller PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||
endif()
|
||||
|
||||
# Four seconds of scripted play under the headless drivers: the level loads, the
|
||||
# player walks and jumps, the swept collision runs against real terrain, and the
|
||||
# program tears down and exits 0. A tutorial that stops working fails here rather
|
||||
# than in front of a reader.
|
||||
#
|
||||
# add_test() rather than _add_test(): the root CMakeLists.txt shadows it only
|
||||
# while this project is top-level, and by the time examples/ is added the
|
||||
# suppression has already been lifted. An embedded build gets the builtin.
|
||||
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
||||
set_tests_properties(example_sidescroller PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
183
examples/sidescroller/actors.c
Normal file
183
examples/sidescroller/actors.c
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @file actors.c
|
||||
* @brief Everything level1.tmj places that is not the player.
|
||||
*
|
||||
* None of these are created here. The map's object layer creates all seven
|
||||
* actors, registers them under the names Tiled gave them, and binds each one to
|
||||
* the character its `character` property names -- so this file's whole job is to
|
||||
* find them again by name and give them behaviour.
|
||||
*
|
||||
* Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one
|
||||
* status in libakgl that is not a failure. Raised from a `movementlogicfunc` it
|
||||
* means "skip the rest of this tick for me": no gravity, no drag, no move for
|
||||
* that actor this step, and akgl_physics_simulate carries on to the next one.
|
||||
* That is exactly what an actor that has just placed itself wants.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/** @brief The blob's collision box, offset into its 32x32 frame. */
|
||||
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
|
||||
|
||||
/**
|
||||
* @brief Per-actor data for everything in this file, one slot per actor.
|
||||
*
|
||||
* A fixed table rather than an allocation, for the same reason the library has
|
||||
* pools rather than a `malloc`: the level places a known number of things, and a
|
||||
* game that cannot run out of memory at runtime is one fewer failure mode.
|
||||
*/
|
||||
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
|
||||
|
||||
/**
|
||||
* @brief Look an actor up by the name its Tiled object carried.
|
||||
*
|
||||
* A miss here means the map and the code disagree about what the level
|
||||
* contains, which is worth failing on rather than working around.
|
||||
*/
|
||||
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A `movementlogicfunc` for something that does not move at all.
|
||||
*
|
||||
* The coins need this. Their character declares no speed and no acceleration, so
|
||||
* they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
|
||||
* accumulates into `ey` for every actor it is handed, so a coin left to the
|
||||
* default logic falls out of the level along with everything else.
|
||||
*
|
||||
* Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
|
||||
* step. It is not an error and nothing logs it.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)dt;
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The blob: walk, and turn around at a wall or a ledge.
|
||||
*
|
||||
* This one stays inside the physics step -- it walks on the ground under the
|
||||
* map's gravity like the player does, and uses the same swept resolution.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
ss_Contact contact;
|
||||
float32_t probe_x = 0.0f;
|
||||
float32_t probe_y = 0.0f;
|
||||
bool floor_ahead = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||
data = (ss_ActorData *)obj->actorData;
|
||||
|
||||
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||
if ( data->facing < 0.0f ) {
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
|
||||
} else {
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
|
||||
}
|
||||
/* Signs the acceleration from the movement bits just set. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
|
||||
|
||||
/* One pixel past the leading edge of the box and one pixel below its feet:
|
||||
* no floor there means the next step walks off. */
|
||||
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
|
||||
if ( data->facing < 0.0f ) {
|
||||
probe_x = obj->x + ss_blob_body.x - 1.0f;
|
||||
} else {
|
||||
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
|
||||
}
|
||||
PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
|
||||
|
||||
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
|
||||
data->facing = -data->facing;
|
||||
/* The resolution already zeroed `tx` on a blocked axis; zero it on the
|
||||
* ledge path too, or the blob keeps its old momentum through the turn. */
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The moth: a figure-eight around where the map put it.
|
||||
*
|
||||
* It flies, so it wants no gravity -- and the map's physics has gravity, because
|
||||
* the player needs it. Rather than fighting the backend the moth writes its own
|
||||
* position and then opts out of the rest of the step, which is the second thing
|
||||
* #AKGL_ERR_LOGICINTERRUPT is for.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||
data = (ss_ActorData *)obj->actorData;
|
||||
|
||||
data->phase += dt;
|
||||
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
|
||||
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
|
||||
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
if ( cosf(data->phase) < 0.0f ) {
|
||||
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
} else {
|
||||
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||
}
|
||||
|
||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_actors_bind(void)
|
||||
{
|
||||
char name[32];
|
||||
int count = 0;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
||||
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
|
||||
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
|
||||
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
|
||||
}
|
||||
|
||||
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
|
||||
PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
|
||||
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
|
||||
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
|
||||
ss_hazard_data[0].facing = -1.0f;
|
||||
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
|
||||
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
|
||||
|
||||
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
|
||||
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
|
||||
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
|
||||
ss_hazard_data[1].facing = 1.0f;
|
||||
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
|
||||
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
383
examples/sidescroller/collision.c
Normal file
383
examples/sidescroller/collision.c
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file collision.c
|
||||
* @brief The collision libakgl does not have.
|
||||
*
|
||||
* This file exists because of a gap, and the gap is worth stating exactly:
|
||||
*
|
||||
* - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not
|
||||
* implemented".
|
||||
* - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade
|
||||
* backend, not for the null one. The vtable slot exists and the simulation
|
||||
* does not use it.
|
||||
* - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.
|
||||
* It does not clamp to the map, consult the tilemap, or test anything.
|
||||
*
|
||||
* So an actor walks through a wall and off the edge of the world, and the only
|
||||
* hook that runs inside the physics step is the actor's own `movementlogicfunc`.
|
||||
* That is where this game's collision lives, and everything here is called from
|
||||
* there.
|
||||
*
|
||||
* The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
|
||||
* drag, the velocity recomputation and `move`, so it cannot look at where the
|
||||
* actor ended up -- the step has not happened yet. ss_collide_predict therefore
|
||||
* repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
|
||||
* resolves against that predicted position. Get the prediction wrong and the
|
||||
* actor is resolved against a step it never takes.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/**
|
||||
* @brief Half a pixel-thousandth, taken off the far edge of a box before it is
|
||||
* turned into tile indices.
|
||||
*
|
||||
* A box whose right edge sits exactly on a tile boundary does not overlap the
|
||||
* tile on the far side of it. Without this, an actor standing flush against a
|
||||
* wall reads as inside it, and the sweep snaps it back a tile every frame.
|
||||
*/
|
||||
#define SS_COLLIDE_EPSILON 0.001f
|
||||
|
||||
/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
|
||||
#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
|
||||
|
||||
/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
|
||||
#define SS_COLLIDE_SETTLE_TILES 4
|
||||
|
||||
static akgl_TilemapLayer *ss_terrain = NULL;
|
||||
|
||||
/**
|
||||
* @brief Is this map cell solid?
|
||||
*
|
||||
* Off the left or right edge of the map is solid, so the level has walls at its
|
||||
* ends. Off the top or the bottom is not: the sky is open and the pit in the
|
||||
* middle of level1.tmj has to be fallable-into, which is the whole point of it.
|
||||
*/
|
||||
static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
|
||||
|
||||
if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
|
||||
*dest = true;
|
||||
} else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
|
||||
*dest = false;
|
||||
} else {
|
||||
/* A tile layer's data is global tile ids in row-major order, and 0 is
|
||||
* the empty cell. Any tile at all on the terrain layer is solid: the
|
||||
* level says what is solid by which layer the tile is drawn on. */
|
||||
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
|
||||
|
||||
ss_terrain = NULL;
|
||||
for ( i = 0; i < map->numlayers; i++ ) {
|
||||
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
|
||||
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
|
||||
ss_terrain = &map->layers[i];
|
||||
}
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
ss_terrain,
|
||||
AKERR_KEY,
|
||||
"Map has no tile layer with id %d to collide against",
|
||||
SS_TERRAIN_LAYER_ID
|
||||
);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
|
||||
{
|
||||
int x0 = 0;
|
||||
int x1 = 0;
|
||||
int y0 = 0;
|
||||
int y1 = 0;
|
||||
int tilex = 0;
|
||||
int tiley = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((box->w <= 0.0f) || (box->h <= 0.0f)),
|
||||
AKERR_VALUE,
|
||||
"Collision box is %fx%f; both sides must be positive",
|
||||
box->w,
|
||||
box->h
|
||||
);
|
||||
|
||||
*dest = false;
|
||||
x0 = (int)floorf(box->x / SS_TILE_SIZE);
|
||||
x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
y0 = (int)floorf(box->y / SS_TILE_SIZE);
|
||||
y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
|
||||
|
||||
for ( tiley = y0; tiley <= y1; tiley++ ) {
|
||||
for ( tilex = x0; tilex <= x1; tilex++ ) {
|
||||
PASS(errctx, solid_tile(tilex, tiley, &solid));
|
||||
if ( solid == true ) {
|
||||
*dest = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
|
||||
{
|
||||
float32_t ex = 0.0f;
|
||||
float32_t ey = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
|
||||
FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
|
||||
|
||||
/*
|
||||
* Everything below is akgl_physics_simulate's own arithmetic, in its own
|
||||
* order: gravity onto the environmental term, then drag off it, then
|
||||
* velocity as environmental plus thrust, then position plus velocity times
|
||||
* dt. The `!= 0` guards are the library's too -- it skips each axis whose
|
||||
* constant is zero rather than multiplying by it -- and are repeated here
|
||||
* because dropping them would make a zero-gravity axis pick up drag.
|
||||
*
|
||||
* This mirrors the *arcade* backend. Against the null backend gravity does
|
||||
* nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
|
||||
* right.
|
||||
*/
|
||||
ex = obj->ex;
|
||||
ey = obj->ey;
|
||||
if ( akgl_physics->gravity_x != 0 ) {
|
||||
ex -= (float32_t)akgl_physics->gravity_x * dt;
|
||||
}
|
||||
if ( akgl_physics->gravity_y != 0 ) {
|
||||
ey += (float32_t)akgl_physics->gravity_y * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_x != 0 ) {
|
||||
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
|
||||
}
|
||||
if ( akgl_physics->drag_y != 0 ) {
|
||||
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
|
||||
}
|
||||
*dx = (ex + obj->tx) * dt;
|
||||
*dy = (ey + obj->ty) * dt;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
|
||||
*
|
||||
* Each axis is tested on its own, which is what lets an actor slide along a wall
|
||||
* instead of sticking to it, and the sub-step is capped at half a tile so a
|
||||
* fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
|
||||
* the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
|
||||
* step, which is nearly two tiles -- so this is not a theoretical concern.
|
||||
*
|
||||
* On a blocked axis the box is snapped to the tile boundary it was about to
|
||||
* cross rather than simply not moved, so an actor lands flush on a floor at
|
||||
* whatever speed it arrives.
|
||||
*/
|
||||
static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect trial;
|
||||
float32_t span = 0.0f;
|
||||
float32_t stepx = 0.0f;
|
||||
float32_t stepy = 0.0f;
|
||||
int steps = 0;
|
||||
int i = 0;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
span = fabsf(dx);
|
||||
if ( fabsf(dy) > span ) {
|
||||
span = fabsf(dy);
|
||||
}
|
||||
steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
|
||||
stepx = dx / (float32_t)steps;
|
||||
stepy = dy / (float32_t)steps;
|
||||
|
||||
for ( i = 0; i < steps; i++ ) {
|
||||
if ( stepx != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.x += stepx;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepx > 0.0f ) {
|
||||
box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
|
||||
} else {
|
||||
box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepx = 0.0f;
|
||||
dest->blocked_x = true;
|
||||
} else {
|
||||
box->x = trial.x;
|
||||
}
|
||||
}
|
||||
if ( stepy != 0.0f ) {
|
||||
trial = *box;
|
||||
trial.y += stepy;
|
||||
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
|
||||
if ( solid == true ) {
|
||||
if ( stepy > 0.0f ) {
|
||||
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
|
||||
} else {
|
||||
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
|
||||
}
|
||||
stepy = 0.0f;
|
||||
dest->blocked_y = true;
|
||||
} else {
|
||||
box->y = trial.y;
|
||||
}
|
||||
}
|
||||
if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
|
||||
{
|
||||
SDL_FRect box;
|
||||
SDL_FRect probe;
|
||||
float32_t dx = 0.0f;
|
||||
float32_t dy = 0.0f;
|
||||
bool solid = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
dest->blocked_x = false;
|
||||
dest->blocked_y = false;
|
||||
dest->grounded = false;
|
||||
|
||||
PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
|
||||
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, sweep(&box, dx, dy, dest));
|
||||
|
||||
/*
|
||||
* Only a blocked axis is written back. The free axis is left for
|
||||
* akgl_physics_arcade_move to advance by exactly the amount this function
|
||||
* predicted -- writing it here as well would move the actor twice.
|
||||
*
|
||||
* `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
|
||||
* effort; a blocked axis has to give up both, or the actor keeps pressing
|
||||
* into the wall and the next step's prediction is wrong by everything it
|
||||
* accumulated while stuck.
|
||||
*
|
||||
* The environmental term is not set to zero, it is set to *minus one step of
|
||||
* gravity*, and that is not a nicety. Zeroing it leaves the step about to add
|
||||
* `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
|
||||
* quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
|
||||
* still fatal: the box now overlaps the floor tile, so the *horizontal*
|
||||
* sweep on the next step finds itself blocked wherever it is, and the actor
|
||||
* is snapped back a whole tile every time it tries to walk. Pre-loading the
|
||||
* cancellation leaves the actor resting exactly on the surface instead.
|
||||
*/
|
||||
if ( dest->blocked_x == true ) {
|
||||
obj->x = box.x - body->x;
|
||||
obj->ex = (float32_t)akgl_physics->gravity_x * dt;
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
if ( dest->blocked_y == true ) {
|
||||
obj->y = box.y - body->y;
|
||||
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
|
||||
obj->ty = 0.0f;
|
||||
}
|
||||
|
||||
/*
|
||||
* Standing on a floor is not a state the library records, so it is measured:
|
||||
* one pixel below where the box will be at the end of this step. Note that
|
||||
* zeroing `ey` above does not stop gravity -- the step still adds
|
||||
* `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
|
||||
* fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
|
||||
* it straight back off, so a standing actor rests within a pixel of the
|
||||
* surface forever rather than sinking through it.
|
||||
*/
|
||||
probe = box;
|
||||
probe.y += 1.0f;
|
||||
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
|
||||
dest->grounded = solid;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
|
||||
{
|
||||
SDL_FRect box;
|
||||
bool solid = false;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
|
||||
|
||||
/*
|
||||
* A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
|
||||
* actor can start out with its box partly inside a step -- level1.tmj puts
|
||||
* the player at x=32 and a block at tiles (3,11), which is under the right
|
||||
* half of the player's frame.
|
||||
*
|
||||
* The swept resolution cannot fix that. It stops an actor *entering*
|
||||
* terrain and has nothing to say about one that began inside it; what it
|
||||
* does instead is refuse every horizontal move, because the box is already
|
||||
* blocked wherever it goes. So a spawn point gets lifted clear once, before
|
||||
* the first step, a tile at a time.
|
||||
*/
|
||||
for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
|
||||
box.x = obj->x + body->x;
|
||||
box.y = obj->y + body->y;
|
||||
box.w = body->w;
|
||||
box.h = body->h;
|
||||
PASS(errctx, ss_collide_box_blocked(&box, &solid));
|
||||
if ( solid == false ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
obj->y -= (float32_t)SS_TILE_SIZE;
|
||||
SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
|
||||
}
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"%s spawned inside more than %d tiles of terrain at %f, %f",
|
||||
(char *)obj->name,
|
||||
SS_COLLIDE_SETTLE_TILES,
|
||||
obj->x,
|
||||
obj->y
|
||||
);
|
||||
}
|
||||
456
examples/sidescroller/main.c
Normal file
456
examples/sidescroller/main.c
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief Startup, the frame loop, and teardown for the sidescroller tutorial.
|
||||
*
|
||||
* The order everything happens in is the point of this file. libakgl has one
|
||||
* startup sequence that works, documented at the top of `include/akgl/game.h`,
|
||||
* and three of its steps are ones a reader gets wrong the first time:
|
||||
*
|
||||
* - the configuration properties have to be set *before* the renderer and the
|
||||
* physics backend are initialized, because both read them;
|
||||
* - `akgl_game_init` does **not** choose a physics backend, so the application
|
||||
* has to;
|
||||
* - characters name sprites and maps name characters, so the assets load
|
||||
* sprites, then characters, then the map -- and the map creates the actors.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */
|
||||
#ifndef SS_ASSET_DIR
|
||||
#define SS_ASSET_DIR "."
|
||||
#endif
|
||||
|
||||
/** @brief Longest asset path this program will build. */
|
||||
#define SS_PATH_MAX 1024
|
||||
|
||||
ss_Game ss_game;
|
||||
|
||||
/**
|
||||
* @brief The sprites, loaded in this order.
|
||||
*
|
||||
* Sprites first and characters second is not a preference. A character's JSON
|
||||
* names its sprites by registry name and `akgl_character_load_json` looks each
|
||||
* one up as it reads the mapping, so a character loaded first fails with
|
||||
* `AKERR_NULLPOINTER` on the first sprite it cannot find.
|
||||
*/
|
||||
static char *ss_sprite_files[] = {
|
||||
"sprite_ss_player_idle_left.json", /* one frame, held */
|
||||
"sprite_ss_player_idle_right.json",
|
||||
"sprite_ss_player_run_left.json", /* four frames at 90 ms */
|
||||
"sprite_ss_player_run_right.json",
|
||||
"sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
|
||||
"sprite_ss_player_jump_right.json",
|
||||
"sprite_ss_coin.json",
|
||||
"sprite_ss_hazard_blob.json",
|
||||
"sprite_ss_hazard_moth.json",
|
||||
NULL
|
||||
};
|
||||
|
||||
/** @brief The characters. Each one binds state bitmasks to the sprites above. */
|
||||
static char *ss_character_files[] = {
|
||||
"character_ss_player.json",
|
||||
"character_ss_coin.json",
|
||||
"character_ss_hazard_blob.json",
|
||||
"character_ss_hazard_moth.json",
|
||||
NULL
|
||||
};
|
||||
|
||||
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
|
||||
static int ss_failed = 0;
|
||||
|
||||
/**
|
||||
* @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame.
|
||||
*
|
||||
* The default is `akgl_game_lowfps`, and it fires on **every frame** the frame
|
||||
* rate is under 30 -- including every frame of the first second of the process,
|
||||
* because `akgl_game.fps` is a completed-second average and reads 0 until the
|
||||
* first second is up. The hook exists to be replaced; the point of it is that a
|
||||
* game can shed work rather than log about it. This one has nothing to shed.
|
||||
*/
|
||||
static void ss_lowfps(void)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Join the asset directory and a file name into @p dest.
|
||||
*
|
||||
* `aksl_snprintf` rather than `snprintf`, because a path that does not fit has
|
||||
* to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it
|
||||
* reports itself later as a missing file with a name nobody wrote.
|
||||
*/
|
||||
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
|
||||
{
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Bring the library up and open the window.
|
||||
*
|
||||
* `akgl_game.name`, `.version` and `.uri` are filled in first because
|
||||
* `akgl_game_init` refuses to run without all three: the window title, SDL's
|
||||
* application metadata and the savegame compatibility check are built from them.
|
||||
*/
|
||||
static akerr_ErrorContext *startup(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&akgl_game.name,
|
||||
sizeof(akgl_game.name),
|
||||
"libakgl sidescroller tutorial",
|
||||
sizeof(akgl_game.name) - 1));
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&akgl_game.version,
|
||||
sizeof(akgl_game.version),
|
||||
"1.0.0",
|
||||
sizeof(akgl_game.version) - 1));
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&akgl_game.uri,
|
||||
sizeof(akgl_game.uri),
|
||||
"net.aklabs.libakgl.sidescroller",
|
||||
sizeof(akgl_game.uri) - 1));
|
||||
|
||||
PASS(errctx, akgl_game_init());
|
||||
akgl_game.lowfpsfunc = &ss_lowfps;
|
||||
|
||||
/* Properties before the renderer: akgl_render_2d_init reads both of these
|
||||
* out of the registry, and an unset one defaults to the string "0", which
|
||||
* asks SDL for a zero-sized window. */
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", "480"));
|
||||
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
|
||||
/*
|
||||
* libakgl draws in map pixels and has no scale factor of its own -- the only
|
||||
* scaling it applies is the tilemap's perspective band, which this map does
|
||||
* not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more
|
||||
* than that. SDL's logical presentation is the answer: the game renders a
|
||||
* 480x240 view and SDL scales it up by whole multiples to fill the window.
|
||||
*/
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderLogicalPresentation(
|
||||
akgl_renderer->sdl_renderer,
|
||||
SS_VIEW_WIDTH,
|
||||
SS_VIEW_HEIGHT,
|
||||
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError()
|
||||
);
|
||||
|
||||
/* akgl_render_2d_init sized the camera from the window. The view is what the
|
||||
* camera looks through, so it has to say the same thing. */
|
||||
akgl_camera->x = 0.0f;
|
||||
akgl_camera->y = 0.0f;
|
||||
akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
|
||||
akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
|
||||
|
||||
/*
|
||||
* akgl_game_init does NOT choose a physics backend. It points akgl_physics
|
||||
* at akgl_default_physics, which is zeroed storage -- all four of its method
|
||||
* pointers are NULL -- and never initializes it. There is no
|
||||
* `physics.engine` property either, whatever physics.h says. Skip this call
|
||||
* and the first akgl_game_update calls through a NULL `simulate`.
|
||||
*
|
||||
* The map replaces this backend below with one of its own. It is still done
|
||||
* here, because a game that loads a map without physics properties gets a
|
||||
* working backend rather than a crash.
|
||||
*/
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Load the sprites, the characters and the level, in that order.
|
||||
*/
|
||||
static akerr_ErrorContext *load_level(char *assetdir)
|
||||
{
|
||||
char path[SS_PATH_MAX];
|
||||
akgl_Actor *player = NULL;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||
|
||||
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
|
||||
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
|
||||
PASS(errctx, akgl_sprite_load_json((char *)&path));
|
||||
}
|
||||
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
|
||||
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
|
||||
PASS(errctx, akgl_character_load_json((char *)&path));
|
||||
}
|
||||
|
||||
/*
|
||||
* `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static
|
||||
* storage in the library. That is not an accident and it is not a
|
||||
* micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB
|
||||
* thread stack, so a local one is a segfault before the loader writes a
|
||||
* byte.
|
||||
*
|
||||
* Loading the map creates every `actor` object in its object layers, binds
|
||||
* each to the character its properties name, and publishes it in
|
||||
* AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first.
|
||||
*/
|
||||
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
|
||||
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
|
||||
|
||||
/*
|
||||
* The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`,
|
||||
* so the loader built a backend of its own and set `use_own_physics`.
|
||||
* Nothing in the library acts on that flag: akgl_game_update steps the
|
||||
* global akgl_physics and never looks at the map's. Honouring it is this
|
||||
* line, and it is what lets a swimming level and a walking level differ by
|
||||
* data rather than by code.
|
||||
*/
|
||||
if ( akgl_gamemap->use_own_physics == true ) {
|
||||
akgl_physics = &akgl_gamemap->physics;
|
||||
SDL_Log(
|
||||
"Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s",
|
||||
akgl_physics->gravity_y,
|
||||
akgl_physics->drag_y,
|
||||
(akgl_physics->gravity_y / akgl_physics->drag_y));
|
||||
}
|
||||
|
||||
PASS(errctx, ss_collide_bind(akgl_gamemap));
|
||||
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
|
||||
PASS(errctx, ss_player_bind(player));
|
||||
PASS(errctx, ss_actors_bind());
|
||||
PASS(errctx, ss_player_controls(0, "player"));
|
||||
|
||||
/*
|
||||
* Re-stamp the clock the simulation measures against. Everything above --
|
||||
* nine sprite files, four characters, a map and its tileset image -- happened
|
||||
* between the backend being created and the first step, and dt is measured
|
||||
* from `gravity_time`. The bound on `physics.max_timestep` would catch it,
|
||||
* at the cost of one visibly slow-motion frame.
|
||||
*/
|
||||
akgl_physics->gravity_time = SDL_GetTicksNS();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Centre the camera on the player, clamped to the level.
|
||||
*
|
||||
* The camera is a plain `SDL_FRect` in map pixels that the library reads; moving
|
||||
* it is the whole of scrolling. It is floored to a whole pixel because
|
||||
* `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles
|
||||
* to show, and a camera that is fractionally different every frame makes the
|
||||
* tile grid shimmer.
|
||||
*/
|
||||
static akerr_ErrorContext *update_camera(void)
|
||||
{
|
||||
float32_t limit = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera");
|
||||
|
||||
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
|
||||
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
|
||||
if ( akgl_camera->x > limit ) {
|
||||
akgl_camera->x = limit;
|
||||
}
|
||||
if ( akgl_camera->x < 0.0f ) {
|
||||
akgl_camera->x = 0.0f;
|
||||
}
|
||||
akgl_camera->x = (float32_t)((int)akgl_camera->x);
|
||||
akgl_camera->y = 0.0f;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief One frame: events, then the camera, then the library's own tick.
|
||||
*
|
||||
* `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It
|
||||
* does not clear or present, so the frame is bracketed by the backend's
|
||||
* `frame_start` and `frame_end` here.
|
||||
*/
|
||||
static akerr_ErrorContext *frame(bool *running)
|
||||
{
|
||||
SDL_Event event;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
|
||||
while ( SDL_PollEvent(&event) == true ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
*running = false;
|
||||
}
|
||||
/* Every event, unconditionally: one that no control map binds is not an
|
||||
* error, it is a call that did nothing. */
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||
}
|
||||
|
||||
ss_game.frame += 1;
|
||||
if ( ss_game.autoplay == true ) {
|
||||
PASS(errctx, ss_player_autoplay(ss_game.frame));
|
||||
}
|
||||
PASS(errctx, update_camera());
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
/*
|
||||
* Note the failure contract: akgl_game_update takes the game state lock and
|
||||
* every one of its failure paths returns with the lock still held. SDL
|
||||
* mutexes are recursive so a single-threaded loop does not deadlock on the
|
||||
* next frame, but a frame that failed has left the world half-stepped.
|
||||
* Treat it as terminal, which is what PASS does here.
|
||||
*/
|
||||
PASS(errctx, akgl_game_update(NULL));
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *run(int frames)
|
||||
{
|
||||
bool running = true;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
while ( running == true ) {
|
||||
PASS(errctx, frame(&running));
|
||||
if ( (frames > 0) && (ss_game.frame >= frames) ) {
|
||||
running = false;
|
||||
}
|
||||
/* A crude frame limiter. A game with a window on a real display should
|
||||
* ask SDL for vsync instead; this one has to work under the dummy video
|
||||
* driver, where there is nothing to sync to. */
|
||||
SDL_Delay(16);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give back what the process is holding.
|
||||
*
|
||||
* **There is no `akgl_game_shutdown`.** Teardown is the application's, and the
|
||||
* order matters in one place: `akgl_text_unloadallfonts` has to run before
|
||||
* `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the
|
||||
* registry that still points at them.
|
||||
*
|
||||
* The pools are not walked beyond the actors. They are static storage in a
|
||||
* process that is exiting, and the sprites and characters are still referenced
|
||||
* by each other; a game that loads a second level has to unwind that properly,
|
||||
* and this one does not pretend to.
|
||||
*/
|
||||
static void shutdown_game(void)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount > 0 ) {
|
||||
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
|
||||
}
|
||||
}
|
||||
/* Guarded: this runs from CLEANUP, which is reached even when startup failed
|
||||
* before akgl_game_init pointed akgl_gamemap at anything. */
|
||||
if ( akgl_gamemap != NULL ) {
|
||||
IGNORE(akgl_tilemap_release(akgl_gamemap));
|
||||
}
|
||||
TTF_Quit();
|
||||
MIX_Quit();
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames)
|
||||
{
|
||||
const char *env = NULL;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
|
||||
|
||||
env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES");
|
||||
if ( env != NULL ) {
|
||||
PASS(errctx, aksl_atoi((char *)env, frames));
|
||||
}
|
||||
for ( i = 1; i < argc; i++ ) {
|
||||
if ( strcmp(argv[i], "--autoplay") == 0 ) {
|
||||
ss_game.autoplay = true;
|
||||
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
|
||||
PASS(errctx, aksl_atoi(argv[i], frames));
|
||||
} else if ( strcmp(argv[i], "--assets") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
|
||||
*assetdir = argv[i];
|
||||
} else {
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
|
||||
);
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
char *assetdir = SS_ASSET_DIR;
|
||||
int frames = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
|
||||
CATCH(errctx, startup());
|
||||
CATCH(errctx, load_level(assetdir));
|
||||
CATCH(errctx, run(frames));
|
||||
} CLEANUP {
|
||||
shutdown_game();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
|
||||
/*
|
||||
* Set a flag rather than returning: leaving a HANDLE block early skips
|
||||
* FINISH's RELEASE_ERROR and leaks the context's pool slot, and the
|
||||
* 129th such call aborts the process. AGENTS.md spells it out.
|
||||
*/
|
||||
ss_failed = 1;
|
||||
/*
|
||||
* FINISH_NORETURN rather than FINISH: FINISH expands a
|
||||
* `return __err_context` that an int-returning function cannot compile,
|
||||
* even on a branch that cannot be reached.
|
||||
*/
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
SDL_Log(
|
||||
"sidescroller: %d frames, %d of %d coins, %d deaths",
|
||||
ss_game.frame,
|
||||
ss_game.coins_taken,
|
||||
SS_COIN_COUNT,
|
||||
ss_game.deaths);
|
||||
return ss_failed;
|
||||
}
|
||||
436
examples/sidescroller/player.c
Normal file
436
examples/sidescroller/player.c
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* @file player.c
|
||||
* @brief The player's two hooks and its control bindings.
|
||||
*
|
||||
* Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the
|
||||
* split between them is the frame's own order. `akgl_game_update` calls every
|
||||
* actor's `updatefunc`, then steps the physics, then draws -- so per-frame game
|
||||
* logic that is not movement (picking a coin up, falling in the pit) goes in
|
||||
* `updatefunc`, and anything that has to happen inside the physics step goes in
|
||||
* `movementlogicfunc`, which is the only hook the step calls.
|
||||
*
|
||||
* Two of the library's documented gaps are worked around here rather than
|
||||
* papered over. Both are in `TODO.md` under "Arcade physics feel".
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
#include "sidescroller.h"
|
||||
|
||||
/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */
|
||||
static SDL_FRect ss_player_body = {
|
||||
.x = SS_PLAYER_BOX_X,
|
||||
.y = SS_PLAYER_BOX_Y,
|
||||
.w = SS_PLAYER_BOX_W,
|
||||
.h = SS_PLAYER_BOX_H
|
||||
};
|
||||
|
||||
/** @brief Where the map put the player, so a death can put it back. */
|
||||
static ss_ActorData ss_player_data;
|
||||
|
||||
/**
|
||||
* @brief The rectangle an actor is tested against, given a 32x32 sprite frame.
|
||||
*
|
||||
* Smaller than the frame on purpose. Sprite art does not reach the edges, and a
|
||||
* hazard box the full size of the frame kills a player who is visibly nowhere
|
||||
* near it.
|
||||
*/
|
||||
static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
dest->x = obj->x + inset;
|
||||
dest->y = obj->y + inset;
|
||||
dest->w = 32.0f - (inset * 2.0f);
|
||||
dest->h = 32.0f - (inset * 2.0f);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Put the player back where the map placed it, at rest.
|
||||
*
|
||||
* Everything the simulation carries between steps has to be cleared, not just
|
||||
* the position: `ey` is where gravity has been accumulating, and a player who
|
||||
* respawns still holding a full-speed fall lands dead again immediately.
|
||||
*/
|
||||
static akerr_ErrorContext *respawn(akgl_Actor *obj)
|
||||
{
|
||||
ss_ActorData *data = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
|
||||
|
||||
data = (ss_ActorData *)obj->actorData;
|
||||
obj->x = data->home_x;
|
||||
obj->y = data->home_y;
|
||||
obj->ex = 0.0f;
|
||||
obj->ey = 0.0f;
|
||||
obj->tx = 0.0f;
|
||||
obj->ty = 0.0f;
|
||||
obj->vx = 0.0f;
|
||||
obj->vy = 0.0f;
|
||||
ss_game.deaths += 1;
|
||||
SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
|
||||
*
|
||||
* Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
|
||||
* drag, the velocity recomputation and `move`.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
ss_Contact contact;
|
||||
float32_t friction = 0.0f;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
/* The default logic still has to run: it is what copies the character's
|
||||
* speeds onto the actor and signs its acceleration by the movement bits. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
/*
|
||||
* Workaround 1: there is no friction anywhere in the arcade backend.
|
||||
*
|
||||
* akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction
|
||||
* stops the actor dead inside one frame -- correct for a top-down Zelda,
|
||||
* wrong for a sidescroller. This game binds its own `_off` handlers that
|
||||
* leave `tx` alone (see below) and decays it here instead, faster on the
|
||||
* ground than in the air. Below a pixel per second it is snapped to zero,
|
||||
* because an exponential decay never actually arrives.
|
||||
*/
|
||||
if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
|
||||
AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
friction = SS_FRICTION_AIR;
|
||||
if ( ss_game.grounded == true ) {
|
||||
friction = SS_FRICTION_GROUND;
|
||||
}
|
||||
obj->tx -= obj->tx * friction * dt;
|
||||
if ( fabsf(obj->tx) < 1.0f ) {
|
||||
obj->tx = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A jump is an impulse straight into the environmental term, because `ey` is
|
||||
* the axis gravity accumulates on and the two have to cancel for the arc to
|
||||
* come back down. Thrust would not: `ty` is capped against the character's
|
||||
* `speed_y`, which is 0 for this character, so a jump written as thrust is
|
||||
* scaled to nothing by the ellipse cap in akgl_physics_simulate.
|
||||
*
|
||||
* `ss_game.grounded` is last step's verdict, one frame stale. That is the
|
||||
* ordering again -- this hook runs before the step it is deciding about --
|
||||
* and one frame of coyote time is not a thing a player can feel.
|
||||
*/
|
||||
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
|
||||
obj->ey = -SS_JUMP_SPEED;
|
||||
}
|
||||
ss_game.jump_requested = false;
|
||||
|
||||
PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
|
||||
ss_game.grounded = contact.grounded;
|
||||
|
||||
/*
|
||||
* The character maps MOVING_UP to the jump sprites, so the state word says
|
||||
* "in the air" whether the actor is rising or falling. Nothing else reads
|
||||
* the bit here: `speed_y` and `acceleration_y` are both 0 in
|
||||
* character_ss_player.json, so the thrust it would authorise is capped to
|
||||
* nothing.
|
||||
*/
|
||||
if ( contact.grounded == true ) {
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
} else {
|
||||
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player's `updatefunc`: the animation, then what it is touching.
|
||||
*
|
||||
* Runs in `akgl_game_update`'s actor sweep, before the physics step and before
|
||||
* anything is drawn.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_player_update(akgl_Actor *obj)
|
||||
{
|
||||
SDL_FRect player;
|
||||
SDL_FRect other;
|
||||
bool hit = false;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
/* Facing, then the animation frame. Replacing a hook does not mean
|
||||
* reimplementing it. */
|
||||
PASS(errctx, akgl_actor_update(obj));
|
||||
|
||||
/* Off the bottom of the world. Nothing in the library stops an actor
|
||||
* leaving the map -- akgl_physics_arcade_move does not clamp -- so falling
|
||||
* out of the level is a thing the game has to notice for itself. */
|
||||
if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
|
||||
PASS(errctx, respawn(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
PASS(errctx, hitbox(obj, 8.0f, &player));
|
||||
|
||||
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
|
||||
if ( ss_game.coins[i] == NULL ) {
|
||||
continue;
|
||||
}
|
||||
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
|
||||
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
||||
if ( hit == true ) {
|
||||
/*
|
||||
* Giving the pool slot back is what unregisters the actor and stops
|
||||
* it being drawn; there is no "despawn" call. Releasing another
|
||||
* actor from inside this sweep is safe -- akgl_game_update re-reads
|
||||
* `refcount` at the top of every iteration and skips a slot that has
|
||||
* gone free.
|
||||
*/
|
||||
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
|
||||
ss_game.coins[i] = NULL;
|
||||
ss_game.coins_taken += 1;
|
||||
SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
for ( i = 0; i < SS_HAZARD_COUNT; i++ ) {
|
||||
if ( ss_game.hazards[i] == NULL ) {
|
||||
continue;
|
||||
}
|
||||
PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other));
|
||||
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
|
||||
if ( hit == true ) {
|
||||
PASS(errctx, respawn(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/*
|
||||
* Workaround 2, the release half.
|
||||
*
|
||||
* akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax`
|
||||
* *and* zero `tx`. That last one is the "stops dead" behaviour; these two do
|
||||
* everything else it does and leave `tx` for ss_player_movement to decay.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
obj->ax = 0.0f;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *ss_control_right_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
obj->ax = 0.0f;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */
|
||||
static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
ss_game.jump_requested = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Cut a rising jump short when the button is let go.
|
||||
*
|
||||
* Variable jump height for four lines, and it works because `ey` is an ordinary
|
||||
* field that nothing else owns between steps.
|
||||
*/
|
||||
static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
if ( obj->ey < 0.0f ) {
|
||||
obj->ey *= 0.4f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
ss_game.player = obj;
|
||||
|
||||
/* Lift the spawn point clear of the step it overlaps *before* recording it,
|
||||
* so a respawn does not put the player back inside the geometry. */
|
||||
PASS(errctx, ss_collide_settle(obj, &ss_player_body));
|
||||
ss_player_data.home_x = obj->x;
|
||||
ss_player_data.home_y = obj->y;
|
||||
obj->actorData = (void *)&ss_player_data;
|
||||
|
||||
/*
|
||||
* The default `facefunc` clears every facing bit and then sets one from the
|
||||
* movement bits -- so an actor that stops moving is left facing nowhere, its
|
||||
* state drops to bare ALIVE, and character_ss_player.json has no sprite for
|
||||
* that. The actor becomes invisible while standing still.
|
||||
*
|
||||
* Clearing this is the documented way out: akgl_actor_automatic_face leaves
|
||||
* an actor alone entirely when it is clear, and the facing bits stay
|
||||
* wherever the control handlers last put them.
|
||||
*/
|
||||
obj->movement_controls_face = false;
|
||||
|
||||
/* Replace the hooks after akgl_actor_initialize, never before -- it
|
||||
* overwrites all six. The tilemap loader has already run it here. */
|
||||
obj->movementlogicfunc = &ss_player_movement;
|
||||
obj->updatefunc = &ss_player_update;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
|
||||
{
|
||||
akgl_ControlMap *controlmap = NULL;
|
||||
akgl_Control control;
|
||||
SDL_KeyboardID *keyboards = NULL;
|
||||
SDL_JoystickID *gamepads = NULL;
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
|
||||
/* akgl_controller_pushmap checks the upper bound and not the lower one, so
|
||||
* a negative id indexes before the start of akgl_controlmaps. TODO.md,
|
||||
* "Known and still open" item 11. */
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Control map id %d is outside 0..%d",
|
||||
controlmapid,
|
||||
(AKGL_MAX_CONTROL_MAPS - 1)
|
||||
);
|
||||
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
|
||||
|
||||
controlmap = &akgl_controlmaps[controlmapid];
|
||||
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
controlmap->target,
|
||||
AKGL_ERR_REGISTRY,
|
||||
"Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded",
|
||||
actorname
|
||||
);
|
||||
|
||||
/*
|
||||
* A control map listens to exactly one keyboard and one gamepad, matched by
|
||||
* id, and a binding whose id does not match the event's is not consulted.
|
||||
* That is what keeps two local players on two keyboards apart -- and it is
|
||||
* also why "the arrow keys do nothing" is usually the wrong id rather than
|
||||
* the wrong key.
|
||||
*/
|
||||
keyboards = SDL_GetKeyboards(&count);
|
||||
if ( keyboards != NULL ) {
|
||||
if ( count > 0 ) {
|
||||
controlmap->kbid = keyboards[0];
|
||||
}
|
||||
SDL_free(keyboards);
|
||||
}
|
||||
gamepads = SDL_GetGamepads(&count);
|
||||
if ( gamepads != NULL ) {
|
||||
if ( count > 0 ) {
|
||||
controlmap->jsid = gamepads[0];
|
||||
}
|
||||
SDL_free(gamepads);
|
||||
}
|
||||
|
||||
/* ---- keyboard ---- */
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
control.key = SDLK_LEFT;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &ss_control_left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
control.key = SDLK_RIGHT;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &ss_control_right_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
control.key = SDLK_SPACE;
|
||||
control.handler_on = &ss_control_jump_on;
|
||||
control.handler_off = &ss_control_jump_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
/* ---- gamepad ---- */
|
||||
/* Clear the keycode first: a keyboard event is matched on `key` whatever
|
||||
* else the binding carries, and 0 is a keycode like any other. */
|
||||
control.key = 0;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &ss_control_left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &ss_control_right_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
control.button = SDL_GAMEPAD_BUTTON_SOUTH;
|
||||
control.handler_on = &ss_control_jump_on;
|
||||
control.handler_off = &ss_control_jump_off;
|
||||
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *ss_player_autoplay(int frame)
|
||||
{
|
||||
SDL_Event synthetic;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
|
||||
PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event)));
|
||||
synthetic.type = SDL_EVENT_KEY_DOWN;
|
||||
|
||||
/*
|
||||
* The headless smoke run has no keyboard, so it calls the handlers a
|
||||
* keyboard would have called. They take the actor and an event, and the
|
||||
* keyboard handlers do not read the event beyond requiring one -- which is
|
||||
* what makes a scripted run possible at all.
|
||||
*/
|
||||
if ( frame == 1 ) {
|
||||
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
|
||||
}
|
||||
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
|
||||
ss_game.jump_requested = true;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
135
examples/sidescroller/sidescroller.h
Normal file
135
examples/sidescroller/sidescroller.h
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file sidescroller.h
|
||||
* @brief Shared declarations for the sidescroller tutorial game.
|
||||
*
|
||||
* The game is four translation units and this is the seam between them:
|
||||
*
|
||||
* main.c startup, asset loading, the frame loop, teardown
|
||||
* collision.c the tile queries and the swept resolution libakgl does not have
|
||||
* player.c the player's hooks and its control bindings
|
||||
* actors.c the coins, the patrolling blob, the flying moth
|
||||
*
|
||||
* Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game
|
||||
* built on it takes its own, and this one is `ss_`.
|
||||
*/
|
||||
|
||||
#ifndef _SIDESCROLLER_H_
|
||||
#define _SIDESCROLLER_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
/*
|
||||
* The level's geometry, in the units the map is authored in. level1.tmj is 40x15
|
||||
* tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240
|
||||
* window onto it that scrolls horizontally.
|
||||
*/
|
||||
#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
|
||||
#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
|
||||
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
|
||||
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
|
||||
|
||||
/*
|
||||
* Which layer of the map is solid.
|
||||
*
|
||||
* akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
|
||||
* layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
|
||||
* `y` and `type`, and nothing else. So a game cannot ask for "the layer called
|
||||
* terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
|
||||
*/
|
||||
#define SS_TERRAIN_LAYER_ID 2
|
||||
|
||||
/* The player's collision box, as an offset into its 32x32 sprite frame. The art
|
||||
* does not fill the frame edge to edge, and a box the full width of the frame
|
||||
* catches on doorways the character visibly clears. */
|
||||
#define SS_PLAYER_BOX_X 8.0f
|
||||
#define SS_PLAYER_BOX_Y 0.0f
|
||||
#define SS_PLAYER_BOX_W 16.0f
|
||||
#define SS_PLAYER_BOX_H 32.0f
|
||||
|
||||
/* Upward velocity a jump installs directly into the actor's environmental term,
|
||||
* in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little
|
||||
* under 100 px -- six tiles -- which is what the platform heights are cut to. */
|
||||
#define SS_JUMP_SPEED 420.0f
|
||||
|
||||
/* How fast thrust bleeds away when no direction is held, as a fraction per
|
||||
* second. The library has no friction at all: see ss_player_friction. */
|
||||
#define SS_FRICTION_GROUND 12.0f
|
||||
#define SS_FRICTION_AIR 1.5f
|
||||
|
||||
/* How many of each kind of thing level1.tmj places. */
|
||||
#define SS_COIN_COUNT 4
|
||||
#define SS_HAZARD_COUNT 2
|
||||
|
||||
/* How far the autoplay script waits between jumps, in frames. */
|
||||
#define SS_AUTOPLAY_JUMP_PERIOD 45
|
||||
|
||||
/**
|
||||
* @brief What one call to ss_collide_resolve found.
|
||||
*
|
||||
* `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
|
||||
* step; `grounded` says there is solid terrain one pixel under where the actor
|
||||
* will be when the step finishes, which is the test a jump is gated on.
|
||||
*/
|
||||
typedef struct {
|
||||
bool blocked_x;
|
||||
bool blocked_y;
|
||||
bool grounded;
|
||||
} ss_Contact;
|
||||
|
||||
/**
|
||||
* @brief Per-actor game data, hung off akgl_Actor::actorData.
|
||||
*
|
||||
* The library never reads or frees `actorData`, so it is the place a game puts
|
||||
* what the library has no field for. These live in a fixed table in actors.c
|
||||
* rather than being allocated, which is the same discipline the library's own
|
||||
* pools follow.
|
||||
*/
|
||||
typedef struct {
|
||||
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
|
||||
float32_t home_y;
|
||||
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
|
||||
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
|
||||
} ss_ActorData;
|
||||
|
||||
/** @brief The whole game's state. One player, one level, no menus. */
|
||||
typedef struct {
|
||||
akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
|
||||
akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
|
||||
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
|
||||
int coins_taken;
|
||||
int deaths;
|
||||
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
|
||||
bool grounded; /**< Last step's verdict; what gates the next jump. */
|
||||
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
|
||||
int frame; /**< Frames drawn so far. */
|
||||
} ss_Game;
|
||||
|
||||
extern ss_Game ss_game;
|
||||
|
||||
/* collision.c -- everything akgl_physics_arcade_collide would have done. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
|
||||
|
||||
/* player.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
|
||||
|
||||
/* actors.c */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
|
||||
|
||||
#endif // _SIDESCROLLER_H_
|
||||
Reference in New Issue
Block a user