Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
# 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
|
2026-08-04 09:01:59 -04:00
|
|
|
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>
|
|
|
|
|
```
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-08-04 09:01:59 -04:00
|
|
|
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 |
|
|
|
|
|
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
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.
|
|
|
|
|
|
2026-08-04 09:01:59 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
## 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
|
2026-08-04 09:01:59 -04:00
|
|
|
executable. The whole CMake recipe, inside an akbasic checkout with
|
|
|
|
|
`AKBASIC_WITH_AKGL=ON`:
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
|
|
|
|
|
```cmake
|
2026-08-04 09:01:59 -04:00
|
|
|
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
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
|
|
|
|
```
|
|
|
|
|
|
2026-08-04 09:01:59 -04:00
|
|
|
The three baked-in paths are what let the program launch from any working
|
|
|
|
|
directory; `--assets` and `--script` flags can override them at runtime.
|
|
|
|
|
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
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
|
2026-08-04 09:01:59 -04:00
|
|
|
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:
|
Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.
docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.
Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00
|
|
|
|
|
|
|
|
```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.
|