diff --git a/CLAUDE.md b/CLAUDE.md index 7e6eaa1..167a58d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ scripting engine for game authors. | [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet | | [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on | | [`README.md`](README.md) | What the project is and why, for somebody who has not seen it | -| [`docs/`](docs/README.md) | The language itself: eighteen chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/` | +| [`docs/`](docs/README.md) | The language itself: twenty-one chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/`; [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) build the embedding host in `examples/galaga/` | | `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively | | `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code | | `deps//AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** | diff --git a/README.md b/README.md index 6dce36a..6f6819c 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra | | | |---|---| -| [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice | +| [`docs/`](docs/README.md) | The guide: twenty-one chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice, and [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) building a C game that embeds the interpreter | | [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style | | [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence | | [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it | diff --git a/docs/10-embedding.md b/docs/10-embedding.md index 236e1e4..422eef7 100644 --- a/docs/10-embedding.md +++ b/docs/10-embedding.md @@ -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 diff --git a/docs/20-tutorial-galaga.md b/docs/20-tutorial-galaga.md new file mode 100644 index 0000000..a799c4e --- /dev/null +++ b/docs/20-tutorial-galaga.md @@ -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: + +![A full wave: four green bosses, two rows of butterflies, bees still streaming into the grid, the player firing](images/galaga-wave.png) + +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. + +![The title screen: the banner, the menu, the starfield](images/galaga-title.png) + +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. diff --git a/docs/21-tutorial-galaga-enemies.md b/docs/21-tutorial-galaga-enemies.md new file mode 100644 index 0000000..2ae4d76 --- /dev/null +++ b/docs/21-tutorial-galaga-enemies.md @@ -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 wave assembling: bosses, butterflies and bees under BASIC control](images/galaga-wave.png) + +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. diff --git a/docs/README.md b/docs/README.md index 347f71a..f35bc37 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/tests/docs_preludes/galagacalls.post b/tests/docs_preludes/galagacalls.post new file mode 100644 index 0000000..ce04880 --- /dev/null +++ b/tests/docs_preludes/galagacalls.post @@ -0,0 +1,8 @@ + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + (void)SCRIPT; (void)SINK; (void)SINKSTATE; (void)SOURCE; + (void)args; (void)argp; (void)dtval; (void)result; + (void)enemy; (void)actor; (void)dt; + SUCCEED_RETURN(errctx); +} diff --git a/tests/docs_preludes/galagacalls.pre b/tests/docs_preludes/galagacalls.pre new file mode 100644 index 0000000..4ebab03 --- /dev/null +++ b/tests/docs_preludes/galagacalls.pre @@ -0,0 +1,53 @@ +/* + * Prelude for the interpreter-facing fragments in docs/20: the boot sequence, + * the ADDEM proof and the rebind-call-reset protocol, shown as runs of CATCH + * calls. The statics are the ones examples/galaga/script.c keeps; the locals + * are the superset every fragment draws from, void-cast in the postlude so an + * unused one is not a warning. + */ +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +typedef struct galaga_docs_Enemy +{ + int32_t kind; + int32_t state; + float homex; + float homey; + float t; + int32_t hp; + int32_t fire; + float rnd; +} galaga_docs_Enemy; + +static akbasic_Runtime SCRIPT; +static akbasic_TextSink SINK; +static akbasic_StdioSink SINKSTATE; +static char SOURCE[16384]; + +akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(galaga_docs_Enemy *enemy, akgl_Actor *actor, float dt); +akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(galaga_docs_Enemy *enemy, akgl_Actor *actor, float dt) +{ + PREPARE_ERROR(errctx); + akbasic_Value args[2]; + akbasic_Value *argp[2]; + akbasic_Value dtval; + akbasic_Value *result = NULL; + + ATTEMPT { diff --git a/tests/docs_preludes/galagagame.pre b/tests/docs_preludes/galagagame.pre new file mode 100644 index 0000000..55e116f --- /dev/null +++ b/tests/docs_preludes/galagagame.pre @@ -0,0 +1,121 @@ +/* + * Prelude for file-scope fragments in docs/20 and docs/21 that assume the + * galaga example's own declarations already exist -- the shared structures + * from examples/galaga/galaga.h and the helpers a fragment calls but does not + * define. The types are copied rather than included so a fragment compiles + * against exactly what the chapter has shown so far; the helper declarations + * are invented prototypes, per the prelude policy in MAINTENANCE.md. + */ +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#define GALAGA_ENEMY_BEE 0 +#define GALAGA_ENEMY_BUTTERFLY 1 +#define GALAGA_ENEMY_BOSS 2 +#define GALAGA_ENEMY_KINDS 3 +#define GALAGA_MAX_ENEMIES 40 +#define GALAGA_MAX_PLAYER_SHOTS 2 +#define GALAGA_MAX_ENEMY_SHOTS 8 +#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; + int32_t state; + float homex; + float homey; + float t; + int32_t hp; + int32_t fire; + float rnd; +} galaga_Enemy; + +typedef struct galaga_Shared +{ + float playerx; + float playery; + int32_t wave; + float rnd; +} galaga_Shared; + +typedef enum +{ + GALAGA_SCREEN_TITLE = 0, + GALAGA_SCREEN_PLAY, + GALAGA_SCREEN_GAMEOVER, + GALAGA_SCREEN_VICTORY +} galaga_Screen; + +typedef struct galaga_Game +{ + galaga_Screen screen; + int frame; + float dt; + bool autoplay; + int score; + int lives; + int kills[GALAGA_ENEMY_KINDS]; + int shots[GALAGA_ENEMY_KINDS]; + int script_errors; + akgl_Actor *player; + float fire_cooldown; + float respawn_timer; + bool firing; + bool moveleft; + bool moveright; + int player_shots_live; + int enemy_shots_live; +} galaga_Game; + +extern galaga_Game galaga_game; +extern galaga_Shared galaga_shared; +extern galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES]; +extern akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES]; + +float galaga_random(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt); +akerr_ErrorContext AKERR_NOIGNORE *galaga_boom_spawn(float x, float y); +akerr_ErrorContext AKERR_NOIGNORE *enemy_fire(galaga_Enemy *enemy, akgl_Actor *from); +akerr_ErrorContext AKERR_NOIGNORE *kill_enemy(int index); +akerr_ErrorContext AKERR_NOIGNORE *player_update(akgl_Actor *obj); +akerr_ErrorContext AKERR_NOIGNORE *left_on(akgl_Actor *obj, SDL_Event *event); +akerr_ErrorContext AKERR_NOIGNORE *left_off(akgl_Actor *obj, SDL_Event *event); +akerr_ErrorContext AKERR_NOIGNORE *right_on(akgl_Actor *obj, SDL_Event *event); +akerr_ErrorContext AKERR_NOIGNORE *right_off(akgl_Actor *obj, SDL_Event *event); +akerr_ErrorContext AKERR_NOIGNORE *fire_on(akgl_Actor *obj, SDL_Event *event); +akerr_ErrorContext AKERR_NOIGNORE *fire_off(akgl_Actor *obj, SDL_Event *event); +void shot_box(akgl_Actor *actor, SDL_FRect *dest); +void enemy_box(akgl_Actor *actor, SDL_FRect *dest); +void player_box(akgl_Actor *actor, SDL_FRect *dest); diff --git a/tests/docs_preludes/galagahost.post b/tests/docs_preludes/galagahost.post new file mode 100644 index 0000000..2265d12 --- /dev/null +++ b/tests/docs_preludes/galagahost.post @@ -0,0 +1,6 @@ + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + (void)event; + SUCCEED_RETURN(errctx); +} diff --git a/tests/docs_preludes/galagahost.pre b/tests/docs_preludes/galagahost.pre new file mode 100644 index 0000000..a9a298a --- /dev/null +++ b/tests/docs_preludes/galagahost.pre @@ -0,0 +1,55 @@ +/* + * Prelude for statement-context fragments in docs/20: runs of CATCH calls + * from the galaga frame loop, shown without their scaffolding because the + * ATTEMPT protocol is the scaffolding. Same policy as hostcalls.pre. + */ +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +typedef enum +{ + GALAGA_SCREEN_TITLE = 0, + GALAGA_SCREEN_PLAY, + GALAGA_SCREEN_GAMEOVER, + GALAGA_SCREEN_VICTORY +} galaga_Screen; + +struct galaga_docs_Game +{ + galaga_Screen screen; + float dt; + akgl_Actor *player; +}; +extern struct galaga_docs_Game galaga_game; +extern akgl_Actor *galaga_enemy_actors[40]; + +akerr_ErrorContext AKERR_NOIGNORE *declare_title(void); +akerr_ErrorContext AKERR_NOIGNORE *declare_play(void); +akerr_ErrorContext AKERR_NOIGNORE *declare_end(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(void) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + + ATTEMPT { diff --git a/tests/docs_preludes/galagatypes.pre b/tests/docs_preludes/galagatypes.pre new file mode 100644 index 0000000..bb0506a --- /dev/null +++ b/tests/docs_preludes/galagatypes.pre @@ -0,0 +1,37 @@ +/* + * Prelude for the self-contained file-scope fragments in docs/20 and docs/21: + * blocks that define a struct, a table or a whole function from scratch need + * only the includes. Only compiled in the AKBASIC_WITH_AKGL build. + */ +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include diff --git a/tests/docs_preludes/hostcalls.post b/tests/docs_preludes/hostcalls.post index 14fb300..0ffbc94 100644 --- a/tests/docs_preludes/hostcalls.post +++ b/tests/docs_preludes/hostcalls.post @@ -2,5 +2,7 @@ } PROCESS(errctx) { } FINISH(errctx, true); (void)score; + (void)argp; + (void)result; SUCCEED_RETURN(errctx); } diff --git a/tests/docs_preludes/hostcalls.pre b/tests/docs_preludes/hostcalls.pre index a79548d..bed87f9 100644 --- a/tests/docs_preludes/hostcalls.pre +++ b/tests/docs_preludes/hostcalls.pre @@ -7,6 +7,7 @@ * surrounding prose says exists but does not print. */ #include +#include #include #include #include @@ -23,5 +24,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void) { PREPARE_ERROR(errctx); int64_t score = 0; + akbasic_Value *argp[4]; + akbasic_Value *result = NULL; ATTEMPT {