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
This commit is contained in:
2026-08-04 08:47:21 -04:00
parent 743e610f8f
commit 27837aeabc
36 changed files with 2532 additions and 0 deletions

View File

@@ -268,6 +268,69 @@ if(AKBASIC_BUILD_EXAMPLES)
endforeach()
endif()
# The galaga example: a C game on libakgl with the interpreter embedded as its
# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it
# is compiled and run by every AKGL build rather than rotting in a document.
# The asset, script and font paths are baked in so the smoke test can launch
# from any working directory; --assets and --script override them at runtime.
if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
add_executable(akbasic_example_galaga
examples/galaga/main.c
examples/galaga/script.c
examples/galaga/enemies.c
examples/galaga/player.c)
target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
akbasic_instrument(akbasic_example_galaga)
# Ten seconds of scripted play under the headless drivers: the script boots,
# a wave enters and forms, the autoplay pilot shoots at it, and the program
# tears down and exits 0. A tutorial that stops working fails here rather
# than in front of a reader.
_add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay)
_set_tests_properties(example_galaga PROPERTIES TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
# The boundary's round-trip test: links the real script.c and the real
# galaga.bas, and fails the moment the two sides of the interop disagree.
add_executable(akbasic_example_galaga_interop
examples/galaga/interop_test.c
examples/galaga/script.c)
target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga_interop PRIVATE
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas")
target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl
SDL3::SDL3)
akbasic_instrument(akbasic_example_galaga_interop)
_add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop)
_set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120)
# Regenerating the game figures in docs/ is a deliberate act, never part of
# a build, for the same reason docs_screenshots is: the PNGs are checked in.
# Wall-clock dt makes each regeneration differ by a few pixels of starfield,
# so expect a binary diff every time this runs; commit one only when the
# content changed on purpose. (docs_galaga_figures, not docs_game_figures:
# the libakgl submodule already owns that target name.)
add_custom_target(docs_galaga_figures
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --frames 40
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-title.png"
--screenshot-frame 30
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --autoplay --frames 370
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-wave.png"
--screenshot-frame 360
DEPENDS akbasic_example_galaga
COMMENT "Regenerating the galaga figures in docs/images"
VERBATIM)
endif()
# ---------------------------------------------------------------------------
# Tests.
#

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
docs/images/galaga-wave.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

63
examples/galaga/README.md Normal file
View File

@@ -0,0 +1,63 @@
# GALAGA — a C engine with akbasic embedded as its enemy brain
A GALAGA-style fixed shooter whose core engine is C on libakgl, with akbasic
linked in as the scripting engine that owns every enemy's behavior. One BASIC
script — `galaga.bas`, nothing but `DEF` functions and an `END` — is called
once per enemy per frame through a custom `akgl_Actor` update hook. Bullets,
collision, scoring and screens are C forever; everything an enemy *decides* is
BASIC.
This is the checked-in example behind two tutorial chapters, and the chapters
are the intended way in:
* **[Chapter 20](../../docs/20-tutorial-galaga.md)** — the engine and the
boundary: from an empty file to a C game that boots a script and hands one
actor to BASIC.
* **[Chapter 21](../../docs/21-tutorial-galaga-enemies.md)** — the data
structures and the AI: from `SELF@` to a full attacking wave.
It is an academic exercise demonstrating *how* such an embed is done, not a
claim that it is the best way to write a GALAGA.
## Building and running
The example builds when both example and graphics builds are on:
```
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --target akbasic_example_galaga
build-akgl/akbasic_example_galaga
```
| Key | Does |
|---|---|
| Left / Right | move the ship |
| Space | fire (two shots on screen, the classic rule) |
| Return | choose a menu entry |
## Flags
```
akbasic_example_galaga [--assets DIR] [--script PATH] [--frames N]
[--autoplay] [--screenshot PATH] [--screenshot-frame N]
```
`--script` points at a different enemy script, which is the whole point of the
architecture: edit `galaga.bas`, run again, no rebuild. `--frames N` with
`--autoplay` is the headless smoke test CI runs under the dummy SDL drivers;
the final log line reports frames, score, kills and shots per kind, and the
script-error count — a wave of dumb enemies still exits 0, and that count is
how you notice.
## The files
| File | Owns |
|---|---|
| `galaga.h` | the shared structs — the whole boundary in one header |
| `main.c` | startup order, the frame loop, screens, the starfield |
| `script.c` | everything that touches the interpreter |
| `enemies.c` | wave table, formation grid, the enemy update hook |
| `player.c` | the ship, both bullet kinds, every collision |
| `galaga.bas` | every decision an enemy makes |
| `interop_test.c` | round-trip proof the boundary works, run by CTest |
| `assets/` | sprite/character JSON, and Kenney CC0 art under `assets/art/` |

View File

