All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m46s
akbasic CI Build / coverage (push) Successful in 4m16s
akbasic CI Build / sanitizers (push) Successful in 8m10s
akbasic CI Build / akgl_build (push) Successful in 8m10s
akbasic CI Build / mutation_test (push) Successful in 17m55s
Adding native RND and ASC (ae2c702) made RND a function name, and a suffixed
identifier that collides with one is refused -- "SYNTAX ERROR Reserved word in
variable name". Three example programs held their PRNG output in a variable
called RND#, or a host field called RND%, and none of them had run since:
- examples/galaga/ bound RND% as a host field on both ENEMY and GAME. The
BASIC-visible name is ROLL% now; the C member stays `rnd`. This one was
caught by example_galaga and example_galaga_interop, which have been
failing.
- examples/breakout/characters/breakout.bas and examples/megademo/
megademo.bas both use RND# for their LCG output, renamed to ROLL#. Neither
is in any test, so neither failure was visible.
examples/breakout/sprites/breakout.bas was broken a second way: seven REM lines
the reader refuses. Worth recording that the ceiling is not the one the message
names -- src/sink_stdio.c fails when the read filled the buffer without seeing
a terminator, so with AKBASIC_MAX_LINE_LENGTH at 80 the message says "79
character limit" and the real maximum is 78, because a 79-character line leaves
no room for the newline. The sweeps that fixed the corpus and the megademo for
this did not reach this file. The seven comments are reflowed.
The prose went stale with the code. Chapter 21 said "there is no RND verb in
this dialect; issue #16 tracks adding one", chapter 17's historical aside
offered an LCG that no longer parses, and four REM blocks across the two games
said the same thing. All of them now say RND exists, and say why these programs
keep their own generator anyway: the sequence has to be reproducible for a
headless run to be the same game on every machine, which is what lets
interop_test.c assert exact counts.
Chapter 21 also gains the rule that bit them, since a reader writing a host
type will hit it: a host field name is a bare word and shares a namespace with
every verb and function.
None of this came from the submodule bump -- all three were already broken on
main. It was found by running the tutorial games, which nothing else does;
that gap is akbasic issue #58.
Verified: all three run clean under the dummy drivers, and 114/114 default,
116/116 with akgl.
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
Co-Authored-By: Claude Code (Claude Opus 5, claude-opus-5[1m]) <noreply@anthropic.com>
700 lines
22 KiB
C
700 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
|
|
* fills GAME@.ROLL% from its own PRNG rather than letting the script call
|
|
* the native RND, so a headless run is the same game on every machine. */
|
|
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;
|
|
}
|