The interop test now ends with a measured comparison: 24,000 formation updates through the script boundary against a line-for-line C translation of the same state machine. 881 us against 0.01 us per call on this machine, quoted verbatim in the new chapter 21 Step 11 with the architectural decisions it prices. A Haiku-class cold read of the chapters produced a build whose failures were all mechanical -- invented include paths, never-shown sink statics, guessed status codes and character names. The chapters now carry the include lists, the script.c statics, the status-code roster, the sprite/character table, the full CMake recipe and the explosion spawn's HANDLE example, so none of those have to be guessed again. Co-authored-by: andrew <andrew@aklabs.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
752 lines
27 KiB
Markdown
752 lines
27 KiB
Markdown
# 20. Tutorial: GALAGA — a C engine with a BASIC brain
|
|
|
|
This chapter and [Chapter 21](21-tutorial-galaga-enemies.md) build a GALAGA-style
|
|
fixed shooter from an empty file. The engine — window, starfield, bullets,
|
|
collision, score, screens — is C on libakgl. The enemies think in BASIC: one
|
|
script of `DEF` functions is called once per enemy per frame, and it reads and
|
|
writes the engine's own structures with no marshalling in either direction.
|
|
This chapter builds the engine and proves the boundary works; the next one
|
|
fills in the data structures and the AI.
|
|
|
|
The split is the point. Everything mechanical stays compiled, and everything an
|
|
enemy *decides* is a text file you can edit and re-run without rebuilding. It is
|
|
an academic exercise in *how* such an embed is done, not a claim that it is the
|
|
best way to write a GALAGA.
|
|
|
|
This is what the two chapters build:
|
|
|
|

