Files
akbasic/examples/galaga/main.c
Tachikoma 27837aeabc Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.

Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).

Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:21 -04:00

699 lines
22 KiB
C

/**
* @file main.c
* @brief Startup, the frame loop, the screens, and teardown.
*
* The startup order is libakgl's one sequence that works (deps/libakgl
* include/akgl/game.h): metadata, akgl_game_init(), screen properties,
* akgl_render_2d_init(), a physics backend -- and then, new in this example,
* the interpreter. The scripts compute, the engine draws; the interpreter is
* lent no devices at all, so a script that tries SPRITE is refused by name.
*/
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/game.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/ui.h>
#include "galaga.h"
/** @brief Where the example's assets live. CMake defines it; `--assets` overrides. */
#ifndef GALAGA_ASSET_DIR
#define GALAGA_ASSET_DIR "."
#endif
/** @brief The enemy script. CMake defines it; `--script` overrides. */
#ifndef GALAGA_SCRIPT_PATH
#define GALAGA_SCRIPT_PATH "galaga.bas"
#endif
/** @brief The HUD font. CMake defines it; headless runs still need it for the UI. */
#ifndef GALAGA_FONT_PATH
#define GALAGA_FONT_PATH "font.ttf"
#endif
#define GALAGA_PATH_MAX 1024
galaga_Game galaga_game;
galaga_Shared galaga_shared;
/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
static char *SHOTPATH = NULL;
static int SHOTFRAME = 0;
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
static int FAILED = 0;
/* ------------------------------------------------------------- starfield --- */
/*
* No parallax facility exists in libakgl and none is needed: a fixed array of
* stars advanced per frame and drawn with akgl_draw_point() between
* frame_start and akgl_game_update(). Two speed bands give the depth for
* free -- the slow band reads as far away.
*/
#define GALAGA_STARS 96
static struct
{
float x;
float y;
float speed;
Uint8 bright;
} STARS[GALAGA_STARS];
static void starfield_seed(void)
{
int i = 0;
for ( i = 0; i < GALAGA_STARS; i++ ) {
STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
STARS[i].y = galaga_random() * (float)GALAGA_VIEW_HEIGHT;
if ( (i % 2) == 0 ) {
STARS[i].speed = 40.0f; /* the far band */
STARS[i].bright = 110;
} else {
STARS[i].speed = 110.0f; /* the near band */
STARS[i].bright = 220;
}
}
}
static akerr_ErrorContext *starfield_draw(void)
{
SDL_Color color = { 255, 255, 255, 255 };
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < GALAGA_STARS; i++ ) {
STARS[i].y += STARS[i].speed * galaga_game.dt;
if ( STARS[i].y > (float)GALAGA_VIEW_HEIGHT ) {
STARS[i].y -= (float)GALAGA_VIEW_HEIGHT;
STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
}
color.r = STARS[i].bright;
color.g = STARS[i].bright;
color.b = STARS[i].bright;
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
}
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------ screenshots --- */
/**
* @brief Read the render target back and write it out as a PNG.
*
* Called after everything has drawn and before the frame is presented,
* because SDL_RenderPresent is where the target stops being readable. The
* figures in docs/20 and docs/21 are output from this program rather than
* pictures somebody took once, so they cannot show a game that no longer
* exists.
*/
static akerr_ErrorContext *save_screenshot(char *path)
{
SDL_Surface *shot = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError());
ATTEMPT {
FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL,
"IMG_SavePNG(%s): %s", path, SDL_GetError());
} CLEANUP {
SDL_DestroySurface(shot);
} PROCESS(errctx) {
} FINISH(errctx, true);
SDL_Log("Wrote %s", path);
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- assets --- */
static char *SPRITE_FILES[] = {
"sprite_galaga_player.json",
"sprite_galaga_bee.json",
"sprite_galaga_butterfly.json",
"sprite_galaga_boss.json",
"sprite_galaga_boss_hurt.json",
"sprite_galaga_playershot.json",
"sprite_galaga_enemyshot.json",
"sprite_galaga_boom.json",
NULL
};
static char *CHARACTER_FILES[] = {
"character_galaga_player.json",
"character_galaga_bee.json",
"character_galaga_butterfly.json",
"character_galaga_boss.json",
"character_galaga_playershot.json",
"character_galaga_enemyshot.json",
"character_galaga_boom.json",
NULL
};
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 Sprites first, characters second. Not a preference: a character's
* JSON names its sprites by registry name, so a character loaded first fails
* on the first sprite it cannot find.
*/
static akerr_ErrorContext *load_assets(char *assetdir)
{
char path[GALAGA_PATH_MAX];
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
for ( i = 0; SPRITE_FILES[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, SPRITE_FILES[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_sprite_load_json((char *)&path));
}
for ( i = 0; CHARACTER_FILES[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, CHARACTER_FILES[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_character_load_json((char *)&path));
}
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- startup --- */
/** @brief Replacement for akgl_game.lowfpsfunc, which logs a line per frame. */
static void galaga_lowfps(void)
{
}
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
"akbasic galaga 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.akbasic.galaga", sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
akgl_game.lowfpsfunc = &galaga_lowfps;
/* Properties before the renderer: akgl_render_2d_init reads both, and an
* unset one defaults to the string "0" -- a zero-sized window. */
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
PASS(errctx, akgl_set_property("game.screenheight", "960"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
GALAGA_VIEW_WIDTH,
GALAGA_VIEW_HEIGHT,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
/* The view is what the camera looks through, so it says the same thing. */
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float)GALAGA_VIEW_WIDTH;
akgl_camera->h = (float)GALAGA_VIEW_HEIGHT;
/*
* akgl_game_init does NOT install a physics backend, whatever physics.h's
* file comment says (libakgl docs/14-physics.md). Null physics accepts
* every call and moves nothing: whatever writes x and y directly is the
* mover, and in this game that is BASIC writing through ACTOR@.
*/
PASS(errctx, akgl_physics_init_null(akgl_physics));
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- the UI --- */
static akgl_UiMenu TITLE_MENU = {
"titlemenu", { "START", "QUIT" }, 2, 0, false, NULL
};
static akgl_UiMenu AGAIN_MENU = {
"againmenu", { "PLAY AGAIN", "QUIT" }, 2, 0, false, NULL
};
/* Clay borrows label text until frame_end, so these cannot be locals. */
static char HUD_SCORE[64];
static char HUD_LIVES[64];
/**
* @brief Draw a headline centred above the menu, in the banner font.
*
* Direct text rather than a ui label: the menu owns AKGL_UI_ANCHOR_CENTER,
* and a label anchored there disappears behind it -- there is no
* top-centre anchor to reach for. Drawn before the UI bracket, so the menu
* still paints over it if the two ever meet.
*/
static akerr_ErrorContext *draw_banner(char *text)
{
SDL_Color ink = { 235, 235, 235, 255 };
TTF_Font *font = NULL;
int w = 0;
int h = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
PASS(errctx, akgl_text_measure(font, text, &w, &h));
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0,
(GALAGA_VIEW_WIDTH - w) / 2, 280));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *declare_title(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_ui_menu(&TITLE_MENU));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *declare_play(void)
{
int count = 0;
PREPARE_ERROR(errctx);
PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE),
"SCORE %06d", galaga_game.score));
PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES),
"LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave));
PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *declare_end(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_ui_menu(&AGAIN_MENU));
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------ transitions --- */
static akerr_ErrorContext *start_game(void)
{
PREPARE_ERROR(errctx);
galaga_game.score = 0;
galaga_game.lives = 3;
memset(galaga_game.kills, 0, sizeof(galaga_game.kills));
memset(galaga_game.shots, 0, sizeof(galaga_game.shots));
galaga_game.respawn_timer = 0.0f;
galaga_shared.wave = 1;
galaga_game.player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
PASS(errctx, galaga_wave_spawn());
galaga_game.screen = GALAGA_SCREEN_PLAY;
SUCCEED_RETURN(errctx);
}
/**
* @brief End-of-round bookkeeping: notice a cleared wave or a spent ship.
*/
static akerr_ErrorContext *check_transitions(bool *running)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
(void)running;
if ( galaga_game.screen != GALAGA_SCREEN_PLAY ) {
SUCCEED_RETURN(errctx);
}
if ( galaga_game.lives <= 0 ) {
PASS(errctx, galaga_wave_release());
galaga_game.screen = GALAGA_SCREEN_GAMEOVER;
SUCCEED_RETURN(errctx);
}
if ( galaga_enemies_alive() == 0 ) {
galaga_game.screen = GALAGA_SCREEN_VICTORY;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Consume a menu activation. The menu never clears `activated`; the
* state machine that acts on it does.
*/
static akerr_ErrorContext *consume_menus(bool *running)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
if ( galaga_game.screen == GALAGA_SCREEN_TITLE && TITLE_MENU.activated ) {
TITLE_MENU.activated = false;
if ( TITLE_MENU.selected == 0 ) {
PASS(errctx, start_game());
} else {
*running = false;
}
}
if ( (galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|| galaga_game.screen == GALAGA_SCREEN_VICTORY)
&& AGAIN_MENU.activated ) {
AGAIN_MENU.activated = false;
if ( AGAIN_MENU.selected == 0 ) {
PASS(errctx, galaga_wave_release());
PASS(errctx, start_game());
} else {
*running = false;
}
}
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------- the frame --- */
/**
* @brief Route one event: the UI gets first refusal, then the menus, then
* the controller. A consumed event goes no further.
*/
static akerr_ErrorContext *route_event(SDL_Event *event, bool *running)
{
bool consumed = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
if ( event->type == SDL_EVENT_QUIT ) {
*running = false;
SUCCEED_RETURN(errctx);
}
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
if ( consumed ) {
SUCCEED_RETURN(errctx);
}
if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
PASS(errctx, akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed));
} else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|| galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
PASS(errctx, akgl_ui_menu_handle_event(&AGAIN_MENU, event, &consumed));
}
if ( consumed ) {
SUCCEED_RETURN(errctx);
}
/* 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));
SUCCEED_RETURN(errctx);
}
/** @brief The previous frame's timestamp, for dt. Stamped once at loop start. */
static uint64_t LAST_NS = 0;
static akerr_ErrorContext *frame(bool *running)
{
SDL_Event event;
uint64_t now = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
while ( SDL_PollEvent(&event) == true ) {
PASS(errctx, route_event(&event, running));
}
galaga_game.frame += 1;
if ( galaga_game.autoplay ) {
if ( galaga_game.screen == GALAGA_SCREEN_TITLE && galaga_game.frame >= 8 ) {
PASS(errctx, start_game());
}
/*
* On an end screen the pilot presses Return, which drives the real
* menu path -- declare, handle, activate, restart -- so a headless
* run that dies keeps exercising the game instead of idling.
*/
if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|| galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
if ( (galaga_game.frame % 30) == 0 ) {
SDL_Event press;
memset(&press, 0, sizeof(press));
press.type = SDL_EVENT_KEY_DOWN;
press.key.key = SDLK_RETURN;
PASS(errctx, route_event(&press, running));
}
}
PASS(errctx, galaga_player_autoplay(galaga_game.frame));
}
/*
* dt from the wall clock, clamped: a debugger pause or a stalled runner
* must not become one frame of teleporting enemies. The clamp is a 30 Hz
* frame, the slowest game this is still worth playing at.
*/
now = SDL_GetTicksNS();
galaga_game.dt = (float)(now - LAST_NS) / 1e9f;
LAST_NS = now;
if ( galaga_game.dt > (1.0f / 30.0f) ) {
galaga_game.dt = 1.0f / 30.0f;
}
/* The shared frame state, refreshed before any enemy thinks. The engine
* filling GAME@.RND% is the issue #16 route: no RND verb exists. */
galaga_shared.playerx = galaga_game.player->x + 50.0f;
galaga_shared.playery = galaga_game.player->y;
galaga_shared.rnd = galaga_random();
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, starfield_draw());
/*
* akgl_game_update is update-every-actor, step-the-physics, draw-the-
* world. Updating every actor is where the forty scripts run: each
* enemy's updatefunc is the hook in enemies.c, and that hook is a BASIC
* call. Held back on the menu screens so the world stands still there.
*/
if ( galaga_game.screen == GALAGA_SCREEN_PLAY ) {
PASS(errctx, akgl_game_update(NULL));
PASS(errctx, check_transitions(running));
}
/* The banner is direct text, drawn before the UI bracket. */
if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
PASS(errctx, draw_banner("GALAGA"));
} else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER ) {
PASS(errctx, draw_banner("GAME OVER"));
} else if ( galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
PASS(errctx, draw_banner("VICTORY"));
}
/* The UI bracket sits between akgl_game_update and frame_end, exactly as
* libakgl docs/22-ui.md draws it. */
PASS(errctx, akgl_ui_frame_begin());
switch ( galaga_game.screen ) {
case GALAGA_SCREEN_TITLE:
PASS(errctx, declare_title());
break;
case GALAGA_SCREEN_PLAY:
PASS(errctx, declare_play());
break;
case GALAGA_SCREEN_GAMEOVER:
case GALAGA_SCREEN_VICTORY:
PASS(errctx, declare_end());
break;
}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
PASS(errctx, consume_menus(running));
if ( (SHOTPATH != NULL) && (galaga_game.frame == SHOTFRAME) ) {
PASS(errctx, save_screenshot(SHOTPATH));
}
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *run(int frames)
{
bool running = true;
PREPARE_ERROR(errctx);
LAST_NS = SDL_GetTicksNS();
while ( running == true ) {
PASS(errctx, frame(&running));
if ( (frames > 0) && (galaga_game.frame >= frames) ) {
running = false;
}
/* A crude frame limiter. A game on a real display should ask SDL for
* vsync; this one has to work under the dummy video driver, where
* there is nothing to sync to. */
SDL_Delay(16);
}
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- teardown --- */
/**
* @brief Give back what the process is holding.
*
* There is no akgl_game_shutdown; teardown is the application's. Fonts have
* to unload before TTF_Quit destroys them underneath the registry. IGNORE()
* on every call: a teardown failure must not mask whatever error is already
* being reported.
*/
static void shutdown_game(void)
{
int i = 0;
IGNORE(akgl_ui_shutdown());
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]));
}
}
TTF_Quit();
SDL_Quit();
}
/* ------------------------------------------------------------------ args --- */
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir,
char **script, int *frames)
{
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
FAIL_ZERO_RETURN(errctx, script, AKERR_NULLPOINTER, "script");
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
for ( i = 1; i < argc; i++ ) {
if ( strcmp(argv[i], "--autoplay") == 0 ) {
galaga_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 if ( strcmp(argv[i], "--script") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--script needs a path");
*script = argv[i];
} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot needs a path");
SHOTPATH = argv[i];
} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
i += 1;
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE,
"--screenshot-frame needs a number");
PASS(errctx, aksl_atoi(argv[i], &SHOTFRAME));
} else {
FAIL_RETURN(
errctx,
AKERR_VALUE,
"usage: galaga [--assets DIR] [--script PATH] [--frames N]"
" [--autoplay] [--screenshot PATH] [--screenshot-frame N]"
);
}
}
SUCCEED_RETURN(errctx);
}
int main(int argc, char *argv[])
{
char *assetdir = GALAGA_ASSET_DIR;
char *script = GALAGA_SCRIPT_PATH;
int frames = 0;
uint16_t fontid = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, parse_args(argc, argv, &assetdir, &script, &frames));
CATCH(errctx, startup());
CATCH(errctx, load_assets(assetdir));
/* The engine refuses to start when the script will not boot: a game
* whose enemies cannot think is not a game missing a feature. */
CATCH(errctx, galaga_script_boot(script));
CATCH(errctx, akgl_ui_init(GALAGA_VIEW_WIDTH, GALAGA_VIEW_HEIGHT));
CATCH(errctx, akgl_text_loadfont("hud", GALAGA_FONT_PATH, 28));
CATCH(errctx, akgl_text_loadfont("banner", GALAGA_FONT_PATH, 84));
CATCH(errctx, akgl_ui_font_register("hud", &fontid));
CATCH(errctx, galaga_player_spawn());
CATCH(errctx, galaga_player_controls());
starfield_seed();
galaga_game.screen = GALAGA_SCREEN_TITLE;
galaga_game.lives = 3;
CATCH(errctx, run(frames));
} CLEANUP {
shutdown_game();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "galaga 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. */
FAILED = 1;
/* FINISH_NORETURN rather than FINISH: FINISH expands a return that an
* int-returning function cannot compile. */
} FINISH_NORETURN(errctx);
/*
* The readout is the evidence: exiting 0 is not proof the wave flew. A
* headless CI log gets the same line a reader's terminal does, and the
* script-error count is the line's whole reason to exist -- a wave of
* dumb enemies still exits 0.
*/
SDL_Log(
"galaga: %d frames, screen %d, score %d, alive %d, kills bee %d bfly %d boss %d,"
" shots bee %d bfly %d boss %d, script errors %d",
galaga_game.frame,
(int)galaga_game.screen,
galaga_game.score,
galaga_enemies_alive(),
galaga_game.kills[GALAGA_ENEMY_BEE],
galaga_game.kills[GALAGA_ENEMY_BUTTERFLY],
galaga_game.kills[GALAGA_ENEMY_BOSS],
galaga_game.shots[GALAGA_ENEMY_BEE],
galaga_game.shots[GALAGA_ENEMY_BUTTERFLY],
galaga_game.shots[GALAGA_ENEMY_BOSS],
galaga_game.script_errors);
return FAILED;
}