@@ -0,0 +1,14 @@
###############################################################################
Space Shooter (Remastered, plus fonts and sounds) by Kenney Vleugels (www.kenney.nl)
------------------------------
License (CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these graphics in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
###############################################################################

View File

@@ -0,0 +1,39 @@
# Where this art came from
Every PNG in this directory is from **Kenney's Space Shooter (Remastered)**, released
into the public domain under
[Creative Commons Zero](http://creativecommons.org/publicdomain/zero/1.0/).
`License.txt` is the pack's own licence file, copied here unedited.
* Source: <https://kenney.nl/assets/space-shooter-remastered>
* Downloaded: 2026-08-04, `kenney_space-shooter-remastered.zip`
* Author: Kenney (<https://www.kenney.nl>)
* Licence: CC0 1.0. Crediting is not required; it is here because it should be.
The files are the pack's `PNG/` versions, byte for byte — nothing is resized,
recoloured or re-encoded, so the checksum of any of them still matches the
distributed archive. `playerShip1_blue.png` sits at the top of `PNG/`; the enemies
are from `PNG/Enemies/` and the lasers from `PNG/Lasers/`.
| File | Size | Used for |
|---|---|---|
| `playerShip1_blue.png` | 99x75 | the player's ship |
| `enemyBlue1.png` | 93x84 | the bee |
| `enemyRed2.png` | 104x84 | the butterfly |
| `enemyGreen3.png` | 103x84 | the boss, at full health |
| `enemyBlack3.png` | 103x84 | the boss at one hit point — same silhouette, drained colour |
| `laserBlue01.png` | 9x54 | the player's shot |
| `laserRed01.png` | 9x54 | an enemy's shot |
| `laserBlue08.png` | 48x46 | the explosion burst |
The sprites are used at their distributed size: libakgl draws a sprite at the
sprite's own dimensions (`akgl_Actor.scale` is overwritten every frame — libakgl
docs/12-actors.md), so there is no way to draw these smaller, and the game's
1280x960 view is sized to fit a ten-column formation of them instead. The boss's
damage state is the same shape in a different colour deliberately: the swap has to
read at a glance from the top of the screen.
Everything else on screen — the starfield and the HUD — is drawn by the program
with `akgl_draw_point()` and the UI layer. See `../../README.md` for the run
instructions and the two tutorial chapters (docs/20, docs/21) for why only the
things that move are artwork.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_bee",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_bee"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_boom",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_boom"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"name": "galaga_boss",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_boss"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_UNDEFINED_13"
],
"sprite": "galaga_boss_hurt"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_butterfly",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_butterfly"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_enemyshot",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_enemyshot"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_player",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_player"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"name": "galaga_playershot",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "galaga_playershot"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/enemyBlue1.png",
"frame_width": 93,
"frame_height": 84
},
"name": "galaga_bee",
"width": 93,
"height": 84,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/laserBlue08.png",
"frame_width": 48,
"frame_height": 46
},
"name": "galaga_boom",
"width": 48,
"height": 46,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/enemyGreen3.png",
"frame_width": 103,
"frame_height": 84
},
"name": "galaga_boss",
"width": 103,
"height": 84,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/enemyBlack3.png",
"frame_width": 103,
"frame_height": 84
},
"name": "galaga_boss_hurt",
"width": 103,
"height": 84,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/enemyRed2.png",
"frame_width": 104,
"frame_height": 84
},
"name": "galaga_butterfly",
"width": 104,
"height": 84,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/laserRed01.png",
"frame_width": 9,
"frame_height": 54
},
"name": "galaga_enemyshot",
"width": 9,
"height": 54,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/playerShip1_blue.png",
"frame_width": 99,
"frame_height": 75
},
"name": "galaga_player",
"width": 99,
"height": 75,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "art/laserBlue01.png",
"frame_width": 9,
"frame_height": 54
},
"name": "galaga_playershot",
"width": 9,
"height": 54,
"speed": 200,
"loop": false,
"loopReverse": false,
"frames": [
0
]
}

314
examples/galaga/enemies.c Normal file
View File

