699 lines
22 KiB
C
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;
|
||
|
|
}
|