Files
akbasic/examples/galaga/enemies.c
Tachikoma 7d085d8815 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 18:49:11 -04:00

315 lines
10 KiB
C

/**
* @file enemies.c
* @brief The formation, the wave, and the hook that hands each enemy to BASIC.
*
* C owns the grid, the wave table and the spawn timing; BASIC owns everything
* an enemy does after it exists. The formation slot arrives in SELF@.HOMEX% /
* HOMEY%, so even the idle breathing of the grid is the script's, computed
* relative to home.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include "galaga.h"
galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES];
akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES];
/* Explosion lifetimes, indexed by heap slot. An explosion is an actor with
* nothing to decide, so its whole state is one countdown. */
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
/* A spawn serial per shot so registry names never collide while two shots
* with the same slot number are briefly both alive. */
static uint32_t SHOT_SERIAL = 0;
static uint32_t BOOM_SERIAL = 0;
/*
* The wave, one row per formation row. Columns are 0..9 at GALAGA_COL_PITCH;
* `first` and `count` say which columns the row fills. 40 enemies: 4 bosses,
* 16 butterflies, 20 bees -- 59 of the 64 actor heap slots at peak, counting
* the player, two player shots, eight enemy shots and eight explosions.
*/
static const struct
{
int32_t kind; /* GALAGA_ENEMY_* */
int row; /* formation row */
int first; /* first column filled */
int count; /* columns filled */
int32_t hp;
}
WAVE_ROWS[] = {
/* kind row first count hp */
{ GALAGA_ENEMY_BOSS, 0, 3, 4, 2 },
{ GALAGA_ENEMY_BUTTERFLY, 1, 1, 8, 1 },
{ GALAGA_ENEMY_BUTTERFLY, 2, 1, 8, 1 },
{ GALAGA_ENEMY_BEE, 3, 0, 10, 1 },
{ GALAGA_ENEMY_BEE, 4, 0, 10, 1 }
};
#define WAVE_ROW_COUNT ((int)(sizeof(WAVE_ROWS) / sizeof(WAVE_ROWS[0])))
/* Enemy kind -> character name, the render half of the dispatch table. */
static char *ENEMY_CHARACTER[GALAGA_ENEMY_KINDS] = {
"galaga_bee",
"galaga_butterfly",
"galaga_boss"
};
/* --------------------------------------------------------------- random --- */
/*
* No RND verb exists (issue #16), so the engine is the script's only source
* of randomness: it refreshes GAME@.RND% each frame and SELF@.RND% each call
* from this PRNG. A hand-rolled LCG rather than rand() so a headless run is
* the same game on every libc.
*/
static uint32_t PRNG_STATE = 0x12345678u;
float galaga_random(void)
{
PRNG_STATE = PRNG_STATE * 1664525u + 1013904223u;
return (float)(PRNG_STATE >> 8) / (float)0x01000000u;
}
/* -------------------------------------------------------------- helpers --- */
static akerr_ErrorContext *release_actor(akgl_Actor *actor)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
PASS(errctx, akgl_heap_release_actor(actor));
SUCCEED_RETURN(errctx);
}
/* ---------------------------------------------------------------- shots --- */
/**
* @brief Move an enemy shot; release it once it has left the screen.
*/
static akerr_ErrorContext *enemy_shot_update(akgl_Actor *obj)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
obj->y += 380.0f * galaga_game.dt;
if ( obj->y > (float)GALAGA_VIEW_HEIGHT + 60.0f ) {
galaga_game.enemy_shots_live -= 1;
PASS(errctx, release_actor(obj));
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Consume an enemy's fire flag: take an actor and aim it downward.
*
* The script only raises a flag. Spawning takes a slot from the actor heap,
* and pool exhaustion must be a C-side refusal with the house error context --
* so C consumes the flag and does the spawn. The engine also enforces the
* eight-shot cap by simply not consuming the flag's wish.
*/
static akerr_ErrorContext *enemy_fire(galaga_Enemy *enemy, akgl_Actor *from)
{
akgl_Actor *shot = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy");
FAIL_ZERO_RETURN(errctx, from, AKERR_NULLPOINTER, "from");
enemy->fire = 0;
if ( galaga_game.enemy_shots_live >= GALAGA_MAX_ENEMY_SHOTS ) {
SUCCEED_RETURN(errctx);
}
SHOT_SERIAL += 1;
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "eshot%u", SHOT_SERIAL));
PASS(errctx, akgl_heap_next_actor(&shot));
PASS(errctx, akgl_actor_initialize(shot, name));
PASS(errctx, akgl_actor_set_character(shot, "galaga_enemyshot"));
/* AFTER initialize: it resets all seven hooks. */
shot->updatefunc = &enemy_shot_update;
shot->movement_controls_face = false;
shot->state = AKGL_ACTOR_STATE_ALIVE;
/* akgl_actor_initialize() does not raise `visible`; a hand-spawned actor
* that skips this line exists, moves and collides -- invisibly. */
shot->visible = true;
/* Actor x/y is a sprite's top-left corner; the shot leaves the enemy's
* midline. Enemy sprites run 93..104 wide, the shot is 9. */
shot->x = from->x + 46.0f;
shot->y = from->y + 60.0f;
galaga_game.enemy_shots_live += 1;
galaga_game.shots[enemy->kind] += 1;
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------- explosions --- */
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
{
ptrdiff_t slot = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
slot = obj - akgl_heap_actors;
BOOM_TTL[slot] -= galaga_game.dt;
if ( BOOM_TTL[slot] <= 0.0f ) {
PASS(errctx, release_actor(obj));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
{
akgl_Actor *boom = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_actor(&boom));
BOOM_SERIAL += 1;
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
CATCH(errctx, akgl_actor_initialize(boom, name));
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
boom->updatefunc = &boom_update;
boom->movement_controls_face = false;
boom->state = AKGL_ACTOR_STATE_ALIVE;
boom->visible = true;
boom->x = x;
boom->y = y;
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKGL_ERR_HEAP) {
/* Explosions are decoration. When the heap is momentarily full the
* right outcome is no explosion, not a dead frame -- this is the one
* spawn that absorbs exhaustion. */
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- enemies --- */
/**
* @brief The custom update hook: one enemy, once per frame, thought in BASIC.
*
* The whole body is the protocol from docs/20: refresh the inbox, hand the
* pair to the script, consume the outbox. akgl_game_update() calls this in
* place of akgl_actor_update() because spawn replaced the hook.
*/
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
{
galaga_Enemy *enemy = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
enemy = (galaga_Enemy *)obj->actorData;
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
enemy->rnd = galaga_random();
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
if ( enemy->fire != 0 ) {
PASS(errctx, enemy_fire(enemy, obj));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_wave_spawn(void)
{
akgl_Actor *actor = NULL;
galaga_Enemy *enemy = NULL;
char name[32];
int row = 0;
int col = 0;
int index = 0;
int count = 0;
PREPARE_ERROR(errctx);
for ( row = 0; row < WAVE_ROW_COUNT; row++ ) {
for ( col = 0; col < WAVE_ROWS[row].count; col++ ) {
FAIL_NONZERO_RETURN(errctx, (index >= GALAGA_MAX_ENEMIES), AKERR_OUTOFBOUNDS,
"The wave table places more than %d enemies", GALAGA_MAX_ENEMIES);
enemy = &galaga_enemies[index];
memset(enemy, 0, sizeof(*enemy));
enemy->kind = WAVE_ROWS[row].kind;
enemy->state = GALAGA_ES_ENTERING;
enemy->homex = (float)(GALAGA_FORM_LEFT
+ (WAVE_ROWS[row].first + col) * GALAGA_COL_PITCH);
enemy->homey = (float)(GALAGA_FORM_TOP + WAVE_ROWS[row].row * GALAGA_ROW_PITCH);
enemy->hp = WAVE_ROWS[row].hp;
/* Stagger the entries: each enemy's clock starts in the past, and
* the script holds still until its own t crosses zero. */
enemy->t = -0.08f * (float)index;
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "enemy%02d", index));
PASS(errctx, akgl_heap_next_actor(&actor));
PASS(errctx, akgl_actor_initialize(actor, name));
PASS(errctx, akgl_actor_set_character(actor, ENEMY_CHARACTER[enemy->kind]));
/* AFTER initialize: it resets all seven hooks. */
actor->updatefunc = &enemy_update;
actor->actorData = enemy;
/* Nothing here moves by state bits, and an actor whose state word
* matches no character mapping is silently not drawn -- so facing
* stays entirely out of the state word. */
actor->movement_controls_face = false;
actor->state = AKGL_ACTOR_STATE_ALIVE;
/* akgl_actor_initialize() does not raise `visible` -- the map
* loader copies it from map data, and there is no map here. Skip
* this and the whole wave exists, moves, fires and dies without
* ever being drawn. */
actor->visible = true;
/* Off screen above, pouring in from whichever side is closer. */
actor->x = (enemy->homex < (float)GALAGA_VIEW_WIDTH / 2.0f)
? -80.0f : (float)GALAGA_VIEW_WIDTH + 80.0f;
actor->y = -80.0f;
galaga_enemy_actors[index] = actor;
index += 1;
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_wave_release(void)
{
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] != NULL ) {
PASS(errctx, release_actor(galaga_enemy_actors[i]));
galaga_enemy_actors[i] = NULL;
}
}
SUCCEED_RETURN(errctx);
}
int galaga_enemies_alive(void)
{
int i = 0;
int alive = 0;
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] != NULL ) {
alive += 1;
}
}
return alive;
}