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
This commit is contained in:
@@ -100,6 +100,50 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
|
||||
dies when the body pops — silently, with the script reading it correctly right up until
|
||||
it stops.
|
||||
|
||||
## Calling a function every frame
|
||||
|
||||
`akbasic_runtime_call_function()` calls a `DEF` by name with values you already
|
||||
hold — the entry point a game loop wants. A host that calls it repeatedly signs
|
||||
up for three rules the one-shot examples never meet:
|
||||
|
||||
```c wrap=hostcalls
|
||||
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "THINK", argp, 1, &result));
|
||||
/* ...consume the result... */
|
||||
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
|
||||
```
|
||||
|
||||
1. **Reset the value scratch after every call, once the result is consumed.**
|
||||
Each call parks its result in the caller environment's per-line scratch
|
||||
(`AKBASIC_MAX_VALUES` slots), and a host calling in a loop never crosses the
|
||||
line boundary that would reset it. Skip the `akbasic_environment_zero()` and
|
||||
the pool drains — measured at under two frames of forty calls — after which
|
||||
every call fails with `Maximum values per line reached`. The reset also
|
||||
invalidates `result`, which is why it comes after the consumption.
|
||||
2. **Force RUN mode once after the boot run.** A multi-line `DEF` body only
|
||||
runs while the runtime is in RUN mode, and by the time a host can call, the
|
||||
program that filed the definitions has ended. One
|
||||
`akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)` after
|
||||
`akbasic_runtime_run()` makes the bodies run, and the mode stays put because
|
||||
nothing steps the runtime between calls. Issue #8 tracks making this
|
||||
unnecessary.
|
||||
3. **Revive after a script error, deliberately.** A BASIC-level error inside a
|
||||
called body reports through the sink, answers a stale value, and latches:
|
||||
the runtime leaves RUN mode and every later call does nothing. When your
|
||||
policy is to absorb the error and keep calling — a game marking one actor
|
||||
dumb rather than killing the frame — the revival is two calls:
|
||||
`akbasic_runtime_clear_error()`, then `akbasic_runtime_set_mode(RUN)` again.
|
||||
The latch is deliberate for *programs* — the first error ends a run, once,
|
||||
with one line — so nothing clears it for you.
|
||||
|
||||
Do not pass structures as per-frame arguments. A structure or pointer parameter
|
||||
spends a value-pool slot on every call and the pool never reclaims, so the
|
||||
interface dies after about a thousand calls — issue #36 has the measurements.
|
||||
Bind the instance once with `akbasic_host_bind()` and point it at each object
|
||||
with `akbasic_host_rebind()` ([Chapter 16](16-structures.md)), which spends
|
||||
nothing per call. The GALAGA tutorial ([Chapters 20](20-tutorial-galaga.md)
|
||||
and [21](21-tutorial-galaga-enemies.md)) is this whole recipe as a working
|
||||
game, forty calls a frame.
|
||||
|
||||
## Where the output goes
|
||||
|
||||
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus
|
||||
|
||||
619
docs/20-tutorial-galaga.md
Normal file
619
docs/20-tutorial-galaga.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# 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 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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. In CMake:
|
||||
|
||||
```cmake
|
||||
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
|
||||
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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.
|
||||
515
docs/21-tutorial-galaga-enemies.md
Normal file
515
docs/21-tutorial-galaga-enemies.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# 21. Tutorial: GALAGA — the structures and the AI
|
||||
|
||||
[Chapter 20](20-tutorial-galaga.md) built a C engine that boots the interpreter
|
||||
and hands one actor to BASIC. This chapter builds everything that crosses the
|
||||
boundary — the three shared structures — and then the script that thinks
|
||||
through them: a full wave that enters, forms up, breathes, dives, fires and
|
||||
dies, without another line of engine code.
|
||||
|
||||

|
||||
|
||||
The finished script is
|
||||
[`examples/galaga/galaga.bas`](../examples/galaga/galaga.bas) — six `DEF`
|
||||
functions and an `END`, nothing else. Editing it and re-running the game is the
|
||||
whole development loop; the engine never rebuilds.
|
||||
|
||||
## What you will do
|
||||
|
||||
- **[Step 1](#step-1-declare-the-enemy-once-in-c)** — declare the enemy record
|
||||
once, in C, and register it as a BASIC type
|
||||
- **[Step 2](#step-2-bind-the-engines-own-actor)** — bind the engine's own
|
||||
actor as the second type, which is the point of the whole exercise
|
||||
- **[Step 3](#step-3-share-the-frame-and-the-dice)** — share the frame state,
|
||||
and give the script randomness it cannot make itself
|
||||
- **[Step 4](#step-4-why-bindings-and-not-arguments)** — see why the structures
|
||||
are bindings rather than function arguments
|
||||
- **[Step 5](#step-5-the-shape-of-the-script)** — learn the three language
|
||||
rules that shape every enemy function
|
||||
- **[Step 6](#step-6-the-shared-maneuvers)** — write the shared maneuvers:
|
||||
glide home, dive, decide to fire
|
||||
- **[Step 7](#step-7-the-three-kinds)** — write the bee, the butterfly and the
|
||||
boss
|
||||
- **[Step 8](#step-8-the-formation-c-or-basic)** — decide who owns the
|
||||
formation, and lay it out
|
||||
- **[Step 9](#step-9-when-a-script-dies)** — decide what a script error does to
|
||||
the game, and make it do that
|
||||
- **[Step 10](#step-10-prove-it)** — prove the boundary with a test that links
|
||||
the real files
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Declare the enemy once, in C
|
||||
|
||||
**Goal: one struct that both languages read and write, with one source of truth.**
|
||||
|
||||
An enemy is what the state machine needs to remember between frames, plus one
|
||||
inbox and one outbox:
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
#define GALAGA_ENEMY_BEE 0
|
||||
#define GALAGA_ENEMY_BUTTERFLY 1
|
||||
#define GALAGA_ENEMY_BOSS 2
|
||||
|
||||
/*
|
||||
* galaga_Enemy.state bits. The script owns these transitions; the engine only
|
||||
* writes the word at spawn.
|
||||
*
|
||||
* 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)
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
The C struct *is* the BASIC type. `akbasic_host_register_type()` takes a table
|
||||
of field descriptors — the BASIC name with its suffix, the C representation,
|
||||
and where the member sits — and after that the language's own machinery works
|
||||
across the boundary with no second set of rules
|
||||
([Chapter 16](16-structures.md)):
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
typedef struct galaga_Enemy
|
||||
{
|
||||
int32_t kind;
|
||||
int32_t state;
|
||||
float homex;
|
||||
float homey;
|
||||
float t;
|
||||
int32_t hp;
|
||||
int32_t fire;
|
||||
float rnd;
|
||||
} galaga_Enemy;
|
||||
|
||||
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
|
||||
};
|
||||
```
|
||||
|
||||
Three decisions are load-bearing here:
|
||||
|
||||
- **`AKBASIC_HOST_FIELD` takes the offset and the width from the member
|
||||
itself**, via `offsetof` — so the two sides cannot drift. Writing them out by
|
||||
hand is two chances to name the wrong member and no way to notice.
|
||||
- **The script never declares a `TYPE`.** A host type and a script `TYPE` share
|
||||
one namespace, and a script that tries to redeclare `ENEMY` is refused. The
|
||||
"structure definitions" half of the boundary lives here, once.
|
||||
- **The suffixes are the dialect's**: `#` is integer, `%` is float
|
||||
([Chapter 3](03-the-language.md)). `HOMEX%` because a formation slot is a
|
||||
pixel coordinate the glide arithmetic must not truncate.
|
||||
|
||||
The limits that shape the struct: a type may carry 16 fields and the runtime 16
|
||||
types ([Chapter 16](16-structures.md)). `ENEMY` spends 8 fields; the game
|
||||
spends 3 types.
|
||||
|
||||
## Step 2: Bind the engine's own actor
|
||||
|
||||
**Goal: the script writes the same bytes the renderer reads.**
|
||||
|
||||
The enemy record is the game's own invention. The second type is not — it is
|
||||
libakgl's `akgl_Actor`, registered field-for-field over the engine's real
|
||||
struct:
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
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
|
||||
};
|
||||
```
|
||||
|
||||
This is the demonstrative point of the whole exercise. When the script writes
|
||||
`ACTOR@.X%`, it writes `akgl_Actor.x` — the same memory the renderer reads on
|
||||
the same frame. There is no copy going in, no copy coming out, and no code
|
||||
between the script's decision and the engine's pixel. Null physics
|
||||
(Chapter 20, Step 1) is what makes that safe: nothing else is trying to move
|
||||
the actor.
|
||||
|
||||
The per-frame call binds both names to *this* enemy before dispatching — one
|
||||
binding per name, pointed at forty enemies in turn, which is what
|
||||
`akbasic_host_rebind()` is for:
|
||||
|
||||
```c wrap=galagacalls requires=akgl
|
||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
|
||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
|
||||
```
|
||||
|
||||
## Step 3: Share the frame, and the dice
|
||||
|
||||
**Goal: everything a diving enemy needs to know about the world, in one record.**
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
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;
|
||||
```
|
||||
|
||||
`GAME@` is bound once at boot to this one global instance and never rebound;
|
||||
the engine refreshes it at the top of every frame. The boss reads
|
||||
`GAME@.PLAYERX%` to lead its dive; the fire decision reads it to know whether
|
||||
anything is worth shooting at.
|
||||
|
||||
The `rnd` fields — one here per frame, one on each enemy per call — exist
|
||||
because the engine's PRNG is the script's **only** source of randomness: write
|
||||
`SELF@.RND% < DT% * 1.5` and an enemy's trigger finger is a dice roll. There
|
||||
is no `RND` verb in this dialect; issue #16 tracks adding one, and Chapter
|
||||
17's breakout hand-rolls a linear congruential generator in BASIC as the other
|
||||
route. Here the engine fills the field, which also keeps a headless run the
|
||||
same game on every machine — the PRNG is the example's own, not libc's.
|
||||
|
||||
## Step 4: Why bindings, and not arguments
|
||||
|
||||
**Goal: know why `SELF@` is a bound global rather than a parameter.**
|
||||
|
||||
The language can pass structures to functions — by value with `E@ AS ENEMY`,
|
||||
by reference with `E@ AS PTR TO ENEMY` ([Chapter 16](16-structures.md)) — and
|
||||
a host can construct those argument values, so the obvious alternative
|
||||
interface is honest functions:
|
||||
|
||||
```basic norun
|
||||
DEF UPDATEBEE(E@ AS PTR TO ENEMY, A@ AS PTR TO ACTOR, G@ AS PTR TO GAME, DT%)
|
||||
```
|
||||
|
||||
It was measured before this chapter chose. Pointer arguments work — writes
|
||||
through `E@->X%` land in the host struct, the type check refuses a wrong type,
|
||||
by-value copies exactly as documented. What rules them out is the pool math:
|
||||
|
||||
| | bound globals | pointer arguments |
|
||||
|---|---|---|
|
||||
| value-pool slots per call | 0 | 1 per structure parameter, never returned |
|
||||
| calls before exhaustion | unbounded | 1,015 measured (2,048-slot pool, 2 pointer args) |
|
||||
| at 40 enemies per frame | unbounded | 25 frames |
|
||||
| per-call cost | 148 us | 251 us |
|
||||
|
||||
A `@`-suffixed name always takes value-pool storage, and that pool never
|
||||
reclaims — a documented property of structures, because a pointer may outlive
|
||||
the scope that `DIM`med it. A *parameter* is a local that dies with the call,
|
||||
but it pays the storage price of a `DIM` that must survive one; the pool
|
||||
drains, and the wave stops thinking mid-flight. Issue #36 tracks it, with the
|
||||
reduction for whoever fixes it. Until then: **bind and rebind for per-frame
|
||||
host calls; pass structures only to functions called a bounded number of
|
||||
times.**
|
||||
|
||||
## Step 5: The shape of the script
|
||||
|
||||
**Goal: the three rules every enemy function is written under.**
|
||||
|
||||
`galaga.bas` is definitions and an `END` — no top-level code, no line numbers,
|
||||
no `LABEL`s. Three rules of the dialect shape every body in it.
|
||||
|
||||
**Rule 1: the left operand decides integer or float arithmetic**
|
||||
([Chapter 3](03-the-language.md)). This will bite every enemy script exactly
|
||||
once, so meet it now. The natural spelling of "move by speed times dt" moves
|
||||
nothing:
|
||||
|
||||
```basic norun
|
||||
ACTOR@.Y% = ACTOR@.Y% + 260 * DT%
|
||||
```
|
||||
|
||||
`260` is an integer, it is on the left of `*`, so `DT%` — a float around
|
||||
0.016 — is converted to integer **zero** before the multiply. Nothing fails;
|
||||
the enemy simply does not move. The working spelling puts the float first:
|
||||
|
||||
```basic norun
|
||||
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
|
||||
SPD% = SELF@.T% * 150 + 260
|
||||
```
|
||||
|
||||
Every expression in the finished script is written float-first. When an enemy
|
||||
of yours will not move, this is the first thing to check.
|
||||
|
||||
**Rule 2: only the last `RETURN` may start a line.** A multi-line `DEF` body
|
||||
runs until `RETURN` — and the *definition* is scanned the same way, ending at
|
||||
the first line that begins with one. An early return therefore always rides an
|
||||
`IF ... THEN RETURN 0` on one line, and exactly one line-leading `RETURN` ends
|
||||
each function. The stagger guard at the top of every update function is the
|
||||
idiom:
|
||||
|
||||
```basic norun
|
||||
DEF UPDATEBEE(DT%)
|
||||
SELF@.T% = SELF@.T% + DT%
|
||||
IF SELF@.T% < 0 THEN RETURN 0
|
||||
```
|
||||
|
||||
**Rule 3: the budgets are small and named.** Eight function slots exist
|
||||
(`AKBASIC_MAX_FUNCTIONS`), each a measured 36 KiB of the runtime's 2.40 MiB.
|
||||
This game defines six: three update functions, two shared maneuvers, one fire
|
||||
decision. Nesting draws from the twelve-slot environment pool exactly as
|
||||
`GOSUB` does; the deepest chain here is three (update → maneuver → nothing).
|
||||
If a design needs a ninth function, raising the limit is one `#define` and
|
||||
+36 KiB per slot — weighed, not assumed.
|
||||
|
||||
## Step 6: The shared maneuvers
|
||||
|
||||
**Goal: three helpers that make the three kinds one page each.**
|
||||
|
||||
Ease toward the formation slot, with a little entry swirl. Answers 1 once the
|
||||
slot is reached — the caller flips the state on that answer:
|
||||
|
||||
```basic
|
||||
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
|
||||
END
|
||||
```
|
||||
|
||||
One frame of a dive: accelerate downward, weave, lean toward the player's
|
||||
column, and glide back in from the top after falling out the bottom. The
|
||||
weave and the lean are parameters, which is what makes three kinds out of one
|
||||
maneuver:
|
||||
|
||||
```basic
|
||||
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
|
||||
END
|
||||
```
|
||||
|
||||
Note the off-screen exit: state back to `1` (ENTERING), clock to zero, and the
|
||||
glide brings it home — a dive that misses rejoins the formation, which is the
|
||||
classic loop. `0.0 - 90` rather than `0 - 90` is Rule 1 again: the float goes
|
||||
first even to make a negative.
|
||||
|
||||
The fire decision raises the flag when diving roughly above the player. The
|
||||
engine consumes `FIRE#` and does the spawning — the script only wishes,
|
||||
because spawning takes an actor from a bounded pool and pool exhaustion must
|
||||
be a C-side refusal with the house error context, not a script mystery:
|
||||
|
||||
```basic
|
||||
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
|
||||
END
|
||||
```
|
||||
|
||||
## Step 7: The three kinds
|
||||
|
||||
**Goal: bee, butterfly, boss — one state machine, three characters.**
|
||||
|
||||
Every kind is the same three-state machine, dispatched by the bits of
|
||||
`SELF@.STATE#`. The bee is the reference implementation:
|
||||
|
||||
```basic
|
||||
DEF GLIDEHOME(DT%)
|
||||
ACTOR@.X% = SELF@.HOMEX%
|
||||
ACTOR@.Y% = SELF@.HOMEY%
|
||||
RETURN 1
|
||||
|
||||
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
|
||||
RETURN 0
|
||||
|
||||
DEF DECIDEFIRE(DT%)
|
||||
RETURN 0
|
||||
|
||||
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
|
||||
END
|
||||
```
|
||||
|
||||
(The three helpers above are stubs so this listing runs alone; the real ones
|
||||
are Step 6's. The listing in `galaga.bas` is this function verbatim.)
|
||||
|
||||
The shape to notice: `S#` is read **once**, so a state flipped this frame does
|
||||
not also run its new state's block this frame — transitions are frame-atomic.
|
||||
Each block is one `IF ... BEGIN`/`BEND`, never nested. The formation block
|
||||
computes position *relative to home* every frame — `HOMEX% + SIN(...)` — so
|
||||
the grid's idle breathing belongs to the script even though C placed the grid.
|
||||
|
||||
The butterfly is the bee with a wide lateral weave — `DIVESTEP(DT%, 260, 0.1)`
|
||||
— and a slightly itchier trigger. The boss differs three ways: two hit points
|
||||
(C fills `HP#` at spawn), a dive that leads the player —
|
||||
`DIVESTEP(DT%, 60, 0.9)` — and one line that crosses the boundary in the other
|
||||
direction:
|
||||
|
||||
```basic norun
|
||||
IF SELF@.HP# = 1 THEN ACTOR@.STATE# = ACTOR@.STATE# OR 8192
|
||||
```
|
||||
|
||||
8192 is `AKGL_ACTOR_STATE_UNDEFINED_13`, one of the actor state bits libakgl
|
||||
reserves for the game. The boss's character file maps the state word
|
||||
`ALIVE` to the green sprite and `ALIVE`+bit-13 to the drained one — so when
|
||||
the script raises the bit, the engine's own character machinery swaps the
|
||||
sprite. BASIC decides *that* the boss looks hurt; C never hears about it.
|
||||
|
||||
## Step 8: The formation: C or BASIC?
|
||||
|
||||
**Goal: decide who owns the grid, from the trade-offs rather than taste.**
|
||||
|
||||
Both can lay out the formation. The choice is argued, not asserted:
|
||||
|
||||
| | C lays out the grid | BASIC lays out the grid |
|
||||
|---|---|---|
|
||||
| actor pool safety | refusal at spawn, house error path | script can ask for more than 64 exist |
|
||||
| tuning without rebuild | no | yes |
|
||||
| call budget | zero calls | one call per spawn |
|
||||
| who knows the screen size | the engine owns it anyway | needs it exported through `GAME@` |
|
||||
|
||||
**Decision: C owns the grid, the wave table and the spawn timing; BASIC owns
|
||||
everything an enemy does after it exists.** The slot arrives in
|
||||
`SELF@.HOMEX%`/`HOMEY%`, so the breathing stays the script's (Step 7), and the
|
||||
pool stays behind a C-side refusal. The wave is the aligned table house style
|
||||
already prescribes for tabular data — one row per formation row:
|
||||
|
||||
```c wrap=galagagame requires=akgl
|
||||
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 }
|
||||
};
|
||||
```
|
||||
|
||||
Forty enemies: 4 bosses, 16 butterflies, 20 bees. The actor heap holds 64:
|
||||
|
||||
```text
|
||||
player 1
|
||||
player shots 2 /* the classic two-on-screen rule */
|
||||
enemies 40 /* 20 bees, 16 butterflies, 4 bosses */
|
||||
enemy shots 8
|
||||
explosions 8 /* short-lived actors, released on a timer */
|
||||
---
|
||||
59 of 64
|
||||
```
|
||||
|
||||
The spawn walks the table, fills each `galaga_Enemy`, and staggers the entry
|
||||
clocks — `t = -0.08 * index`, so each enemy holds still until its own clock
|
||||
crosses zero and the wave pours in as a stream rather than a wall. The full
|
||||
loop is `examples/galaga/enemies.c`.
|
||||
|
||||
## Step 9: When a script dies
|
||||
|
||||
**Goal: a script error costs one enemy's wits, never the frame.**
|
||||
|
||||
A BASIC-level error in an enemy's function — a misspelled field, arithmetic on
|
||||
the wrong type — reports through the sink and stops the script. The engine's
|
||||
policy, implemented around the call in `script.c`:
|
||||
|
||||
- **The enemy goes dumb**: state cleared to a formation hold it will never
|
||||
leave, outbox cleared. The other thirty-nine keep thinking.
|
||||
- **The runtime is revived**: a run's first error latches, and while it stands
|
||||
every later call answers a stale value after doing nothing. Revival is two
|
||||
calls — `akbasic_runtime_clear_error()`, then the same
|
||||
`akbasic_runtime_set_mode(RUN)` the boot needed (issue #8's mechanics).
|
||||
- **The first failure is logged, the rest are counted.** Sixty a second of the
|
||||
same message is how a log stops being read; the count lands in the closing
|
||||
readout as `script errors N`, where a headless run cannot miss it.
|
||||
|
||||
The same detection runs at boot: every function in the dispatch table is
|
||||
called once against a zeroed scratch enemy, so a script that cannot run fails
|
||||
at startup with the function's name in the message — not on frame one of the
|
||||
first wave.
|
||||
|
||||
## Step 10: Prove it
|
||||
|
||||
**Goal: a test that fails the moment the two sides disagree.**
|
||||
|
||||
`examples/galaga/interop_test.c` links the real `script.c` and loads the real
|
||||
`galaga.bas` — not copies — and pins the four claims this chapter made:
|
||||
|
||||
```text
|
||||
ok: a formation bee's sway is written into akgl_Actor.x/y by the script
|
||||
ok: a diving bee above the player raises FIRE# for the engine to consume
|
||||
ok: a boss at one hit point raises actor state bit 13 from BASIC
|
||||
ok: 24000 calls survive the per-call akbasic_environment_zero() regime
|
||||
```
|
||||
|
||||
That last claim is the per-frame contract from Chapter 20 Step 6 under a full
|
||||
game's load — forty enemies at sixty frames a second for ten seconds. CTest
|
||||
runs it as `example_galaga_interop` beside the headless game itself.
|
||||
|
||||
And because the script is data, the proof extends to scripts nobody planned:
|
||||
run the game with `--script` pointing at a variant — enemies that never dive,
|
||||
enemies that always dive — and the engine neither knows nor cares. That
|
||||
swap-a-brain-without-rebuilding property is what the two chapters were about;
|
||||
the readout tells you how each brain did:
|
||||
|
||||
```text
|
||||
galaga: 3000 frames, screen 2, score 2350, alive 0, kills bee 20 bfly 15 boss 1, shots bee 1 bfly 1 boss 1, script errors 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Where to go from here: more waves are rows in the table; a new enemy kind is
|
||||
one table row, one character file and one `DEF`; a smarter boss is edits to a
|
||||
text file while the game is closed — or a different file handed to
|
||||
`--script`. The engine is done. That is the point.
|
||||
@@ -16,7 +16,10 @@ embedding it, debugging it or changing it.
|
||||
**[Chapters 17](17-tutorial-breakout.md)** and **[18](18-tutorial-breakout-artwork.md)**
|
||||
are tutorials rather than reference: they build one complete game twice, two different
|
||||
ways, in numbered steps you can type in one at a time. Start with 17 — it needs nothing
|
||||
but the earlier chapters, and 18 assumes it.
|
||||
but the earlier chapters, and 18 assumes it. **[Chapters 20](20-tutorial-galaga.md)** and
|
||||
**[21](21-tutorial-galaga-enemies.md)** are the third tutorial, from the other side of
|
||||
the boundary: a C game on libakgl that embeds the interpreter as its enemy-behavior
|
||||
engine, for anyone whose question is "how do I put this in *my* game".
|
||||
|
||||
## Chapters
|
||||
|
||||
@@ -41,6 +44,8 @@ but the earlier chapters, and 18 assumes it.
|
||||
| **[17. Tutorial: Breakout](17-tutorial-breakout.md)** | Build a whole game out of the text grid and two `DATA` sprites, in sixteen steps |
|
||||
| **[18. Tutorial: Breakout with artwork](18-tutorial-breakout-artwork.md)** | Build it again out of loaded artwork, powerups and a drawn colour HUD, in thirteen |
|
||||
| **[19. Menus and dialogs](19-user-interface.md)** | `MENU`, `DIALOG`, `HUD` and `UISTYLE` — the widgets, and who owns the keyboard |
|
||||
| **[20. Tutorial: GALAGA](20-tutorial-galaga.md)** | Build a C engine on libakgl that embeds the interpreter, boots a script and hands it an actor |
|
||||
| **[21. Tutorial: GALAGA enemies](21-tutorial-galaga-enemies.md)** | Share three C structs with the script, then write the wave's whole brain in BASIC |
|
||||
|
||||
## The shortest possible start
|
||||
|
||||
|
||||
Reference in New Issue
Block a user