|
|
|
|
The finished program is [`examples/galaga/`](../examples/galaga/): four C files,
|
|
one `galaga.bas`, and the assets. You do not need it to follow along, but it is
|
|
the same program assembled.
|
|
|
|
```sh norun
|
|
$ 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 at a time, the classic rule |
|
|
| return | choose a menu entry |
|
|
|
|
## What you will do
|
|
|
|
- **[Step 1](#step-1-open-a-window)** — open a window, in the one startup order
|
|
that works
|
|
- **[Step 2](#step-2-scatter-a-starfield)** — scatter a starfield and scroll it,
|
|
with no parallax machinery at all
|
|
- **[Step 3](#step-3-put-a-ship-on-screen)** — put a ship on screen from a
|
|
sprite and a character file, and drive it from the keyboard
|
|
- **[Step 4](#step-4-shots-and-collision)** — spawn shots from the actor heap
|
|
and collide them by hand
|
|
- **[Step 5](#step-5-boot-the-interpreter)** — link the interpreter in, load a
|
|
script of definitions, and call one from C
|
|
- **[Step 6](#step-6-the-update-hook)** — replace an actor's update hook so its
|
|
every frame is a BASIC call
|
|
- **[Step 7](#step-7-first-light)** — watch one enemy move under BASIC control,
|
|
and read the same numbers from both sides
|
|
- **[Step 8](#step-8-screens)** — add the title, game over and victory screens
|
|
- **[Step 9](#step-9-run-it-headless)** — run the whole game headless, so CI can
|
|
play it every night
|
|
|
|
Each step compiles and runs. The C fragments quote the finished example; the
|
|
file layout there — `main.c` for the harness, `script.c` for the boundary,
|
|
`enemies.c` and `player.c` for the actors — is a good one to copy.
|
|
|
|
---
|
|
|
|
## Step 1: Open a window
|
|
|
|
**Goal: a black window with a title, from the canonical startup order.**
|
|
|
|
libakgl has one startup sequence that works, documented at the top of its
|
|
`include/akgl/game.h` and walked through in its own tutorial (libakgl
|
|
docs/20-tutorial-sidescroller.md). The order matters twice: the screen
|
|
properties are read by the renderer, so they must be set before it exists, and
|
|
`akgl_game_init()` does **not** install a physics backend, so the application
|
|
must.
|
|
|
|
```c wrap=galagatypes requires=akgl
|
|
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());
|
|
|
|
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,
|
|
1280,
|
|
960,
|
|
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
|
AKGL_ERR_SDL,
|
|
"%s",
|
|
SDL_GetError()
|
|
);
|
|
akgl_camera->x = 0.0f;
|
|
akgl_camera->y = 0.0f;
|
|
akgl_camera->w = 1280.0f;
|
|
akgl_camera->h = 960.0f;
|
|
|
|
PASS(errctx, akgl_physics_init_null(akgl_physics));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
Three of those lines deserve their reasons.
|
|
|
|
**The view is 1280x960 because the artwork is ~100 pixels wide.** libakgl draws
|
|
a sprite at the sprite's own size — `akgl_Actor.scale` is overwritten every
|
|
frame, so there is no way to draw one smaller (libakgl docs/12-actors.md) — and
|
|
a ten-column formation of 100-pixel ships needs 1120 pixels plus margins. The
|
|
view is sized to the art rather than the art resized to a view.
|
|
|
|
**`akgl_physics_init_null()` is not optional.** Skip it and the first
|
|
`akgl_game_update()` calls through a NULL `simulate` pointer. Null physics
|
|
accepts every call and moves nothing, which is exactly right here: whatever
|
|
writes `x` and `y` directly is the mover, and in this game that will be BASIC.
|
|
|
|
**Error handling is the house protocol.** Every function returns
|
|
`akerr_ErrorContext *`, `PASS` propagates, `ATTEMPT`/`CATCH`/`CLEANUP` brackets
|
|
anything that must unwind. libakgl's docs/04-errors.md teaches it; this chapter
|
|
just uses it. The status codes this game raises are `AKERR_NULLPOINTER`,
|
|
`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL`
|
|
and `AKGL_ERR_HEAP` — there is no code this tutorial invents.
|
|
|
|
The includes the engine files draw on, so nothing later has to be guessed —
|
|
the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not
|
|
`SDL3/SDL_ttf.h`):
|
|
|
|
```c wrap=galagatypes requires=akgl
|
|
#include <stdbool.h>
|
|
#include <stdint.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/error.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 <akgl/util.h>
|
|
```
|
|
|
|
The frame loop is the standard bracket, with one addition you will meet in
|
|
Step 6 — for now, events in, world drawn, frame out:
|
|
|
|
```c wrap=galagahost requires=akgl
|
|
while ( SDL_PollEvent(&event) == true ) {
|
|
CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
|
}
|
|
CATCH(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
CATCH(errctx, akgl_game_update(NULL));
|
|
CATCH(errctx, akgl_renderer->frame_end(akgl_renderer));
|
|
```
|
|
|
|
`akgl_game_update(NULL)` is update-every-actor, step-the-physics,
|
|
draw-the-world. It neither clears nor presents; the `frame_start` and
|
|
`frame_end` calls own that.
|
|
|
|
## Step 2: Scatter a starfield
|
|
|
|
**Goal: a scrolling two-depth starfield, from an array and one draw call.**
|
|
|
|
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()`, is the whole feature. Two speed bands
|
|
give the depth for free — the slow band reads as far away:
|
|
|
|
```c wrap=galagatypes requires=akgl
|
|
#define GALAGA_STARS 96
|
|
|
|
static struct
|
|
{
|
|
float x;
|
|
float y;
|
|
float speed;
|
|
Uint8 bright;
|
|
} STARS[GALAGA_STARS];
|
|
|
|
static akerr_ErrorContext *starfield_draw(float dt)
|
|
{
|
|
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 * dt;
|
|
if ( STARS[i].y > 960.0f ) {
|
|
STARS[i].y -= 960.0f;
|
|
}
|
|
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);
|
|
}
|
|
```
|
|
|
|
Seed the array once at startup — even indexes slow and dim (speed 40, bright
|
|
110), odd indexes fast and bright (speed 110, bright 220) — and the effect is
|
|
done. A point is exactly one pixel (libakgl docs/09-drawing.md).
|
|
|
|
## Step 3: Put a ship on screen
|
|
|
|
**Goal: a player actor, drawn from a character file, moving on key input.**
|
|
|
|
The art is Kenney's Space Shooter pack, CC0, used byte for byte — see
|
|
[`examples/galaga/assets/art/PROVENANCE.md`](../examples/galaga/assets/art/PROVENANCE.md)
|
|
for what each file is. An actor gets its looks from a **character**, which maps
|
|
actor state words to **sprites** (libakgl docs/10 and 12). Both are JSON; load
|
|
sprites first, because a character names its sprites and a character loaded
|
|
first fails on the first name it cannot find.
|
|
|
|
These are the names, so the loading lists and every
|
|
`akgl_actor_set_character()` call in both chapters agree — each `sprite_*.json`
|
|
and `character_*.json` lives in `assets/`:
|
|
|
|
| Character | Sprite(s) it maps | Worn by |
|
|
|---|---|---|
|
|
| `galaga_player` | `galaga_player` | the ship |
|
|
| `galaga_bee` | `galaga_bee` | bees |
|
|
| `galaga_butterfly` | `galaga_butterfly` | butterflies |
|
|
| `galaga_boss` | `galaga_boss`, and `galaga_boss_hurt` on state bit 13 | bosses |
|
|
| `galaga_playershot` | `galaga_playershot` | the ship's shots |
|
|
| `galaga_enemyshot` | `galaga_enemyshot` | enemy shots |
|
|
| `galaga_boom` | `galaga_boom` | explosions |
|
|
|
|
The spawn is four decisions after the two boilerplate calls:
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
static 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 = 590.0f;
|
|
player->y = 860.0f;
|
|
|
|
galaga_game.player = player;
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
Each of the four lines under the comment closes a trap:
|
|
|
|
- **`updatefunc` after `akgl_actor_initialize()`**, never before — initialize
|
|
installs all seven default hooks, and a hook set first is a hook reset.
|
|
- **`movement_controls_face = false`.** The default facing logic edits the
|
|
state word, a character mapping matches the **whole** word, and an actor
|
|
whose state matches no mapping is *silently not drawn*. Nothing here moves by
|
|
state bits, so facing stays out of the word entirely.
|
|
- **`state = AKGL_ACTOR_STATE_ALIVE`** — the word the character mapping names.
|
|
- **`visible = true`.** `akgl_actor_initialize()` does not raise it. In a
|
|
tilemap game the map loader copies visibility from map data; there is no map
|
|
here, so an actor that skips this line exists, moves, fires and collides —
|
|
invisibly. This one line cost this example its first screenshot.
|
|
|
|
Input goes through a control map: push a control per key with handlers that set
|
|
flags, and let the actor's update hook read the flags. The full recipe is in
|
|
`examples/galaga/player.c` and libakgl docs/16-input.md; the shape is:
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
static 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);
|
|
}
|
|
```
|
|
|
|
Hand **every** polled event to `akgl_controller_handle_event()` — one that no
|
|
control binds is not an error, it is a call that did nothing.
|
|
|
|
## Step 4: Shots and collision
|
|
|
|
**Goal: bullets that fly, hit, and give their actor slot back.**
|
|
|
|
Bullets and collision are C forever — they are engine, not behavior. A shot is
|
|
an actor from the same 64-slot heap pool, with its own tiny update hook: move,
|
|
test, release.
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
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);
|
|
}
|
|
```
|
|
|
|
Four conventions worth keeping:
|
|
|
|
- **`akgl_collide_rectangles()` is the whole collision system.** At most 2
|
|
shots x 40 enemies of axis-aligned tests per frame is noise; the full
|
|
`akgl_CollisionWorld` machinery earns its keep on tilemaps, not here. The
|
|
`shot_box`/`enemy_box` helpers inset each box from the artwork's rectangle,
|
|
because the PNGs carry transparent margin that should not kill anybody.
|
|
- **Releasing is despawning.** `akgl_heap_release_actor()` unregisters the
|
|
actor and stops it drawing; releasing mid-sweep is safe because
|
|
`akgl_game_update()` re-reads each slot's refcount as it goes.
|
|
- **Names carry a serial** — `pshot17`, not `pshot1` reused — because the actor
|
|
registry is keyed by name, and two live actors with one name is a fight.
|
|
- **Spawn caps are C-side refusals.** Two player shots, eight enemy shots; the
|
|
spawn functions simply decline past the cap.
|
|
|
|
Give the enemy shots the same shape falling downward, and the ship a sweep over
|
|
both — `examples/galaga/player.c` has all three loops.
|
|
|
|
Explosions are the fourth actor kind, and they carry the one place this game
|
|
*absorbs* an error instead of propagating it. `HANDLE` names the status it
|
|
forgives; everything else still travels:
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
|
|
static uint32_t BOOM_SERIAL = 0;
|
|
|
|
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, akgl_heap_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. */
|
|
} FINISH(errctx, true);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
## Step 5: Boot the interpreter
|
|
|
|
**Goal: the engine calls a BASIC function and prints its answer.**
|
|
|
|
Everything so far was libakgl. Now link the interpreter into the same
|
|
executable. The whole CMake recipe, inside an akbasic checkout with
|
|
`AKBASIC_WITH_AKGL=ON`:
|
|
|
|
```cmake
|
|
add_executable(mygalaga
|
|
main.c
|
|
script.c
|
|
enemies.c
|
|
player.c)
|
|
target_compile_options(mygalaga PRIVATE -Wall -Wextra)
|
|
target_compile_definitions(mygalaga PRIVATE
|
|
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
|
|
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas"
|
|
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
|
|
target_link_libraries(mygalaga PRIVATE akbasic akgl
|
|
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
|
```
|
|
|
|
The three baked-in paths are what let the program launch from any working
|
|
directory; `--assets` and `--script` flags can override them at runtime.
|
|
|
|
Link `akbasic` — the interpreter only. Not `akbasic_akgl` (the device backends
|
|
that let a script draw), and not `akbasic_frontend` (the standalone program's
|
|
host). This game lends the script **no devices at all**: the scripts compute,
|
|
the engine draws, and a script that tries `SPRITE` is refused by name. That
|
|
refusal is enforced by the interpreter, not by convention —
|
|
[Chapter 10](10-embedding.md) explains the device-lending model this game
|
|
declines to use.
|
|
|
|
The boot is the embedding host from Chapter 10, adapted to a script that only
|
|
defines. Keep every line that touches the interpreter in one file — the
|
|
example's `script.c` — so the boundary stays a place rather than a habit. That
|
|
file's interpreter-facing includes and statics, exactly:
|
|
|
|
```c wrap=galagatypes requires=akgl
|
|
#include <akbasic/environment.h>
|
|
#include <akbasic/error.h>
|
|
#include <akbasic/host.h>
|
|
#include <akbasic/runtime.h>
|
|
#include <akbasic/sink.h>
|
|
|
|
/* 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;
|
|
static char SOURCE[16384];
|
|
```
|
|
|
|
The boot itself:
|
|
|
|
```c wrap=galagacalls requires=akgl
|
|
CATCH(errctx, akbasic_error_register());
|
|
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
|
|
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
|
|
|
|
CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
|
|
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
|
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
|
|
CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
|
|
```
|
|
|
|
Two of those lines are the ones a first embedding gets wrong.
|
|
|
|
**A "no top level code" script still has to run once.** The script is nothing
|
|
but `DEF` blocks and a final `END`, and executing the `DEF` statements is what
|
|
files the functions. The run is bounded — a script that is all definitions has
|
|
no business taking more than a few steps per line, and an accidental loop at
|
|
boot should be a diagnosis, not a hang.
|
|
|
|
**The `set_mode` after the run is load-bearing.** 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 to RUN makes the bodies run, and it
|
|
stays put because nothing here ever steps the runtime again. Issue #8 tracks
|
|
making this workaround unnecessary.
|
|
|
|
`PRINT` inside the script goes through the stdio sink and lands on stdout —
|
|
that is the script's debug channel for the rest of both chapters.
|
|
|
|
Prove the wiring with one function. Put this in the script:
|
|
|
|
```basic
|
|
DEF ADDEM(A#, B#) = A# + B#
|
|
END
|
|
```
|
|
|
|
And call it from C, with values you already have:
|
|
|
|
```c wrap=galagacalls requires=akgl
|
|
memset(&args[0], 0, sizeof(args[0]));
|
|
memset(&args[1], 0, sizeof(args[1]));
|
|
args[0].valuetype = AKBASIC_TYPE_INTEGER;
|
|
args[0].intval = 17;
|
|
args[1].valuetype = AKBASIC_TYPE_INTEGER;
|
|
args[1].intval = 25;
|
|
argp[0] = &args[0];
|
|
argp[1] = &args[1];
|
|
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result));
|
|
printf("ADDEM(17, 25) = %lld\n", (long long)result->intval);
|
|
```
|
|
|
|
```text
|
|
ADDEM(17, 25) = 42
|
|
```
|
|
|
|
`akbasic_runtime_call_function()` is the host's entry point: a name and
|
|
already-evaluated values in, the function's result out. The engine refuses to
|
|
start when the script will not boot — a game whose enemies cannot think is not
|
|
a game missing a feature, it is a game that does not run.
|
|
|
|
## Step 6: The update hook
|
|
|
|
**Goal: one actor whose every frame is a BASIC call.**
|
|
|
|
`akgl_game_update()` calls each live actor's `updatefunc` exactly once per
|
|
frame. Replacing that pointer is the whole integration: the actor's frame *is*
|
|
a script call.
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
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);
|
|
}
|
|
```
|
|
|
|
The hook's body is a protocol, and `galaga_script_update_enemy()` is its
|
|
middle: **rebind, call, recover, reset.**
|
|
|
|
```c wrap=galagacalls requires=akgl
|
|
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
|
|
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
|
|
|
|
memset(&dtval, 0, sizeof(dtval));
|
|
dtval.valuetype = AKBASIC_TYPE_FLOAT;
|
|
dtval.floatval = (double)dt;
|
|
argp[0] = &dtval;
|
|
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result));
|
|
|
|
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
|
|
```
|
|
|
|
`SELF@` and `ACTOR@` are **host bindings** — the enemy's record and the
|
|
engine's live actor, shared with the script as structures it can read and
|
|
write directly. [Chapter 21](21-tutorial-galaga-enemies.md) builds them; for
|
|
this chapter, know that `akbasic_host_rebind()` points an existing binding at
|
|
a different instance, which is how forty enemies share one script: one name,
|
|
rebound per enemy, rather than forty names.
|
|
|
|
**The `akbasic_environment_zero()` after every call is load-bearing.** Each
|
|
call parks its 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 line the scratch drains in under two frames of a 40-enemy wave and
|
|
every later call fails with `Maximum values per line reached`. Chapter 10's
|
|
["Calling a function every frame"](10-embedding.md#calling-a-function-every-frame)
|
|
section is the rule's home.
|
|
|
|
## Step 7: First light
|
|
|
|
**Goal: a C actor moving under BASIC control, and proof it is one memory.**
|
|
|
|
Before any real AI, the smallest demonstration. One enemy, one function, a sine
|
|
drift written entirely in BASIC through the actor binding:
|
|
|
|
```basic
|
|
DEF UPDATEBEE(DT%)
|
|
SELF@.T% = SELF@.T% + DT%
|
|
ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200
|
|
ACTOR@.Y% = 300.0
|
|
PRINT "BASIC SEES X = " + ACTOR@.X%
|
|
RETURN 0
|
|
END
|
|
```
|
|
|
|
Spawn one enemy with the hook from Step 6, and have the engine print the same
|
|
actor's position each frame from C:
|
|
|
|
```c wrap=galagahost requires=akgl
|
|
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
|
|
```
|
|
|
|
```text
|
|
BASIC SEES X = 593.191094
|
|
INFO: C SEES X = 593.191094
|
|
BASIC SEES X = 596.378593
|
|
INFO: C SEES X = 596.378593
|
|
```
|
|
|
|
Same numbers, one memory. The script wrote `ACTOR@.X%`; the renderer read
|
|
`akgl_Actor.x`; nothing copied anything anywhere. The ship swings in a slow
|
|
arc, and the whole architecture is visible in that one motion: C owns the
|
|
frame, BASIC owns the decision, and the actor is the same bytes to both.
|
|
|
|
## Step 8: Screens
|
|
|
|
**Goal: title, playing, game over, victory — a state machine around the loop.**
|
|
|
|
The screens are libakgl's UI layer, in the three-state pattern of its uidemo
|
|
example (libakgl docs/22-ui.md). A `galaga_Screen` enum, one `declare_*()`
|
|
function per screen, and the UI bracket between `akgl_game_update()` and
|
|
`frame_end` — exactly where the frame contract puts it:
|
|
|
|
```c wrap=galagahost requires=akgl
|
|
CATCH(errctx, akgl_ui_frame_begin());
|
|
switch ( galaga_game.screen ) {
|
|
case GALAGA_SCREEN_TITLE:
|
|
CATCH(errctx, declare_title());
|
|
break;
|
|
case GALAGA_SCREEN_PLAY:
|
|
CATCH(errctx, declare_play());
|
|
break;
|
|
case GALAGA_SCREEN_GAMEOVER:
|
|
case GALAGA_SCREEN_VICTORY:
|
|
CATCH(errctx, declare_end());
|
|
break;
|
|
}
|
|
CATCH(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
```
|
|
|
|
The playing screen is two `akgl_ui_label()` calls — score top-left, lives and
|
|
wave top-right — formatted into `static` buffers, because the UI borrows label
|
|
text until `frame_end` and a local buffer would be dangling by the time it
|
|
draws. The title and end screens are an `akgl_ui_menu()` at the center.
|
|
|
|
The big **GALAGA** headline is direct text rather than a label:
|
|
|
|
```c wrap=galagagame requires=akgl
|
|
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, (1280 - w) / 2, 280));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
```
|
|
|
|
The menu owns `AKGL_UI_ANCHOR_CENTER`, a label anchored there disappears
|
|
behind it, and there is no top-center anchor — so the headline measures itself
|
|
and draws at a coordinate, before the UI bracket so the menu still paints over
|
|
it if the two ever meet.
|
|
|
|

|
|
|
|
Screen transitions are three rules read after the world updates: lives spent is
|
|
GAME OVER, an empty wave is VICTORY, and a menu activation either restarts or
|
|
quits. The menu never clears its own `activated` flag — the state machine that
|
|
acts on it does.
|
|
|
|
## Step 9: Run it headless
|
|
|
|
**Goal: the same game, playable by a script, in CI every night.**
|
|
|
|
The example takes five flags, in the pattern of libakgl's sidescroller:
|
|
|
|
```sh norun
|
|
$ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SDL_RENDER_DRIVER=software \
|
|
./build-akgl/akbasic_example_galaga --frames 600 --autoplay
|
|
```
|
|
|
|
`--frames N` bounds the run; `--autoplay` is a scripted pilot that starts the
|
|
game, sweeps the floor and holds fire until the wave assembles; `--screenshot
|
|
PATH --screenshot-frame N` write a PNG from the render target — the figures in
|
|
this chapter are that flag's output, not pictures somebody took once. Synthetic
|
|
input goes through `akgl_controller_handle_event()` with constructed
|
|
`SDL_Event`s, never by calling the handlers directly — the point of autoplay is
|
|
to exercise the same path a keyboard does.
|
|
|
|
The last line of every run is the evidence:
|
|
|
|
```text
|
|
galaga: 600 frames, screen 1, score 1910, alive 9, kills bee 19 bfly 12 boss 0, shots bee 0 bfly 3 boss 0, script errors 0
|
|
```
|
|
|
|
Exiting 0 is not proof the wave flew. The readout is: kills and shots counted
|
|
per kind say the enemies entered, thought and fired, and **`script errors 0`**
|
|
says every one of the ~24,000 BASIC calls in those ten seconds came back clean.
|
|
A wave of dumb enemies still exits 0, and that count is how you notice. The
|
|
CTest entry `example_galaga` runs exactly this under the dummy SDL drivers,
|
|
which is what keeps both chapters honest.
|
|
|
|
---
|
|
|
|
That is the engine: a window, a starfield, a ship, bullets, screens, and an
|
|
interpreter that answers when called. Everything on screen so far is C. What
|
|
turns it into a GALAGA is [Chapter 21](21-tutorial-galaga-enemies.md) — the
|
|
three shared structures, the script that thinks through them, and a full wave
|
|
entering, breathing, diving and firing without another line of engine code.
|