@@ -0,0 +1,314 @@
/**
* @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;
}

119
examples/galaga/galaga.bas Normal file
View File

@@ -0,0 +1,119 @@
REM GALAGA enemy behavior. The C engine loads this file, runs it once so the
REM definitions exist, and then calls one UPDATE function per enemy per frame.
REM There is no top-level code: definitions, then END.
REM
REM Names the engine binds before every call:
REM SELF@ - this enemy's record (ENEMY): the state machine's memory
REM ACTOR@ - the engine's live actor (ACTOR): position is the real thing
REM GAME@ - shared frame state (GAME): player position, wave, randomness
REM
REM SELF@.STATE# bits: 1 = entering 2 = in formation 4 = diving
REM
REM Two rules of this dialect that bite here, both from docs/03:
REM - the LEFT operand decides integer or float arithmetic, so a float
REM always goes first: SELF@.T% * 150 + 260, never 260 + 150 * SELF@.T%
REM - RETURN at the start of a line ends the DEF body, so every early
REM return rides an IF ... THEN, and only the last RETURN starts a line
REM Ease toward the formation slot, with a little entry swirl.
REM Answers 1 once the slot is reached, else 0.
DEF GLIDEHOME(DT%)
DX% = SELF@.HOMEX% - ACTOR@.X%
DY% = SELF@.HOMEY% - ACTOR@.Y%
K% = DT% * 4.5
IF K% > 1 THEN K% = 1
ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT%
ACTOR@.Y% = ACTOR@.Y% + DY% * K%
IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1
RETURN 0
REM One frame of a dive: accelerate downward, weave, lean toward the
REM player's column, and glide back in from the top after falling out.
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
SPD% = SELF@.T% * 150 + 260
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT%
DX% = GAME@.PLAYERX% - ACTOR@.X%
IF DX% > 220 THEN DX% = 220
IF DX% < -220 THEN DX% = -220
ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT%
IF ACTOR@.Y% > 1040 THEN BEGIN
ACTOR@.Y% = 0.0 - 90
SELF@.STATE# = 1
SELF@.T% = 0
BEND
RETURN 0
REM Raise the fire flag when diving roughly above the player. The engine
REM consumes FIRE# and does the spawning; the script only wishes.
DEF DECIDEFIRE(DT%)
DX% = GAME@.PLAYERX% - ACTOR@.X%
IF ABS(DX%) > 140 THEN RETURN 0
IF ACTOR@.Y% > GAME@.PLAYERY% THEN RETURN 0
IF SELF@.RND% < DT% * 1.5 THEN SELF@.FIRE# = 1
RETURN 0
REM Bee: enter, breathe in formation, occasionally dive nearly straight.
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
S# = SELF@.STATE#
IF (S# AND 1) > 0 THEN BEGIN
R# = GLIDEHOME(DT%)
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
BEND
IF (S# AND 2) > 0 THEN BEGIN
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.7) * 16
ACTOR@.Y% = SELF@.HOMEY%
IF SELF@.RND% < DT% * 0.04 THEN SELF@.STATE# = 4 : SELF@.T% = 0
BEND
IF (S# AND 4) > 0 THEN BEGIN
R# = DIVESTEP(DT%, 130, 0.2)
R# = DECIDEFIRE(DT%)
BEND
RETURN 0
REM Butterfly: the same machine with a wide lateral weave on the dive.
DEF UPDATEBFLY(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
S# = SELF@.STATE#
IF (S# AND 1) > 0 THEN BEGIN
R# = GLIDEHOME(DT%)
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
BEND
IF (S# AND 2) > 0 THEN BEGIN
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 2.1) * 24
ACTOR@.Y% = SELF@.HOMEY%
IF SELF@.RND% < DT% * 0.05 THEN SELF@.STATE# = 4 : SELF@.T% = 0
BEND
IF (S# AND 4) > 0 THEN BEGIN
R# = DIVESTEP(DT%, 260, 0.1)
R# = DECIDEFIRE(DT%)
BEND
RETURN 0
REM Boss: two hit points, a slow sway, and a dive that leads the player.
REM At one hit point it raises actor state bit 13 (8192), and the engine's
REM character mapping swaps the sprite - the boundary crossed the other way.
DEF UPDATEBOSS(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
IF SELF@.HP# = 1 THEN ACTOR@.STATE# = ACTOR@.STATE# OR 8192
S# = SELF@.STATE#
IF (S# AND 1) > 0 THEN BEGIN
R# = GLIDEHOME(DT%)
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
BEND
IF (S# AND 2) > 0 THEN BEGIN
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.1) * 10
ACTOR@.Y% = SELF@.HOMEY%
IF SELF@.RND% < DT% * 0.03 THEN SELF@.STATE# = 4 : SELF@.T% = 0
BEND
IF (S# AND 4) > 0 THEN BEGIN
R# = DIVESTEP(DT%, 60, 0.9)
R# = DECIDEFIRE(DT%)
BEND
RETURN 0
END

155
examples/galaga/galaga.h Normal file
View File

@@ -0,0 +1,155 @@
/**
* @file galaga.h
* @brief Shared declarations for the GALAGA embedding example.
*
* The engine is C on libakgl; the enemies think in BASIC. Everything the two
* sides share crosses in exactly one place: the three structures below, which
* script.c registers as host types so a script reads and writes them directly.
* docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md build this
* program from an empty file; the split between files follows the split
* between chapters.
*/
#ifndef _GALAGA_H_
#define _GALAGA_H_
#include <stdbool.h>
#include <stdint.h>
#include <akerror.h>
#include <akgl/actor.h>
/* ------------------------------------------------------------- geometry --- */
/*
* The view is sized to the artwork rather than the other way round: the Kenney
* sprites are ~100 pixels wide, libakgl has no way to draw a sprite smaller
* than it is (akgl_Actor.scale is overwritten every frame -- libakgl
* docs/12-actors.md), and a ten-column formation of them needs 1120 pixels.
*/
#define GALAGA_VIEW_WIDTH 1280
#define GALAGA_VIEW_HEIGHT 960
#define GALAGA_FORM_COLUMNS 10 /* formation width, in slots */
#define GALAGA_FORM_LEFT 136 /* x of column 0, map pixels */
#define GALAGA_FORM_TOP 120 /* y of row 0, map pixels */
#define GALAGA_COL_PITCH 112
#define GALAGA_ROW_PITCH 100
#define GALAGA_PLAYER_Y 860.0f
#define GALAGA_PLAYER_SPEED 420.0f /* map pixels per second */
#define GALAGA_PLAYER_MARGIN 60.0f /* how close to the edge it may go */
/* ------------------------------------------------------------- entities --- */
#define GALAGA_ENEMY_BEE 0
#define GALAGA_ENEMY_BUTTERFLY 1
#define GALAGA_ENEMY_BOSS 2
#define GALAGA_ENEMY_KINDS 3
#define GALAGA_MAX_ENEMIES 40
#define GALAGA_MAX_PLAYER_SHOTS 2 /* the classic two-on-screen rule */
#define GALAGA_MAX_ENEMY_SHOTS 8
/*
* galaga_Enemy.state bits. The script owns these transitions; the engine only
* writes the word at spawn and when a script error forces an enemy dumb.
* galaga.bas spells the same three values as literals, with a REM naming them.
*
* 8 0
* 0 0 0 0 0 1 1 1
* | | `-- ENTERING: flying its entry path toward the formation slot
* | `---- FORMATION: holding (and breathing around) homex/homey
* `------ DIVING: attacking, off the grid until it leaves the screen
*/
#define GALAGA_ES_ENTERING (1 << 0)
#define GALAGA_ES_FORMATION (1 << 1)
#define GALAGA_ES_DIVING (1 << 2)
/** @brief One enemy, as both sides see it. Hangs off akgl_Actor.actorData. */
typedef struct galaga_Enemy
{
int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */
int32_t state; /* GALAGA_ES_* bit flags */
float homex; /* formation slot, in map pixels */
float homey;
float t; /* parametric clock for the current maneuver */
int32_t hp;
int32_t fire; /* outbox: script sets 1, engine consumes */
float rnd; /* inbox: engine writes fresh 0..1 each call */
} galaga_Enemy;
/** @brief Frame state every enemy may read. Bound once as GAME@. */
typedef struct galaga_Shared
{
float playerx; /* the player actor's position, this frame */
float playery;
int32_t wave;
float rnd; /* fresh 0..1 each frame; the issue #16 route */
} galaga_Shared;
/* --------------------------------------------------------------- screens --- */
typedef enum
{
GALAGA_SCREEN_TITLE = 0,
GALAGA_SCREEN_PLAY,
GALAGA_SCREEN_GAMEOVER,
GALAGA_SCREEN_VICTORY
} galaga_Screen;
/* ------------------------------------------------------------ game state --- */
typedef struct galaga_Game
{
galaga_Screen screen;
int frame;
float dt; /* seconds, clamped; see main.c */
bool autoplay;
int score;
int lives;
int kills[GALAGA_ENEMY_KINDS];
int shots[GALAGA_ENEMY_KINDS]; /* shots each kind fired */
int script_errors;
akgl_Actor *player;
float fire_cooldown;
float respawn_timer; /* > 0 while the player is invulnerable */
bool firing;
bool moveleft;
bool moveright;
int player_shots_live;
int enemy_shots_live;
} galaga_Game;
extern galaga_Game galaga_game;
extern galaga_Shared galaga_shared;
extern galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES];
extern akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES];
/* ---------------------------------------------------------------- script --- */
akerr_ErrorContext AKERR_NOIGNORE *galaga_script_boot(char *path);
akerr_ErrorContext AKERR_NOIGNORE *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt);
/* --------------------------------------------------------------- enemies --- */
akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_spawn(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_release(void);
int galaga_enemies_alive(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_boom_spawn(float x, float y);
/* ---------------------------------------------------------------- player --- */
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_spawn(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_controls(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_autoplay(int frame);
/** @brief A 0..1 random draw from the engine's own PRNG (see enemies.c). */
float galaga_random(void);
#endif // _GALAGA_H_

View File

@@ -0,0 +1,132 @@
/**
* @file interop_test.c
* @brief Round-trip test for the galaga boundary, hoststruct.c-style.
*
* Links the real script.c and the real galaga.bas -- not copies -- so this
* fails the moment the boundary and the script disagree. The four claims it
* pins:
*
* 1. The script writes the engine's actor memory: a formation enemy's sway
* lands in akgl_Actor.x with no marshalling step.
* 2. The outbox works: a diving enemy above the player raises FIRE# and the
* C side reads it.
* 3. The boss flips actor state bit 13 at one hit point -- the boundary
* crossed engine-ward.
* 4. Sustained calling holds: 24000 calls through the per-call
* akbasic_environment_zero() regime, the load a 40-enemy wave puts on
* the runtime in ten seconds.
*
* Exit status equals the number of failed claims.
*/
#include <math.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/actor.h>
#include "galaga.h"
#ifndef GALAGA_SCRIPT_PATH
#define GALAGA_SCRIPT_PATH "galaga.bas"
#endif
/* script.c reads these; main.c usually defines them. This test is the host. */
galaga_Game galaga_game;
galaga_Shared galaga_shared;
static int FAILURES = 0;
#define CLAIM(__cond, __text) \
if ( !(__cond) ) { \
fprintf(stderr, "FAILED: %s\n", __text); \
FAILURES += 1; \
} else { \
printf("ok: %s\n", __text); \
}
static akerr_ErrorContext *run_claims(void)
{
galaga_Enemy enemy;
akgl_Actor actor;
int i = 0;
PREPARE_ERROR(errctx);
PASS(errctx, galaga_script_boot((char *)GALAGA_SCRIPT_PATH));
/* --- 1: formation sway lands in the actor ---------------------------- */
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BEE;
enemy.state = GALAGA_ES_FORMATION;
enemy.homex = 400.0f;
enemy.homey = 300.0f;
enemy.hp = 1;
actor.x = 0.0f;
actor.y = 0.0f;
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
CLAIM((fabsf(actor.x - enemy.homex) <= 16.5f) && (actor.y == enemy.homey),
"a formation bee's sway is written into akgl_Actor.x/y by the script");
/* --- 2: the fire outbox ---------------------------------------------- */
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BEE;
enemy.state = GALAGA_ES_DIVING;
enemy.rnd = 0.0f; /* 0.0 < DT% * 1.5: always willing */
actor.x = 600.0f;
actor.y = 200.0f;
galaga_shared.playerx = 610.0f; /* just off the shot's column */
galaga_shared.playery = 860.0f; /* well below */
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
CLAIM(enemy.fire == 1,
"a diving bee above the player raises FIRE# for the engine to consume");
/* --- 3: the boss's hurt bit ------------------------------------------ */
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BOSS;
enemy.state = GALAGA_ES_FORMATION;
enemy.homex = 500.0f;
enemy.homey = 120.0f;
enemy.hp = 1;
actor.state = AKGL_ACTOR_STATE_ALIVE;
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
CLAIM((actor.state & AKGL_ACTOR_STATE_UNDEFINED_13) != 0,
"a boss at one hit point raises actor state bit 13 from BASIC");
/* --- 4: sustained calling --------------------------------------------- */
memset(&enemy, 0, sizeof(enemy));
memset(&actor, 0, sizeof(actor));
enemy.kind = GALAGA_ENEMY_BEE;
enemy.state = GALAGA_ES_FORMATION;
enemy.homex = 400.0f;
enemy.homey = 300.0f;
for ( i = 0; i < 24000; i++ ) {
enemy.rnd = 0.9f; /* never dive: keep the state put */
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
}
CLAIM(galaga_game.script_errors == 0,
"24000 calls survive the per-call akbasic_environment_zero() regime");
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, run_claims());
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the interop test could not run");
FAILURES += 1;
} FINISH_NORETURN(errctx);
return FAILURES;
}

698
examples/galaga/main.c Normal file
View File

@@ -0,0 +1,698 @@
/**
* @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;
}

407
examples/galaga/player.c Normal file
View File

@@ -0,0 +1,407 @@
/**
* @file player.c
* @brief The player's ship, its shots, and every collision in the game.
*
* Bullets and collision are C forever -- they are engine, not behavior. The
* per-frame budget for the script is spent on the forty things that think;
* nothing here thinks, it just moves and intersects.
*/
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/util.h>
#include "galaga.h"
/* Points per kill, indexed by enemy kind. */
static const int KILL_SCORE[GALAGA_ENEMY_KINDS] = {
/* bee butterfly boss */
50, 80, 150
};
static uint32_t PSHOT_SERIAL = 0;
/* -------------------------------------------------------------- hitboxes --- */
/*
* Actor x/y is a sprite's top-left corner. Every box is inset from the
* artwork's rectangle, because the PNGs carry transparent margin and wing
* tips that should not kill anybody.
*/
static void player_box(akgl_Actor *actor, SDL_FRect *dest)
{
dest->x = actor->x + 12.0f;
dest->y = actor->y + 8.0f;
dest->w = 75.0f;
dest->h = 60.0f;
}
static void enemy_box(akgl_Actor *actor, SDL_FRect *dest)
{
dest->x = actor->x + 8.0f;
dest->y = actor->y + 8.0f;
dest->w = 78.0f;
dest->h = 68.0f;
}
static void shot_box(akgl_Actor *actor, SDL_FRect *dest)
{
dest->x = actor->x;
dest->y = actor->y;
dest->w = 9.0f;
dest->h = 54.0f;
}
/* ---------------------------------------------------------- player shots --- */
/**
* @brief Kill one enemy: score it, blow it up, free its slot.
*/
static akerr_ErrorContext *kill_enemy(int index)
{
akgl_Actor *actor = NULL;
galaga_Enemy *enemy = NULL;
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, (index < 0 || index >= GALAGA_MAX_ENEMIES),
AKERR_OUTOFBOUNDS, "enemy index %d", index);
actor = galaga_enemy_actors[index];
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "enemy %d is already gone", index);
enemy = &galaga_enemies[index];
galaga_game.score += KILL_SCORE[enemy->kind];
galaga_game.kills[enemy->kind] += 1;
PASS(errctx, galaga_boom_spawn(actor->x + 20.0f, actor->y + 20.0f));
PASS(errctx, akgl_heap_release_actor(actor));
galaga_enemy_actors[index] = NULL;
SUCCEED_RETURN(errctx);
}
/**
* @brief Move a player shot and test it against every live enemy.
*
* The classic O(shots x enemies) sweep: at most 2 x 40 rectangle tests a
* frame, which is noise. A hit costs the enemy a point of hp; the boss's
* second point is the script's business to survive, not this file's.
*/
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
{
SDL_FRect mine;
SDL_FRect theirs;
bool hit = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
obj->y -= 900.0f * galaga_game.dt;
if ( obj->y < -60.0f ) {
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
shot_box(obj, &mine);
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] == NULL ) {
continue;
}
enemy_box(galaga_enemy_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( !hit ) {
continue;
}
galaga_enemies[i].hp -= 1;
if ( galaga_enemies[i].hp <= 0 ) {
PASS(errctx, kill_enemy(i));
}
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *player_fire(akgl_Actor *player)
{
akgl_Actor *shot = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
PSHOT_SERIAL += 1;
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "pshot%u", PSHOT_SERIAL));
PASS(errctx, akgl_heap_next_actor(&shot));
PASS(errctx, akgl_actor_initialize(shot, name));
PASS(errctx, akgl_actor_set_character(shot, "galaga_playershot"));
/* AFTER initialize: it resets all seven hooks. */
shot->updatefunc = &player_shot_update;
shot->movement_controls_face = false;
shot->state = AKGL_ACTOR_STATE_ALIVE;
shot->visible = true;
shot->x = player->x + 45.0f;
shot->y = player->y - 44.0f;
galaga_game.player_shots_live += 1;
galaga_game.fire_cooldown = 0.22f;
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------- player hit --- */
static akerr_ErrorContext *player_hit(akgl_Actor *player)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
galaga_game.lives -= 1;
galaga_game.respawn_timer = 2.0f;
PASS(errctx, galaga_boom_spawn(player->x + 25.0f, player->y + 10.0f));
player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
SUCCEED_RETURN(errctx);
}
/**
* @brief The player's own update hook: motion, fire, and what can kill it.
*/
static akerr_ErrorContext *player_update(akgl_Actor *obj)
{
SDL_FRect mine;
SDL_FRect theirs;
bool hit = false;
float dx = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
dx = 0.0f;
if ( galaga_game.moveleft ) {
dx -= GALAGA_PLAYER_SPEED;
}
if ( galaga_game.moveright ) {
dx += GALAGA_PLAYER_SPEED;
}
obj->x += dx * galaga_game.dt;
if ( obj->x < GALAGA_PLAYER_MARGIN ) {
obj->x = GALAGA_PLAYER_MARGIN;
}
if ( obj->x > (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f ) {
obj->x = (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f;
}
galaga_game.fire_cooldown -= galaga_game.dt;
if ( galaga_game.firing
&& galaga_game.fire_cooldown <= 0.0f
&& galaga_game.player_shots_live < GALAGA_MAX_PLAYER_SHOTS
&& galaga_game.screen == GALAGA_SCREEN_PLAY ) {
PASS(errctx, player_fire(obj));
}
/*
* Respawn grace: two seconds of blinking invulnerability. The blink is
* the `visible` flag, which is deliberate hiding -- the actor still
* updates, it just is not drawn on the off frames.
*/
if ( galaga_game.respawn_timer > 0.0f ) {
galaga_game.respawn_timer -= galaga_game.dt;
obj->visible = ((galaga_game.frame / 6) % 2) == 0;
SUCCEED_RETURN(errctx);
}
obj->visible = true;
player_box(obj, &mine);
/* Enemy shots. */
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount == 0 ) {
continue;
}
if ( strncmp(akgl_heap_actors[i].name, "eshot", 5) != 0 ) {
continue;
}
shot_box(&akgl_heap_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( hit ) {
galaga_game.enemy_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(&akgl_heap_actors[i]));
PASS(errctx, player_hit(obj));
SUCCEED_RETURN(errctx);
}
}
/* Diving enemies. The formation never reaches this low, so testing all
* forty is the same answer as testing the divers, without a state read. */
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] == NULL ) {
continue;
}
enemy_box(galaga_enemy_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( hit ) {
PASS(errctx, kill_enemy(i));
PASS(errctx, player_hit(obj));
SUCCEED_RETURN(errctx);
}
}
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- controls --- */
static akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.moveleft = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.moveleft = false;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *right_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.moveright = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *right_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.moveright = false;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *fire_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.firing = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *fire_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
galaga_game.firing = false;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_player_controls(void)
{
akgl_Control control;
PREPARE_ERROR(errctx);
memset(&control, 0, sizeof(control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &left_on;
control.handler_off = &left_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_RIGHT;
control.handler_on = &right_on;
control.handler_off = &right_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_SPACE;
control.handler_on = &fire_on;
control.handler_off = &fire_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
akgl_controlmaps[0].target = galaga_game.player;
SUCCEED_RETURN(errctx);
}
/* ----------------------------------------------------------------- spawn --- */
akerr_ErrorContext *galaga_player_spawn(void)
{
akgl_Actor *player = NULL;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_actor(&player));
PASS(errctx, akgl_actor_initialize(player, "player"));
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
/* AFTER initialize: it resets all seven hooks. */
player->updatefunc = &player_update;
player->movement_controls_face = false;
player->state = AKGL_ACTOR_STATE_ALIVE;
player->visible = true;
player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
player->y = GALAGA_PLAYER_Y;
galaga_game.player = player;
SUCCEED_RETURN(errctx);
}
/* -------------------------------------------------------------- autoplay --- */
/**
* @brief Send one synthetic key event through the controller.
*
* Through akgl_controller_handle_event(), never the handlers directly: the
* point of autoplay is to exercise the same path a keyboard does.
*/
static akerr_ErrorContext *synth_key(SDL_Keycode key, bool down)
{
SDL_Event event;
PREPARE_ERROR(errctx);
memset(&event, 0, sizeof(event));
event.type = (down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP);
event.key.key = key;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
SUCCEED_RETURN(errctx);
}
/**
* @brief The scripted pilot for headless runs: hold fire, sweep the floor.
*/
akerr_ErrorContext *galaga_player_autoplay(int frame)
{
int phase = 0;
PREPARE_ERROR(errctx);
/* Hold fire until the wave has mostly assembled: shooting the entry
* stream point-blank empties the formation before it exists, which makes
* both the game and its figure worse. */
if ( frame == 300 ) {
PASS(errctx, synth_key(SDLK_SPACE, true));
}
phase = frame % 240;
if ( phase == 30 ) {
PASS(errctx, synth_key(SDLK_LEFT, true));
} else if ( phase == 90 ) {
PASS(errctx, synth_key(SDLK_LEFT, false));
PASS(errctx, synth_key(SDLK_RIGHT, true));
} else if ( phase == 210 ) {
PASS(errctx, synth_key(SDLK_RIGHT, false));
}
SUCCEED_RETURN(errctx);
}

281
examples/galaga/script.c Normal file
View File

@@ -0,0 +1,281 @@
/**
* @file script.c
* @brief The boundary: everything that touches the interpreter lives here.
*
* One runtime, one script, three host types, three bindings. The engine calls
* exactly one thing per enemy per frame -- galaga_script_update_enemy() -- and
* that function is the whole protocol: rebind, call, recover, reset.
*
* The structure types are declared once, in C, right below. The script never
* declares a TYPE of its own; akbasic_host_register_type() makes these structs
* *be* the BASIC types, offsets taken from offsetof() so the two sides cannot
* drift (include/akbasic/host.h).
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include "galaga.h"
/* The interpreter. Static because an akbasic_Runtime is far too big for a
* stack frame -- 2.40 MiB on this branch. */
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
/** @brief Longest galaga.bas this loader will accept. */
#define GALAGA_MAX_SCRIPT_BYTES 16384
static char SOURCE[GALAGA_MAX_SCRIPT_BYTES];
/*
* Enemy kind -> BASIC function name. Dispatch is a table, not a conditional:
* adding a kind is one row here and one DEF in galaga.bas.
*/
static const char *UPDATE_FUNCTION[GALAGA_ENEMY_KINDS] = {
"UPDATEBEE", /* GALAGA_ENEMY_BEE */
"UPDATEBFLY", /* GALAGA_ENEMY_BUTTERFLY */
"UPDATEBOSS" /* GALAGA_ENEMY_BOSS */
};
/* ---------------------------------------------------------- host types --- */
static const akbasic_HostField ENEMY_FIELDS[] = {
/* struct member BASIC name C representation */
AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
};
static const akbasic_HostType ENEMY_TYPE = {
"ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8
};
/*
* The live actor itself. This is the demonstrative point of the whole
* example: the script writes the engine's *real* actor memory -- the same x
* the renderer reads -- with no copy in either direction.
*/
static const akbasic_HostField ACTOR_FIELDS[] = {
AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL )
};
static const akbasic_HostType ACTOR_TYPE = {
"ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4
};
static const akbasic_HostField GAME_FIELDS[] = {
AKBASIC_HOST_FIELD( galaga_Shared, playerx, "PLAYERX%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Shared, playery, "PLAYERY%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Shared, wave, "WAVE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Shared, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
};
static const akbasic_HostType GAME_TYPE = {
"GAME", sizeof(galaga_Shared), GAME_FIELDS, 4
};
/* Placeholders the boot bindings point at until the first real rebind. A
* binding is borrowed, never copied, so these must be static storage. */
static galaga_Enemy SCRATCH_ENEMY;
static akgl_Actor SCRATCH_ACTOR;
/* ---------------------------------------------------------------- boot --- */
static akerr_ErrorContext *read_script(char *path)
{
FILE *fp = NULL;
size_t got = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
fp = fopen(path, "rb");
FAIL_ZERO_RETURN(errctx, fp, AKERR_IO, "Cannot open the enemy script %s", path);
ATTEMPT {
got = fread(SOURCE, 1, sizeof(SOURCE) - 1, fp);
SOURCE[got] = '\0';
FAIL_NONZERO_BREAK(errctx, (got >= sizeof(SOURCE) - 1), AKERR_OUTOFBOUNDS,
"%s does not fit in the %d byte script buffer",
path, GALAGA_MAX_SCRIPT_BYTES);
} CLEANUP {
fclose(fp);
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief One dry call of every dispatch-table function, at boot.
*
* A missing or misspelled DEF fails here, at startup, with the function's name
* in the message -- not on frame one of the first wave. The scratch enemy's
* state word is zero, so no maneuver block runs and nothing moves.
*/
static akerr_ErrorContext *dry_run(void)
{
akbasic_Value dt;
akbasic_Value *argp[1];
akbasic_Value *result = NULL;
int i = 0;
PREPARE_ERROR(errctx);
memset(&SCRATCH_ENEMY, 0, sizeof(SCRATCH_ENEMY));
memset(&dt, 0, sizeof(dt));
dt.valuetype = AKBASIC_TYPE_FLOAT;
dt.floatval = 0.0;
argp[0] = &dt;
for ( i = 0; i < GALAGA_ENEMY_KINDS; i++ ) {
PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", &SCRATCH_ENEMY));
PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", &SCRATCH_ACTOR));
PASS(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[i],
argp, 1, &result));
/*
* A body that died reports through the sink and answers zero; the
* dropped mode is the only signal C gets. At boot that must be fatal
* and must say which function -- not frame one of the first wave.
*/
FAIL_NONZERO_RETURN(errctx, (SCRIPT.mode != AKBASIC_MODE_RUN), AKERR_VALUE,
"%s died during the boot dry run; the interpreter's report"
" is above", UPDATE_FUNCTION[i]);
PASS(errctx, akbasic_environment_zero(SCRIPT.environment));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_script_boot(char *path)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
PASS(errctx, akbasic_error_register());
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
PASS(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
PASS(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE));
PASS(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE));
PASS(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE));
PASS(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY));
PASS(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR));
PASS(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared));
PASS(errctx, read_script(path));
PASS(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
/*
* A "no top level code" script still has to run once: executing the DEF
* statements is what files the functions. The run is bounded because a
* script that is all definitions has no business taking more than a step
* per line, and an accidental loop at boot should be a diagnosis, not a
* hang.
*/
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
/*
* The program has now ended and the runtime sits in QUIT mode, where a
* multi-line DEF called from the host returns a silent zero. Forcing the
* mode back makes the bodies run, and it stays put because nothing here
* ever steps the runtime again. Issue #8 tracks making this unnecessary.
*/
PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
PASS(errctx, dry_run());
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------ per frame --- */
akerr_ErrorContext *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt)
{
akbasic_Value dtval;
akbasic_Value *argp[1];
akbasic_Value *result = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy");
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
FAIL_NONZERO_RETURN(errctx, (enemy->kind < 0 || enemy->kind >= GALAGA_ENEMY_KINDS),
AKERR_VALUE, "Enemy kind %d has no update function", enemy->kind);
PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
memset(&dtval, 0, sizeof(dtval));
dtval.valuetype = AKBASIC_TYPE_FLOAT;
dtval.floatval = (double)dt;
argp[0] = &dtval;
/*
* An error in an enemy's function is that script's problem, not the
* engine's: the enemy goes dumb -- cleared to a formation hold it will
* never leave -- and the frame lives. HANDLE_DEFAULT absorbs whatever the
* interpreter raised; the first failure is logged with the function's
* name, the rest are counted, because sixty a second of the same message
* is how a log stops being read.
*/
ATTEMPT {
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[enemy->kind],
argp, 1, &result));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
if ( galaga_game.script_errors == 0 ) {
LOG_ERROR_WITH_MESSAGE(errctx, "first script error; this enemy is now dumb");
}
galaga_game.script_errors += 1;
enemy->state = GALAGA_ES_FORMATION;
enemy->fire = 0;
} FINISH(errctx, true);
/*
* A BASIC-level error in the body is quieter than an interpreter error:
* it reports through the sink, the call answers a stale value, and the
* runtime falls out of RUN mode -- after which every later call is a
* silent no-op. The mode is the tell. Revival is two calls:
* clear_error(), because a run's first error latches and every line is
* skipped while it stands, and the same set_mode(RUN) the boot needed
* (issue #8's mechanics). The enemy is treated exactly like the
* interpreter-error case above.
*/
if ( SCRIPT.mode != AKBASIC_MODE_RUN ) {
if ( galaga_game.script_errors == 0 ) {
SDL_Log("first script error (reported by the interpreter above);"
" enemy %s is now dumb", UPDATE_FUNCTION[enemy->kind]);
}
galaga_game.script_errors += 1;
enemy->state = GALAGA_ES_FORMATION;
enemy->fire = 0;
PASS(errctx, akbasic_runtime_clear_error(&SCRIPT));
PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
}
/*
* Load-bearing: akbasic_runtime_call_function() parks every result in the
* caller environment's per-line value scratch, and a host calling in a
* loop never crosses the line boundary that would reset it. Without this
* the pool drains in under two frames of a 40-enemy wave.
*/
PASS(errctx, akbasic_environment_zero(SCRIPT.environment));
SUCCEED_RETURN(errctx);
}