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/CMakeLists.txt b/CMakeLists.txt index 21229cf..c739f0c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -268,6 +268,69 @@ if(AKBASIC_BUILD_EXAMPLES) endforeach() endif() +# The galaga example: a C game on libakgl with the interpreter embedded as its +# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it +# is compiled and run by every AKGL build rather than rotting in a document. +# The asset, script and font paths are baked in so the smoke test can launch +# from any working directory; --assets and --script override them at runtime. +if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL) + add_executable(akbasic_example_galaga + examples/galaga/main.c + examples/galaga/script.c + examples/galaga/enemies.c + examples/galaga/player.c) + target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra) + target_compile_definitions(akbasic_example_galaga PRIVATE + GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets" + GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas" + GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf") + target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image) + akbasic_instrument(akbasic_example_galaga) + # Ten seconds of scripted play under the headless drivers: the script boots, + # a wave enters and forms, the autoplay pilot shoots at it, and the program + # tears down and exits 0. A tutorial that stops working fails here rather + # than in front of a reader. + _add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay) + _set_tests_properties(example_galaga PROPERTIES TIMEOUT 120 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software") + + # The boundary's round-trip test: links the real script.c and the real + # galaga.bas, and fails the moment the two sides of the interop disagree. + add_executable(akbasic_example_galaga_interop + examples/galaga/interop_test.c + examples/galaga/script.c) + target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra) + target_compile_definitions(akbasic_example_galaga_interop PRIVATE + GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas") + target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl + SDL3::SDL3 m) + akbasic_instrument(akbasic_example_galaga_interop) + _add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop) + _set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120) + + # Regenerating the game figures in docs/ is a deliberate act, never part of + # a build, for the same reason docs_screenshots is: the PNGs are checked in. + # Wall-clock dt makes each regeneration differ by a few pixels of starfield, + # so expect a binary diff every time this runs; commit one only when the + # content changed on purpose. (docs_galaga_figures, not docs_game_figures: + # the libakgl submodule already owns that target name.) + add_custom_target(docs_galaga_figures + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy + SDL_RENDER_DRIVER=software + $ --frames 40 + --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-title.png" + --screenshot-frame 30 + COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy + SDL_RENDER_DRIVER=software + $ --autoplay --frames 370 + --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-wave.png" + --screenshot-frame 360 + DEPENDS akbasic_example_galaga + COMMENT "Regenerating the galaga figures in docs/images" + VERBATIM) +endif() + # --------------------------------------------------------------------------- # Tests. # 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/17-tutorial-breakout.md b/docs/17-tutorial-breakout.md index 547c4de..5ea37e6 100644 --- a/docs/17-tutorial-breakout.md +++ b/docs/17-tutorial-breakout.md @@ -685,9 +685,9 @@ seconds asks for fifty frames a second. ### Why `GOTO` rather than `DO ... LOOP` A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO` -that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32 +that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 12 scopes, so a game that leaves its main loop once per lost life stops on the -thirty-second one: +twelfth one: ```basic N# = 0 @@ -700,7 +700,7 @@ PRINT "SURVIVED " + N# ``` ```output -? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use) +? 3 : PARSE ERROR Environment pool exhausted at line 3 (12 in use) ``` diff --git a/docs/20-tutorial-galaga.md b/docs/20-tutorial-galaga.md new file mode 100644 index 0000000..72f9e68 --- /dev/null +++ b/docs/20-tutorial-galaga.md @@ -0,0 +1,849 @@ +# 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, with two rules that keep the fragments compiling: **`CATCH` is +only legal inside an `ATTEMPT` block, and `PASS` everywhere else** — swap them +and the compiler objects about a stray `break` — and `main()` alone ends its +block with `FINISH_NORETURN(errctx)` instead of `FINISH`, because `FINISH` +expands a `return` of the context that an `int`-returning function cannot +compile: + +```c wrap=galagatypes requires=akgl +static int FAILED = 0; + +int main(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + + (void)argc; (void)argv; + ATTEMPT { + /* CATCH each stage in order: startup, assets, the script boot, + * the spawns, then the frame loop. */ + } CLEANUP { + /* ...teardown, every call wrapped in IGNORE()... */ + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run"); + /* Set a flag rather than returning: leaving a HANDLE block early + * skips FINISH's release and leaks the context's pool slot. */ + FAILED = 1; + } FINISH_NORETURN(errctx); + return FAILED; +} +``` + +The status codes this game raises are `AKERR_NULLPOINTER`, +`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` +and `AKGL_ERR_HEAP` — there is no code this tutorial invents. + +The includes the engine files draw on, so nothing later has to be guessed — +the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not +`SDL3/SDL_ttf.h`): + +```c wrap=galagatypes requires=akgl +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +``` + +The frame loop is the standard bracket, with one addition you will meet in +Step 6 — for now, events in, world drawn, frame out: + +```c wrap=galagahost requires=akgl +while ( SDL_PollEvent(&event) == true ) { + CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); +} +CATCH(errctx, akgl_renderer->frame_start(akgl_renderer)); +CATCH(errctx, akgl_game_update(NULL)); +CATCH(errctx, akgl_renderer->frame_end(akgl_renderer)); +``` + +`akgl_game_update(NULL)` is update-every-actor, step-the-physics, +draw-the-world. It neither clears nor presents; the `frame_start` and +`frame_end` calls own that. + +## Step 2: Scatter a starfield + +**Goal: a scrolling two-depth starfield, from an array and one draw call.** + +No parallax facility exists in libakgl and none is needed. A fixed array of +stars, advanced per frame and drawn with `akgl_draw_point()` between +`frame_start` and `akgl_game_update()`, is the whole feature. Two speed bands +give the depth for free — the slow band reads as far away: + +```c wrap=galagatypes requires=akgl +#define GALAGA_STARS 96 + +static struct +{ + float x; + float y; + float speed; + Uint8 bright; +} STARS[GALAGA_STARS]; + +static akerr_ErrorContext *starfield_draw(float dt) +{ + SDL_Color color = { 255, 255, 255, 255 }; + int i = 0; + PREPARE_ERROR(errctx); + + for ( i = 0; i < GALAGA_STARS; i++ ) { + STARS[i].y += STARS[i].speed * dt; + if ( STARS[i].y > 960.0f ) { + STARS[i].y -= 960.0f; + } + color.r = STARS[i].bright; + color.g = STARS[i].bright; + color.b = STARS[i].bright; + PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color)); + } + SUCCEED_RETURN(errctx); +} +``` + +Seed the array once at startup — even indexes slow and dim (speed 40, bright +110), odd indexes fast and bright (speed 110, bright 220) — and the effect is +done. A point is exactly one pixel (libakgl docs/09-drawing.md). + +## Step 3: Put a ship on screen + +**Goal: a player actor, drawn from a character file, moving on key input.** + +The art is Kenney's Space Shooter pack, CC0, used byte for byte — see +[`examples/galaga/assets/art/PROVENANCE.md`](../examples/galaga/assets/art/PROVENANCE.md) +for what each file is. An actor gets its looks from a **character**, which maps +actor state words to **sprites** (libakgl docs/10 and 12). Both are JSON; load +sprites first, because a character names its sprites and a character loaded +first fails on the first name it cannot find. + +These are the names, so the loading lists and every +`akgl_actor_set_character()` call in both chapters agree — each `sprite_*.json` +and `character_*.json` lives in `assets/`: + +| Character | Sprite(s) it maps | Worn by | +|---|---|---| +| `galaga_player` | `galaga_player` | the ship | +| `galaga_bee` | `galaga_bee` | bees | +| `galaga_butterfly` | `galaga_butterfly` | butterflies | +| `galaga_boss` | `galaga_boss`, and `galaga_boss_hurt` on state bit 13 | bosses | +| `galaga_playershot` | `galaga_playershot` | the ship's shots | +| `galaga_enemyshot` | `galaga_enemyshot` | enemy shots | +| `galaga_boom` | `galaga_boom` | explosions | + +The spawn is four decisions after the two boilerplate calls: + +```c wrap=galagagame requires=akgl +static akerr_ErrorContext *galaga_player_spawn(void) +{ + akgl_Actor *player = NULL; + PREPARE_ERROR(errctx); + + PASS(errctx, akgl_heap_next_actor(&player)); + PASS(errctx, akgl_actor_initialize(player, "player")); + PASS(errctx, akgl_actor_set_character(player, "galaga_player")); + /* AFTER initialize: it resets all seven hooks. */ + player->updatefunc = &player_update; + player->movement_controls_face = false; + player->state = AKGL_ACTOR_STATE_ALIVE; + player->visible = true; + player->x = 590.0f; + player->y = 860.0f; + + galaga_game.player = player; + SUCCEED_RETURN(errctx); +} +``` + +Each of the four lines under the comment closes a trap: + +- **`updatefunc` after `akgl_actor_initialize()`**, never before — initialize + installs all seven default hooks, and a hook set first is a hook reset. +- **`movement_controls_face = false`.** The default facing logic edits the + state word, a character mapping matches the **whole** word, and an actor + whose state matches no mapping is *silently not drawn*. Nothing here moves by + state bits, so facing stays out of the word entirely. +- **`state = AKGL_ACTOR_STATE_ALIVE`** — the word the character mapping names. +- **`visible = true`.** `akgl_actor_initialize()` does not raise it. In a + tilemap game the map loader copies visibility from map data; there is no map + here, so an actor that skips this line exists, moves, fires and collides — + invisibly. This one line cost this example its first screenshot. + +Input goes through a control map: push a control per key with handlers that set +flags, and let the actor's update hook read the flags. A handler receives the +map's target actor and the event, and returns through the error protocol like +everything else — this pair is the whole pattern, repeated per key: + +```c wrap=galagagame requires=akgl +static bool MOVELEFT = false; + +akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + MOVELEFT = true; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + MOVELEFT = false; + SUCCEED_RETURN(errctx); +} +``` + +(The example keeps the flags in its `galaga_Game` struct rather than statics; +either works.) The bindings themselves are pushes onto map 0: + +```c wrap=galagagame requires=akgl +static akerr_ErrorContext *galaga_player_controls(void) +{ + akgl_Control control; + PREPARE_ERROR(errctx); + + memset(&control, 0, sizeof(control)); + control.event_on = SDL_EVENT_KEY_DOWN; + control.event_off = SDL_EVENT_KEY_UP; + + control.key = SDLK_LEFT; + control.handler_on = &left_on; + control.handler_off = &left_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + control.key = SDLK_RIGHT; + control.handler_on = &right_on; + control.handler_off = &right_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + control.key = SDLK_SPACE; + control.handler_on = &fire_on; + control.handler_off = &fire_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + akgl_controlmaps[0].target = galaga_game.player; + SUCCEED_RETURN(errctx); +} +``` + +Hand **every** polled event to `akgl_controller_handle_event()` — one that no +control binds is not an error, it is a call that did nothing. + +## Step 4: Shots and collision + +**Goal: bullets that fly, hit, and give their actor slot back.** + +Bullets and collision are C forever — they are engine, not behavior. A shot is +an actor from the same 64-slot heap pool, with its own tiny update hook: move, +test, release. + +```c wrap=galagagame requires=akgl +static akerr_ErrorContext *player_shot_update(akgl_Actor *obj) +{ + SDL_FRect mine; + SDL_FRect theirs; + bool hit = false; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + obj->y -= 900.0f * galaga_game.dt; + if ( obj->y < -60.0f ) { + galaga_game.player_shots_live -= 1; + PASS(errctx, akgl_heap_release_actor(obj)); + SUCCEED_RETURN(errctx); + } + + shot_box(obj, &mine); + for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) { + if ( galaga_enemy_actors[i] == NULL ) { + continue; + } + enemy_box(galaga_enemy_actors[i], &theirs); + PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit)); + if ( !hit ) { + continue; + } + galaga_enemies[i].hp -= 1; + if ( galaga_enemies[i].hp <= 0 ) { + PASS(errctx, kill_enemy(i)); + } + galaga_game.player_shots_live -= 1; + PASS(errctx, akgl_heap_release_actor(obj)); + SUCCEED_RETURN(errctx); + } + SUCCEED_RETURN(errctx); +} +``` + +Four conventions worth keeping: + +- **`akgl_collide_rectangles()` is the whole collision system.** At most 2 + shots x 40 enemies of axis-aligned tests per frame is noise; the full + `akgl_CollisionWorld` machinery earns its keep on tilemaps, not here. The + `shot_box`/`enemy_box` helpers inset each box from the artwork's rectangle, + because the PNGs carry transparent margin that should not kill anybody. +- **Releasing is despawning.** `akgl_heap_release_actor()` unregisters the + actor and stops it drawing; releasing mid-sweep is safe because + `akgl_game_update()` re-reads each slot's refcount as it goes. +- **Names carry a serial** — `pshot17`, not `pshot1` reused — because the actor + registry is keyed by name, and two live actors with one name is a fight. +- **Spawn caps are C-side refusals.** Two player shots, eight enemy shots; the + spawn functions simply decline past the cap. + +Give the enemy shots the same shape falling downward, and the ship a sweep over +both — `examples/galaga/player.c` has all three loops. + +Explosions are the fourth actor kind, and they carry the one place this game +*absorbs* an error instead of propagating it. `HANDLE` names the status it +forgives; everything else still travels: + +```c wrap=galagagame requires=akgl +static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR]; +static uint32_t BOOM_SERIAL = 0; + +static akerr_ErrorContext *boom_update(akgl_Actor *obj) +{ + ptrdiff_t slot = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + slot = obj - akgl_heap_actors; + BOOM_TTL[slot] -= galaga_game.dt; + if ( BOOM_TTL[slot] <= 0.0f ) { + PASS(errctx, akgl_heap_release_actor(obj)); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_boom_spawn(float x, float y) +{ + akgl_Actor *boom = NULL; + char name[32]; + int count = 0; + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_heap_next_actor(&boom)); + BOOM_SERIAL += 1; + CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL)); + CATCH(errctx, akgl_actor_initialize(boom, name)); + CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom")); + boom->updatefunc = &boom_update; + boom->movement_controls_face = false; + boom->state = AKGL_ACTOR_STATE_ALIVE; + boom->visible = true; + boom->x = x; + boom->y = y; + BOOM_TTL[boom - akgl_heap_actors] = 0.25f; + } CLEANUP { + } PROCESS(errctx) { + } HANDLE(errctx, AKGL_ERR_HEAP) { + /* Explosions are decoration. When the heap is momentarily full the + * right outcome is no explosion, not a dead frame. */ + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} +``` + +## Step 5: Boot the interpreter + +**Goal: the engine calls a BASIC function and prints its answer.** + +Everything so far was libakgl. Now link the interpreter into the same +executable. The whole CMake recipe, inside an akbasic checkout with +`AKBASIC_WITH_AKGL=ON`: + +```cmake +add_executable(mygalaga + main.c + script.c + enemies.c + player.c) +target_compile_options(mygalaga PRIVATE -Wall -Wextra) +target_compile_definitions(mygalaga PRIVATE + GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets" + GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas" + GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf") +target_link_libraries(mygalaga PRIVATE akbasic akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image) +``` + +The three baked-in paths are what let the program launch from any working +directory; `--assets` and `--script` flags can override them at runtime. + +Link `akbasic` — the interpreter only. Not `akbasic_akgl` (the device backends +that let a script draw), and not `akbasic_frontend` (the standalone program's +host). This game lends the script **no devices at all**: the scripts compute, +the engine draws, and a script that tries `SPRITE` is refused by name. That +refusal is enforced by the interpreter, not by convention — +[Chapter 10](10-embedding.md) explains the device-lending model this game +declines to use. + +The boot is the embedding host from Chapter 10, adapted to a script that only +defines. Keep every line that touches the interpreter in one file — the +example's `script.c` — so the boundary stays a place rather than a habit. That +file's interpreter-facing includes and statics, exactly: + +```c wrap=galagatypes requires=akgl +#include +#include +#include +#include +#include + +/* Static because an akbasic_Runtime is far too big for a stack frame -- + * 2.40 MiB on this branch. */ +static akbasic_Runtime SCRIPT; +static akbasic_TextSink SINK; +static akbasic_StdioSink SINKSTATE; +static char SOURCE[16384]; +``` + +The boot itself: + +```c wrap=galagacalls requires=akgl +CATCH(errctx, akbasic_error_register()); +CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL)); +CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK)); + +CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE)); +CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN)); +CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES)); +CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)); +``` + +Two of those lines are the ones a first embedding gets wrong. + +**A "no top level code" script still has to run once.** The script is nothing +but `DEF` blocks and a final `END`, and executing the `DEF` statements is what +files the functions. The run is bounded — a script that is all definitions has +no business taking more than a few steps per line, and an accidental loop at +boot should be a diagnosis, not a hang. + +**The `set_mode` after the run is load-bearing.** The program has now ended and +the runtime sits in QUIT mode, where a multi-line `DEF` called from the host +returns a silent zero. Forcing the mode back to RUN makes the bodies run, and it +stays put because nothing here ever steps the runtime again. Issue #8 tracks +making this workaround unnecessary. + +`PRINT` inside the script goes through the stdio sink and lands on stdout — +that is the script's debug channel for the rest of both chapters. + +Prove the wiring with one function. Put this in the script: + +```basic +DEF ADDEM(A#, B#) = A# + B# +END +``` + +And call it from C, with values you already have: + +```c wrap=galagacalls requires=akgl +memset(&args[0], 0, sizeof(args[0])); +memset(&args[1], 0, sizeof(args[1])); +args[0].valuetype = AKBASIC_TYPE_INTEGER; +args[0].intval = 17; +args[1].valuetype = AKBASIC_TYPE_INTEGER; +args[1].intval = 25; +argp[0] = &args[0]; +argp[1] = &args[1]; +CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result)); +printf("ADDEM(17, 25) = %lld\n", (long long)result->intval); +``` + +```text +ADDEM(17, 25) = 42 +``` + +`akbasic_runtime_call_function()` is the host's entry point: a name and +already-evaluated values in, the function's result out. The engine refuses to +start when the script will not boot — a game whose enemies cannot think is not +a game missing a feature, it is a game that does not run. + +## Step 6: The update hook + +**Goal: one actor whose every frame is a BASIC call.** + +`akgl_game_update()` calls each live actor's `updatefunc` exactly once per +frame. Replacing that pointer is the whole integration: the actor's frame *is* +a script call. + +```c wrap=galagagame requires=akgl +static akerr_ErrorContext *enemy_update(akgl_Actor *obj) +{ + galaga_Enemy *enemy = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + enemy = (galaga_Enemy *)obj->actorData; + FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached"); + + enemy->rnd = galaga_random(); + PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt)); + if ( enemy->fire != 0 ) { + PASS(errctx, enemy_fire(enemy, obj)); + } + SUCCEED_RETURN(errctx); +} +``` + +The hook's body is a protocol, and `galaga_script_update_enemy()` is its +middle: **rebind, call, recover, reset.** + +```c wrap=galagacalls requires=akgl +CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy)); +CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor)); + +memset(&dtval, 0, sizeof(dtval)); +dtval.valuetype = AKBASIC_TYPE_FLOAT; +dtval.floatval = (double)dt; +argp[0] = &dtval; +CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result)); + +CATCH(errctx, akbasic_environment_zero(SCRIPT.environment)); +``` + +`SELF@` and `ACTOR@` are **host bindings** — the enemy's record and the +engine's live actor, shared with the script as structures it can read and +write directly. [Chapter 21](21-tutorial-galaga-enemies.md) builds them; for +this chapter, know that `akbasic_host_rebind()` points an existing binding at +a different instance, which is how forty enemies share one script: one name, +rebound per enemy, rather than forty names. + +**The `akbasic_environment_zero()` after every call is load-bearing.** Each +call parks its result in the caller environment's per-line value scratch, and a +host calling in a loop never crosses the line boundary that would reset it. +Without this line the scratch drains in under two frames of a 40-enemy wave and +every later call fails with `Maximum values per line reached`. Chapter 10's +["Calling a function every frame"](10-embedding.md#calling-a-function-every-frame) +section is the rule's home. + +## Step 7: First light + +**Goal: a C actor moving under BASIC control, and proof it is one memory.** + +Before any real AI, the smallest demonstration. One enemy, one function, a sine +drift written entirely in BASIC through the actor binding: + +```basic +DEF UPDATEBEE(DT%) + SELF@.T% = SELF@.T% + DT% + ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200 + ACTOR@.Y% = 300.0 + PRINT "BASIC SEES X = " + ACTOR@.X% + RETURN 0 +END +``` + +Spawn one enemy with the hook from Step 6, and have the engine print the same +actor's position each frame from C: + +```c wrap=galagahost requires=akgl +SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x); +``` + +```text +BASIC SEES X = 593.191094 +INFO: C SEES X = 593.191094 +BASIC SEES X = 596.378593 +INFO: C SEES X = 596.378593 +``` + +Same numbers, one memory. The script wrote `ACTOR@.X%`; the renderer read +`akgl_Actor.x`; nothing copied anything anywhere. The ship swings in a slow +arc, and the whole architecture is visible in that one motion: C owns the +frame, BASIC owns the decision, and the actor is the same bytes to both. + +## Step 8: Screens + +**Goal: title, playing, game over, victory — a state machine around the loop.** + +The screens are libakgl's UI layer, in the three-state pattern of its uidemo +example (libakgl docs/22-ui.md). A `galaga_Screen` enum, one `declare_*()` +function per screen, and the UI bracket between `akgl_game_update()` and +`frame_end` — exactly where the frame contract puts it: + +```c wrap=galagahost requires=akgl +CATCH(errctx, akgl_ui_frame_begin()); +switch ( galaga_game.screen ) { +case GALAGA_SCREEN_TITLE: + CATCH(errctx, declare_title()); + break; +case GALAGA_SCREEN_PLAY: + CATCH(errctx, declare_play()); + break; +case GALAGA_SCREEN_GAMEOVER: +case GALAGA_SCREEN_VICTORY: + CATCH(errctx, declare_end()); + break; +} +CATCH(errctx, akgl_ui_frame_end(akgl_renderer)); +``` + +The playing screen is two `akgl_ui_label()` calls — a widget call per label, +not a struct — 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: + +```c wrap=galagagame requires=akgl +static char HUD_SCORE[64]; +static char HUD_LIVES[64]; + +static akerr_ErrorContext *declare_play(void) +{ + int count = 0; + PREPARE_ERROR(errctx); + + PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE), + "SCORE %06d", galaga_game.score)); + PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES), + "LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave)); + PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + SUCCEED_RETURN(errctx); +} +``` + +The title and end screens are an `akgl_ui_menu()` at the center, fed an +`akgl_UiMenu` that lives in a `static` for the same borrowing reason. The +struct is an id, the item strings, a count, the selected index, the +`activated` output flag, and a style (`NULL` for the default): + +```c wrap=galagatypes requires=akgl +static akgl_UiMenu TITLE_MENU = { + "titlemenu", { "START", "QUIT" }, 2, 0, false, NULL +}; + +static akerr_ErrorContext *declare_title(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, akgl_ui_menu(&TITLE_MENU)); + SUCCEED_RETURN(errctx); +} +``` + +Route events to the menu with +`akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed)` — the menu for +whichever screen is up, a `bool` out-parameter reporting whether the event was +taken. Up and down move `selected`, return sets `activated`. + +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..ff14dc7 --- /dev/null +++ b/docs/21-tutorial-galaga-enemies.md @@ -0,0 +1,585 @@ +# 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 11](#step-11-the-cost-measured)** — measure what thinking in BASIC + costs, against the same logic in C + +--- + +## 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. + +Registration and the first binding happen at boot, before the script loads — +between `akbasic_runtime_init()` and `akbasic_runtime_load()` in Chapter 20's +boot sequence. A binding is **borrowed, never copied**, so the placeholders it +points at must be static storage: + +```c wrap=galagacalls requires=akgl +CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE)); +CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE)); +CATCH(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE)); + +CATCH(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY)); +CATCH(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR)); +CATCH(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared)); +``` + +`akbasic_host_bind()` takes the script name, the registered type's name, and +the instance; after that, `SELF@` and `ACTOR@` are only ever *re*bound. + +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 +``` + +## Step 11: The cost, measured + +**Goal: the real price of the boundary, in numbers, next to the same logic in C.** + +The interop test binary ends with a benchmark: 24,000 formation-hold updates — +forty enemies at sixty frames a second for ten seconds — once through +`galaga_script_update_enemy()` and once through a line-for-line C translation +of `UPDATEBEE` with its helpers inlined. Same guard, same branches, same +arithmetic; the difference is the interpreter. On this repository's build +machine (a two-core VM, the interpreter built `-O2`): + +```text +benchmark: 24000 formation-hold updates, dt 0.016 + BASIC through the boundary: 21.147 s 881.11 us/call 35.245 ms per 40-enemy frame + the same logic in C: 0.000 s 0.01 us/call 0.001 ms per 40-enemy frame + ratio: 61022x +``` + +The facts, without decoration: + +- **A BASIC-driven update costs about four orders of magnitude more than the + same logic compiled.** The C translation of the whole state machine costs + tens of *nano*seconds; the scripted call costs high hundreds of + *micro*seconds. +- **The cost is per line executed, not per call.** The interpreter scans and + parses each body line from source text on every call; a 3-line body measured + ~148 us on this class of machine, and this ~15-line body measures ~881 us. + Body length is the knob. +- **At this cost, forty thinking enemies spend ~35 ms per frame on this + hardware** — more than two 60 Hz frames. The shipped example visibly runs + below 60 fps on this machine while the whole wave is alive, and exactly at + its frame pace once the wave thins. A faster machine moves the numbers, not + the shape. + +This is the measured version of decisions the chapters already made on +architectural grounds. Bullets, collision and the starfield are C +([Chapter 20](20-tutorial-galaga.md), Steps 2 and 4) — at two shots and forty +tests a frame, scripting them would multiply the call count for things that +decide nothing. The fire decision is one flag rather than a per-bullet +callback (Step 6): the script's call budget is bounded by the enemy count and +nothing else. C owns the formation and the spawn timing (Step 8), so zero +calls happen for enemies that do not exist yet. And the 36 KiB function slots +and 2.40 MiB runtime (Step 5) are the memory half of the same bill. + +What the cost buys is the previous ten steps: behavior as data, edited and +swapped without a compiler. Whether ~900 us per thinking entity per frame is +acceptable is a per-project decision — fewer thinkers, shorter bodies, or a +lower think rate (every Nth frame) are the standard levers, and all three are +host-side choices this architecture leaves open. + +--- + +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/docs/images/galaga-title.png b/docs/images/galaga-title.png new file mode 100644 index 0000000..7a71b86 Binary files /dev/null and b/docs/images/galaga-title.png differ diff --git a/docs/images/galaga-wave.png b/docs/images/galaga-wave.png new file mode 100644 index 0000000..daf87f1 Binary files /dev/null and b/docs/images/galaga-wave.png differ diff --git a/examples/galaga/README.md b/examples/galaga/README.md new file mode 100644 index 0000000..1e2ac99 --- /dev/null +++ b/examples/galaga/README.md @@ -0,0 +1,63 @@ +# GALAGA — a C engine with akbasic embedded as its enemy brain + +A GALAGA-style fixed shooter whose core engine is C on libakgl, with akbasic +linked in as the scripting engine that owns every enemy's behavior. One BASIC +script — `galaga.bas`, nothing but `DEF` functions and an `END` — is called +once per enemy per frame through a custom `akgl_Actor` update hook. Bullets, +collision, scoring and screens are C forever; everything an enemy *decides* is +BASIC. + +This is the checked-in example behind two tutorial chapters, and the chapters +are the intended way in: + +* **[Chapter 20](../../docs/20-tutorial-galaga.md)** — the engine and the + boundary: from an empty file to a C game that boots a script and hands one + actor to BASIC. +* **[Chapter 21](../../docs/21-tutorial-galaga-enemies.md)** — the data + structures and the AI: from `SELF@` to a full attacking wave. + +It is an academic exercise demonstrating *how* such an embed is done, not a +claim that it is the best way to write a GALAGA. + +## Building and running + +The example builds when both example and graphics builds are on: + +``` +cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON +cmake --build build-akgl --target akbasic_example_galaga +build-akgl/akbasic_example_galaga +``` + +| Key | Does | +|---|---| +| Left / Right | move the ship | +| Space | fire (two shots on screen, the classic rule) | +| Return | choose a menu entry | + +## Flags + +``` +akbasic_example_galaga [--assets DIR] [--script PATH] [--frames N] + [--autoplay] [--screenshot PATH] [--screenshot-frame N] +``` + +`--script` points at a different enemy script, which is the whole point of the +architecture: edit `galaga.bas`, run again, no rebuild. `--frames N` with +`--autoplay` is the headless smoke test CI runs under the dummy SDL drivers; +the final log line reports frames, score, kills and shots per kind, and the +script-error count — a wave of dumb enemies still exits 0, and that count is +how you notice. + +## The files + +| File | Owns | +|---|---| +| `galaga.h` | the shared structs — the whole boundary in one header | +| `main.c` | startup order, the frame loop, screens, the starfield | +| `script.c` | everything that touches the interpreter | +| `enemies.c` | wave table, formation grid, the enemy update hook | +| `player.c` | the ship, both bullet kinds, every collision | +| `galaga.bas` | every decision an enemy makes | +| `interop_test.c` | round-trip proof the boundary works, run by CTest | +| `assets/` | sprite/character JSON, and Kenney CC0 art under `assets/art/` | diff --git a/examples/galaga/assets/art/License.txt b/examples/galaga/assets/art/License.txt new file mode 100644 index 0000000..1201ab5 --- /dev/null +++ b/examples/galaga/assets/art/License.txt @@ -0,0 +1,14 @@ + +############################################################################### + + Space Shooter (Remastered, plus fonts and sounds) by Kenney Vleugels (www.kenney.nl) + + ------------------------------ + + License (CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You may use these graphics in personal and commercial projects. + Credit (Kenney or www.kenney.nl) would be nice but is not mandatory. + +############################################################################### \ No newline at end of file diff --git a/examples/galaga/assets/art/PROVENANCE.md b/examples/galaga/assets/art/PROVENANCE.md new file mode 100644 index 0000000..9014bd2 --- /dev/null +++ b/examples/galaga/assets/art/PROVENANCE.md @@ -0,0 +1,39 @@ +# Where this art came from + +Every PNG in this directory is from **Kenney's Space Shooter (Remastered)**, released +into the public domain under +[Creative Commons Zero](http://creativecommons.org/publicdomain/zero/1.0/). +`License.txt` is the pack's own licence file, copied here unedited. + +* Source: +* Downloaded: 2026-08-04, `kenney_space-shooter-remastered.zip` +* Author: Kenney () +* Licence: CC0 1.0. Crediting is not required; it is here because it should be. + +The files are the pack's `PNG/` versions, byte for byte — nothing is resized, +recoloured or re-encoded, so the checksum of any of them still matches the +distributed archive. `playerShip1_blue.png` sits at the top of `PNG/`; the enemies +are from `PNG/Enemies/` and the lasers from `PNG/Lasers/`. + +| File | Size | Used for | +|---|---|---| +| `playerShip1_blue.png` | 99x75 | the player's ship | +| `enemyBlue1.png` | 93x84 | the bee | +| `enemyRed2.png` | 104x84 | the butterfly | +| `enemyGreen3.png` | 103x84 | the boss, at full health | +| `enemyBlack3.png` | 103x84 | the boss at one hit point — same silhouette, drained colour | +| `laserBlue01.png` | 9x54 | the player's shot | +| `laserRed01.png` | 9x54 | an enemy's shot | +| `laserBlue08.png` | 48x46 | the explosion burst | + +The sprites are used at their distributed size: libakgl draws a sprite at the +sprite's own dimensions (`akgl_Actor.scale` is overwritten every frame — libakgl +docs/12-actors.md), so there is no way to draw these smaller, and the game's +1280x960 view is sized to fit a ten-column formation of them instead. The boss's +damage state is the same shape in a different colour deliberately: the swap has to +read at a glance from the top of the screen. + +Everything else on screen — the starfield and the HUD — is drawn by the program +with `akgl_draw_point()` and the UI layer. See `../../README.md` for the run +instructions and the two tutorial chapters (docs/20, docs/21) for why only the +things that move are artwork. diff --git a/examples/galaga/assets/art/enemyBlack3.png b/examples/galaga/assets/art/enemyBlack3.png new file mode 100644 index 0000000..dafec1b Binary files /dev/null and b/examples/galaga/assets/art/enemyBlack3.png differ diff --git a/examples/galaga/assets/art/enemyBlue1.png b/examples/galaga/assets/art/enemyBlue1.png new file mode 100644 index 0000000..cedc073 Binary files /dev/null and b/examples/galaga/assets/art/enemyBlue1.png differ diff --git a/examples/galaga/assets/art/enemyGreen3.png b/examples/galaga/assets/art/enemyGreen3.png new file mode 100644 index 0000000..74e2bca Binary files /dev/null and b/examples/galaga/assets/art/enemyGreen3.png differ diff --git a/examples/galaga/assets/art/enemyRed2.png b/examples/galaga/assets/art/enemyRed2.png new file mode 100644 index 0000000..3ee96c5 Binary files /dev/null and b/examples/galaga/assets/art/enemyRed2.png differ diff --git a/examples/galaga/assets/art/laserBlue01.png b/examples/galaga/assets/art/laserBlue01.png new file mode 100644 index 0000000..b76aaf7 Binary files /dev/null and b/examples/galaga/assets/art/laserBlue01.png differ diff --git a/examples/galaga/assets/art/laserBlue08.png b/examples/galaga/assets/art/laserBlue08.png new file mode 100644 index 0000000..7a46396 Binary files /dev/null and b/examples/galaga/assets/art/laserBlue08.png differ diff --git a/examples/galaga/assets/art/laserRed01.png b/examples/galaga/assets/art/laserRed01.png new file mode 100644 index 0000000..5e467b6 Binary files /dev/null and b/examples/galaga/assets/art/laserRed01.png differ diff --git a/examples/galaga/assets/art/playerShip1_blue.png b/examples/galaga/assets/art/playerShip1_blue.png new file mode 100644 index 0000000..cecbbed Binary files /dev/null and b/examples/galaga/assets/art/playerShip1_blue.png differ diff --git a/examples/galaga/assets/character_galaga_bee.json b/examples/galaga/assets/character_galaga_bee.json new file mode 100644 index 0000000..0b82825 --- /dev/null +++ b/examples/galaga/assets/character_galaga_bee.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_bee", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_bee" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_boom.json b/examples/galaga/assets/character_galaga_boom.json new file mode 100644 index 0000000..fa39a92 --- /dev/null +++ b/examples/galaga/assets/character_galaga_boom.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_boom", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_boom" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_boss.json b/examples/galaga/assets/character_galaga_boss.json new file mode 100644 index 0000000..13ff200 --- /dev/null +++ b/examples/galaga/assets/character_galaga_boss.json @@ -0,0 +1,23 @@ +{ + "name": "galaga_boss", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_boss" + }, + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE", + "AKGL_ACTOR_STATE_UNDEFINED_13" + ], + "sprite": "galaga_boss_hurt" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_butterfly.json b/examples/galaga/assets/character_galaga_butterfly.json new file mode 100644 index 0000000..e2a53be --- /dev/null +++ b/examples/galaga/assets/character_galaga_butterfly.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_butterfly", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_butterfly" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_enemyshot.json b/examples/galaga/assets/character_galaga_enemyshot.json new file mode 100644 index 0000000..9827180 --- /dev/null +++ b/examples/galaga/assets/character_galaga_enemyshot.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_enemyshot", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_enemyshot" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_player.json b/examples/galaga/assets/character_galaga_player.json new file mode 100644 index 0000000..54639be --- /dev/null +++ b/examples/galaga/assets/character_galaga_player.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_player", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_player" + } + ] +} diff --git a/examples/galaga/assets/character_galaga_playershot.json b/examples/galaga/assets/character_galaga_playershot.json new file mode 100644 index 0000000..37a0fef --- /dev/null +++ b/examples/galaga/assets/character_galaga_playershot.json @@ -0,0 +1,16 @@ +{ + "name": "galaga_playershot", + "speedtime": 200, + "speed_x": 0.0, + "speed_y": 0.0, + "acceleration_x": 0.0, + "acceleration_y": 0.0, + "sprite_mappings": [ + { + "state": [ + "AKGL_ACTOR_STATE_ALIVE" + ], + "sprite": "galaga_playershot" + } + ] +} diff --git a/examples/galaga/assets/sprite_galaga_bee.json b/examples/galaga/assets/sprite_galaga_bee.json new file mode 100644 index 0000000..30397ed --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_bee.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/enemyBlue1.png", + "frame_width": 93, + "frame_height": 84 + }, + "name": "galaga_bee", + "width": 93, + "height": 84, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_boom.json b/examples/galaga/assets/sprite_galaga_boom.json new file mode 100644 index 0000000..4575076 --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_boom.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/laserBlue08.png", + "frame_width": 48, + "frame_height": 46 + }, + "name": "galaga_boom", + "width": 48, + "height": 46, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_boss.json b/examples/galaga/assets/sprite_galaga_boss.json new file mode 100644 index 0000000..62e9a8e --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_boss.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/enemyGreen3.png", + "frame_width": 103, + "frame_height": 84 + }, + "name": "galaga_boss", + "width": 103, + "height": 84, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_boss_hurt.json b/examples/galaga/assets/sprite_galaga_boss_hurt.json new file mode 100644 index 0000000..3f3537c --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_boss_hurt.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/enemyBlack3.png", + "frame_width": 103, + "frame_height": 84 + }, + "name": "galaga_boss_hurt", + "width": 103, + "height": 84, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_butterfly.json b/examples/galaga/assets/sprite_galaga_butterfly.json new file mode 100644 index 0000000..a86909b --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_butterfly.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/enemyRed2.png", + "frame_width": 104, + "frame_height": 84 + }, + "name": "galaga_butterfly", + "width": 104, + "height": 84, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_enemyshot.json b/examples/galaga/assets/sprite_galaga_enemyshot.json new file mode 100644 index 0000000..87da91f --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_enemyshot.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/laserRed01.png", + "frame_width": 9, + "frame_height": 54 + }, + "name": "galaga_enemyshot", + "width": 9, + "height": 54, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_player.json b/examples/galaga/assets/sprite_galaga_player.json new file mode 100644 index 0000000..fcfe452 --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_player.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/playerShip1_blue.png", + "frame_width": 99, + "frame_height": 75 + }, + "name": "galaga_player", + "width": 99, + "height": 75, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/assets/sprite_galaga_playershot.json b/examples/galaga/assets/sprite_galaga_playershot.json new file mode 100644 index 0000000..05ed19f --- /dev/null +++ b/examples/galaga/assets/sprite_galaga_playershot.json @@ -0,0 +1,16 @@ +{ + "spritesheet": { + "filename": "art/laserBlue01.png", + "frame_width": 9, + "frame_height": 54 + }, + "name": "galaga_playershot", + "width": 9, + "height": 54, + "speed": 200, + "loop": false, + "loopReverse": false, + "frames": [ + 0 + ] +} diff --git a/examples/galaga/enemies.c b/examples/galaga/enemies.c new file mode 100644 index 0000000..aca8580 --- /dev/null +++ b/examples/galaga/enemies.c @@ -0,0 +1,314 @@ +/** + * @file enemies.c + * @brief The formation, the wave, and the hook that hands each enemy to BASIC. + * + * C owns the grid, the wave table and the spawn timing; BASIC owns everything + * an enemy does after it exists. The formation slot arrives in SELF@.HOMEX% / + * HOMEY%, so even the idle breathing of the grid is the script's, computed + * relative to home. + */ + +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include + +#include "galaga.h" + +galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES]; +akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES]; + +/* Explosion lifetimes, indexed by heap slot. An explosion is an actor with + * nothing to decide, so its whole state is one countdown. */ +static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR]; + +/* A spawn serial per shot so registry names never collide while two shots + * with the same slot number are briefly both alive. */ +static uint32_t SHOT_SERIAL = 0; +static uint32_t BOOM_SERIAL = 0; + +/* + * The wave, one row per formation row. Columns are 0..9 at GALAGA_COL_PITCH; + * `first` and `count` say which columns the row fills. 40 enemies: 4 bosses, + * 16 butterflies, 20 bees -- 59 of the 64 actor heap slots at peak, counting + * the player, two player shots, eight enemy shots and eight explosions. + */ +static const struct +{ + int32_t kind; /* GALAGA_ENEMY_* */ + int row; /* formation row */ + int first; /* first column filled */ + int count; /* columns filled */ + int32_t hp; +} +WAVE_ROWS[] = { + /* kind row first count hp */ + { GALAGA_ENEMY_BOSS, 0, 3, 4, 2 }, + { GALAGA_ENEMY_BUTTERFLY, 1, 1, 8, 1 }, + { GALAGA_ENEMY_BUTTERFLY, 2, 1, 8, 1 }, + { GALAGA_ENEMY_BEE, 3, 0, 10, 1 }, + { GALAGA_ENEMY_BEE, 4, 0, 10, 1 } +}; +#define WAVE_ROW_COUNT ((int)(sizeof(WAVE_ROWS) / sizeof(WAVE_ROWS[0]))) + +/* Enemy kind -> character name, the render half of the dispatch table. */ +static char *ENEMY_CHARACTER[GALAGA_ENEMY_KINDS] = { + "galaga_bee", + "galaga_butterfly", + "galaga_boss" +}; + +/* --------------------------------------------------------------- random --- */ + +/* + * No RND verb exists (issue #16), so the engine is the script's only source + * of randomness: it refreshes GAME@.RND% each frame and SELF@.RND% each call + * from this PRNG. A hand-rolled LCG rather than rand() so a headless run is + * the same game on every libc. + */ +static uint32_t PRNG_STATE = 0x12345678u; + +float galaga_random(void) +{ + PRNG_STATE = PRNG_STATE * 1664525u + 1013904223u; + return (float)(PRNG_STATE >> 8) / (float)0x01000000u; +} + +/* -------------------------------------------------------------- helpers --- */ + +static akerr_ErrorContext *release_actor(akgl_Actor *actor) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); + PASS(errctx, akgl_heap_release_actor(actor)); + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- shots --- */ + +/** + * @brief Move an enemy shot; release it once it has left the screen. + */ +static akerr_ErrorContext *enemy_shot_update(akgl_Actor *obj) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + obj->y += 380.0f * galaga_game.dt; + if ( obj->y > (float)GALAGA_VIEW_HEIGHT + 60.0f ) { + galaga_game.enemy_shots_live -= 1; + PASS(errctx, release_actor(obj)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Consume an enemy's fire flag: take an actor and aim it downward. + * + * The script only raises a flag. Spawning takes a slot from the actor heap, + * and pool exhaustion must be a C-side refusal with the house error context -- + * so C consumes the flag and does the spawn. The engine also enforces the + * eight-shot cap by simply not consuming the flag's wish. + */ +static akerr_ErrorContext *enemy_fire(galaga_Enemy *enemy, akgl_Actor *from) +{ + akgl_Actor *shot = NULL; + char name[32]; + int count = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy"); + FAIL_ZERO_RETURN(errctx, from, AKERR_NULLPOINTER, "from"); + + enemy->fire = 0; + if ( galaga_game.enemy_shots_live >= GALAGA_MAX_ENEMY_SHOTS ) { + SUCCEED_RETURN(errctx); + } + + SHOT_SERIAL += 1; + PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "eshot%u", SHOT_SERIAL)); + PASS(errctx, akgl_heap_next_actor(&shot)); + PASS(errctx, akgl_actor_initialize(shot, name)); + PASS(errctx, akgl_actor_set_character(shot, "galaga_enemyshot")); + /* AFTER initialize: it resets all seven hooks. */ + shot->updatefunc = &enemy_shot_update; + shot->movement_controls_face = false; + shot->state = AKGL_ACTOR_STATE_ALIVE; + /* akgl_actor_initialize() does not raise `visible`; a hand-spawned actor + * that skips this line exists, moves and collides -- invisibly. */ + shot->visible = true; + /* Actor x/y is a sprite's top-left corner; the shot leaves the enemy's + * midline. Enemy sprites run 93..104 wide, the shot is 9. */ + shot->x = from->x + 46.0f; + shot->y = from->y + 60.0f; + + galaga_game.enemy_shots_live += 1; + galaga_game.shots[enemy->kind] += 1; + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------- explosions --- */ + +static akerr_ErrorContext *boom_update(akgl_Actor *obj) +{ + ptrdiff_t slot = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + slot = obj - akgl_heap_actors; + BOOM_TTL[slot] -= galaga_game.dt; + if ( BOOM_TTL[slot] <= 0.0f ) { + PASS(errctx, release_actor(obj)); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_boom_spawn(float x, float y) +{ + akgl_Actor *boom = NULL; + char name[32]; + int count = 0; + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_heap_next_actor(&boom)); + BOOM_SERIAL += 1; + CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL)); + CATCH(errctx, akgl_actor_initialize(boom, name)); + CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom")); + boom->updatefunc = &boom_update; + boom->movement_controls_face = false; + boom->state = AKGL_ACTOR_STATE_ALIVE; + boom->visible = true; + boom->x = x; + boom->y = y; + BOOM_TTL[boom - akgl_heap_actors] = 0.25f; + } CLEANUP { + } PROCESS(errctx) { + } HANDLE(errctx, AKGL_ERR_HEAP) { + /* Explosions are decoration. When the heap is momentarily full the + * right outcome is no explosion, not a dead frame -- this is the one + * spawn that absorbs exhaustion. */ + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/* --------------------------------------------------------------- enemies --- */ + +/** + * @brief The custom update hook: one enemy, once per frame, thought in BASIC. + * + * The whole body is the protocol from docs/20: refresh the inbox, hand the + * pair to the script, consume the outbox. akgl_game_update() calls this in + * place of akgl_actor_update() because spawn replaced the hook. + */ +static akerr_ErrorContext *enemy_update(akgl_Actor *obj) +{ + galaga_Enemy *enemy = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + enemy = (galaga_Enemy *)obj->actorData; + FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached"); + + enemy->rnd = galaga_random(); + PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt)); + if ( enemy->fire != 0 ) { + PASS(errctx, enemy_fire(enemy, obj)); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_wave_spawn(void) +{ + akgl_Actor *actor = NULL; + galaga_Enemy *enemy = NULL; + char name[32]; + int row = 0; + int col = 0; + int index = 0; + int count = 0; + PREPARE_ERROR(errctx); + + for ( row = 0; row < WAVE_ROW_COUNT; row++ ) { + for ( col = 0; col < WAVE_ROWS[row].count; col++ ) { + FAIL_NONZERO_RETURN(errctx, (index >= GALAGA_MAX_ENEMIES), AKERR_OUTOFBOUNDS, + "The wave table places more than %d enemies", GALAGA_MAX_ENEMIES); + enemy = &galaga_enemies[index]; + memset(enemy, 0, sizeof(*enemy)); + enemy->kind = WAVE_ROWS[row].kind; + enemy->state = GALAGA_ES_ENTERING; + enemy->homex = (float)(GALAGA_FORM_LEFT + + (WAVE_ROWS[row].first + col) * GALAGA_COL_PITCH); + enemy->homey = (float)(GALAGA_FORM_TOP + WAVE_ROWS[row].row * GALAGA_ROW_PITCH); + enemy->hp = WAVE_ROWS[row].hp; + /* Stagger the entries: each enemy's clock starts in the past, and + * the script holds still until its own t crosses zero. */ + enemy->t = -0.08f * (float)index; + + PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "enemy%02d", index)); + PASS(errctx, akgl_heap_next_actor(&actor)); + PASS(errctx, akgl_actor_initialize(actor, name)); + PASS(errctx, akgl_actor_set_character(actor, ENEMY_CHARACTER[enemy->kind])); + /* AFTER initialize: it resets all seven hooks. */ + actor->updatefunc = &enemy_update; + actor->actorData = enemy; + /* Nothing here moves by state bits, and an actor whose state word + * matches no character mapping is silently not drawn -- so facing + * stays entirely out of the state word. */ + actor->movement_controls_face = false; + actor->state = AKGL_ACTOR_STATE_ALIVE; + /* akgl_actor_initialize() does not raise `visible` -- the map + * loader copies it from map data, and there is no map here. Skip + * this and the whole wave exists, moves, fires and dies without + * ever being drawn. */ + actor->visible = true; + /* Off screen above, pouring in from whichever side is closer. */ + actor->x = (enemy->homex < (float)GALAGA_VIEW_WIDTH / 2.0f) + ? -80.0f : (float)GALAGA_VIEW_WIDTH + 80.0f; + actor->y = -80.0f; + + galaga_enemy_actors[index] = actor; + index += 1; + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_wave_release(void) +{ + int i = 0; + PREPARE_ERROR(errctx); + + for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) { + if ( galaga_enemy_actors[i] != NULL ) { + PASS(errctx, release_actor(galaga_enemy_actors[i])); + galaga_enemy_actors[i] = NULL; + } + } + SUCCEED_RETURN(errctx); +} + +int galaga_enemies_alive(void) +{ + int i = 0; + int alive = 0; + + for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) { + if ( galaga_enemy_actors[i] != NULL ) { + alive += 1; + } + } + return alive; +} diff --git a/examples/galaga/galaga.bas b/examples/galaga/galaga.bas new file mode 100644 index 0000000..8105f5b --- /dev/null +++ b/examples/galaga/galaga.bas @@ -0,0 +1,119 @@ +REM GALAGA enemy behavior. The C engine loads this file, runs it once so the +REM definitions exist, and then calls one UPDATE function per enemy per frame. +REM There is no top-level code: definitions, then END. +REM +REM Names the engine binds before every call: +REM SELF@ - this enemy's record (ENEMY): the state machine's memory +REM ACTOR@ - the engine's live actor (ACTOR): position is the real thing +REM GAME@ - shared frame state (GAME): player position, wave, randomness +REM +REM SELF@.STATE# bits: 1 = entering 2 = in formation 4 = diving +REM +REM Two rules of this dialect that bite here, both from docs/03: +REM - the LEFT operand decides integer or float arithmetic, so a float +REM always goes first: SELF@.T% * 150 + 260, never 260 + 150 * SELF@.T% +REM - RETURN at the start of a line ends the DEF body, so every early +REM return rides an IF ... THEN, and only the last RETURN starts a line + +REM Ease toward the formation slot, with a little entry swirl. +REM Answers 1 once the slot is reached, else 0. +DEF GLIDEHOME(DT%) + DX% = SELF@.HOMEX% - ACTOR@.X% + DY% = SELF@.HOMEY% - ACTOR@.Y% + K% = DT% * 4.5 + IF K% > 1 THEN K% = 1 + ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT% + ACTOR@.Y% = ACTOR@.Y% + DY% * K% + IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1 + RETURN 0 + +REM One frame of a dive: accelerate downward, weave, lean toward the +REM player's column, and glide back in from the top after falling out. +DEF DIVESTEP(DT%, WEAVE%, LEAD%) + SPD% = SELF@.T% * 150 + 260 + ACTOR@.Y% = ACTOR@.Y% + SPD% * DT% + ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT% + DX% = GAME@.PLAYERX% - ACTOR@.X% + IF DX% > 220 THEN DX% = 220 + IF DX% < -220 THEN DX% = -220 + ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT% + IF ACTOR@.Y% > 1040 THEN BEGIN + ACTOR@.Y% = 0.0 - 90 + SELF@.STATE# = 1 + SELF@.T% = 0 + BEND + RETURN 0 + +REM Raise the fire flag when diving roughly above the player. The engine +REM consumes FIRE# and does the spawning; the script only wishes. +DEF DECIDEFIRE(DT%) + DX% = GAME@.PLAYERX% - ACTOR@.X% + IF ABS(DX%) > 140 THEN RETURN 0 + IF ACTOR@.Y% > GAME@.PLAYERY% THEN RETURN 0 + IF SELF@.RND% < DT% * 1.5 THEN SELF@.FIRE# = 1 + RETURN 0 + +REM Bee: enter, breathe in formation, occasionally dive nearly straight. +DEF UPDATEBEE(DT%) + SELF@.T% = SELF@.T% + DT% + IF SELF@.T% < 0 THEN RETURN 0 + S# = SELF@.STATE# + IF (S# AND 1) > 0 THEN BEGIN + R# = GLIDEHOME(DT%) + IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0 + BEND + IF (S# AND 2) > 0 THEN BEGIN + ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.7) * 16 + ACTOR@.Y% = SELF@.HOMEY% + IF SELF@.RND% < DT% * 0.04 THEN SELF@.STATE# = 4 : SELF@.T% = 0 + BEND + IF (S# AND 4) > 0 THEN BEGIN + R# = DIVESTEP(DT%, 130, 0.2) + R# = DECIDEFIRE(DT%) + BEND + RETURN 0 + +REM Butterfly: the same machine with a wide lateral weave on the dive. +DEF UPDATEBFLY(DT%) + SELF@.T% = SELF@.T% + DT% + IF SELF@.T% < 0 THEN RETURN 0 + S# = SELF@.STATE# + IF (S# AND 1) > 0 THEN BEGIN + R# = GLIDEHOME(DT%) + IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0 + BEND + IF (S# AND 2) > 0 THEN BEGIN + ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 2.1) * 24 + ACTOR@.Y% = SELF@.HOMEY% + IF SELF@.RND% < DT% * 0.05 THEN SELF@.STATE# = 4 : SELF@.T% = 0 + BEND + IF (S# AND 4) > 0 THEN BEGIN + R# = DIVESTEP(DT%, 260, 0.1) + R# = DECIDEFIRE(DT%) + BEND + RETURN 0 + +REM Boss: two hit points, a slow sway, and a dive that leads the player. +REM At one hit point it raises actor state bit 13 (8192), and the engine's +REM character mapping swaps the sprite - the boundary crossed the other way. +DEF UPDATEBOSS(DT%) + SELF@.T% = SELF@.T% + DT% + IF SELF@.T% < 0 THEN RETURN 0 + IF SELF@.HP# = 1 THEN ACTOR@.STATE# = ACTOR@.STATE# OR 8192 + S# = SELF@.STATE# + IF (S# AND 1) > 0 THEN BEGIN + R# = GLIDEHOME(DT%) + IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0 + BEND + IF (S# AND 2) > 0 THEN BEGIN + ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.1) * 10 + ACTOR@.Y% = SELF@.HOMEY% + IF SELF@.RND% < DT% * 0.03 THEN SELF@.STATE# = 4 : SELF@.T% = 0 + BEND + IF (S# AND 4) > 0 THEN BEGIN + R# = DIVESTEP(DT%, 60, 0.9) + R# = DECIDEFIRE(DT%) + BEND + RETURN 0 + +END diff --git a/examples/galaga/galaga.h b/examples/galaga/galaga.h new file mode 100644 index 0000000..ff324f5 --- /dev/null +++ b/examples/galaga/galaga.h @@ -0,0 +1,155 @@ +/** + * @file galaga.h + * @brief Shared declarations for the GALAGA embedding example. + * + * The engine is C on libakgl; the enemies think in BASIC. Everything the two + * sides share crosses in exactly one place: the three structures below, which + * script.c registers as host types so a script reads and writes them directly. + * docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md build this + * program from an empty file; the split between files follows the split + * between chapters. + */ + +#ifndef _GALAGA_H_ +#define _GALAGA_H_ + +#include +#include + +#include + +#include + +/* ------------------------------------------------------------- geometry --- */ + +/* + * The view is sized to the artwork rather than the other way round: the Kenney + * sprites are ~100 pixels wide, libakgl has no way to draw a sprite smaller + * than it is (akgl_Actor.scale is overwritten every frame -- libakgl + * docs/12-actors.md), and a ten-column formation of them needs 1120 pixels. + */ +#define GALAGA_VIEW_WIDTH 1280 +#define GALAGA_VIEW_HEIGHT 960 + +#define GALAGA_FORM_COLUMNS 10 /* formation width, in slots */ +#define GALAGA_FORM_LEFT 136 /* x of column 0, map pixels */ +#define GALAGA_FORM_TOP 120 /* y of row 0, map pixels */ +#define GALAGA_COL_PITCH 112 +#define GALAGA_ROW_PITCH 100 + +#define GALAGA_PLAYER_Y 860.0f +#define GALAGA_PLAYER_SPEED 420.0f /* map pixels per second */ +#define GALAGA_PLAYER_MARGIN 60.0f /* how close to the edge it may go */ + +/* ------------------------------------------------------------- entities --- */ + +#define GALAGA_ENEMY_BEE 0 +#define GALAGA_ENEMY_BUTTERFLY 1 +#define GALAGA_ENEMY_BOSS 2 +#define GALAGA_ENEMY_KINDS 3 + +#define GALAGA_MAX_ENEMIES 40 +#define GALAGA_MAX_PLAYER_SHOTS 2 /* the classic two-on-screen rule */ +#define GALAGA_MAX_ENEMY_SHOTS 8 + +/* + * galaga_Enemy.state bits. The script owns these transitions; the engine only + * writes the word at spawn and when a script error forces an enemy dumb. + * galaga.bas spells the same three values as literals, with a REM naming them. + * + * 8 0 + * 0 0 0 0 0 1 1 1 + * | | `-- ENTERING: flying its entry path toward the formation slot + * | `---- FORMATION: holding (and breathing around) homex/homey + * `------ DIVING: attacking, off the grid until it leaves the screen + */ +#define GALAGA_ES_ENTERING (1 << 0) +#define GALAGA_ES_FORMATION (1 << 1) +#define GALAGA_ES_DIVING (1 << 2) + +/** @brief One enemy, as both sides see it. Hangs off akgl_Actor.actorData. */ +typedef struct galaga_Enemy +{ + int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */ + int32_t state; /* GALAGA_ES_* bit flags */ + float homex; /* formation slot, in map pixels */ + float homey; + float t; /* parametric clock for the current maneuver */ + int32_t hp; + int32_t fire; /* outbox: script sets 1, engine consumes */ + float rnd; /* inbox: engine writes fresh 0..1 each call */ +} galaga_Enemy; + +/** @brief Frame state every enemy may read. Bound once as GAME@. */ +typedef struct galaga_Shared +{ + float playerx; /* the player actor's position, this frame */ + float playery; + int32_t wave; + float rnd; /* fresh 0..1 each frame; the issue #16 route */ +} galaga_Shared; + +/* --------------------------------------------------------------- screens --- */ + +typedef enum +{ + GALAGA_SCREEN_TITLE = 0, + GALAGA_SCREEN_PLAY, + GALAGA_SCREEN_GAMEOVER, + GALAGA_SCREEN_VICTORY +} galaga_Screen; + +/* ------------------------------------------------------------ game state --- */ + +typedef struct galaga_Game +{ + galaga_Screen screen; + int frame; + float dt; /* seconds, clamped; see main.c */ + bool autoplay; + + int score; + int lives; + int kills[GALAGA_ENEMY_KINDS]; + int shots[GALAGA_ENEMY_KINDS]; /* shots each kind fired */ + int script_errors; + + akgl_Actor *player; + float fire_cooldown; + float respawn_timer; /* > 0 while the player is invulnerable */ + bool firing; + bool moveleft; + bool moveright; + + int player_shots_live; + int enemy_shots_live; +} galaga_Game; + +extern galaga_Game galaga_game; +extern galaga_Shared galaga_shared; + +extern galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES]; +extern akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES]; + +/* ---------------------------------------------------------------- script --- */ + +akerr_ErrorContext AKERR_NOIGNORE *galaga_script_boot(char *path); +akerr_ErrorContext AKERR_NOIGNORE *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt); + +/* --------------------------------------------------------------- enemies --- */ + +akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_spawn(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_release(void); +int galaga_enemies_alive(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_boom_spawn(float x, float y); + +/* ---------------------------------------------------------------- player --- */ + +akerr_ErrorContext AKERR_NOIGNORE *galaga_player_spawn(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_player_controls(void); +akerr_ErrorContext AKERR_NOIGNORE *galaga_player_autoplay(int frame); + +/** @brief A 0..1 random draw from the engine's own PRNG (see enemies.c). */ +float galaga_random(void); + +#endif // _GALAGA_H_ diff --git a/examples/galaga/interop_test.c b/examples/galaga/interop_test.c new file mode 100644 index 0000000..297947a --- /dev/null +++ b/examples/galaga/interop_test.c @@ -0,0 +1,265 @@ +/** + * @file interop_test.c + * @brief Round-trip test for the galaga boundary, hoststruct.c-style. + * + * Links the real script.c and the real galaga.bas -- not copies -- so this + * fails the moment the boundary and the script disagree. The four claims it + * pins: + * + * 1. The script writes the engine's actor memory: a formation enemy's sway + * lands in akgl_Actor.x with no marshalling step. + * 2. The outbox works: a diving enemy above the player raises FIRE# and the + * C side reads it. + * 3. The boss flips actor state bit 13 at one hit point -- the boundary + * crossed engine-ward. + * 4. Sustained calling holds: 24000 calls through the per-call + * akbasic_environment_zero() regime, the load a 40-enemy wave puts on + * the runtime in ten seconds. + * + * Exit status equals the number of failed claims. + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "galaga.h" + +#ifndef GALAGA_SCRIPT_PATH +#define GALAGA_SCRIPT_PATH "galaga.bas" +#endif + +/* script.c reads these; main.c usually defines them. This test is the host. */ +galaga_Game galaga_game; +galaga_Shared galaga_shared; + +static int FAILURES = 0; + +#define CLAIM(__cond, __text) \ + if ( !(__cond) ) { \ + fprintf(stderr, "FAILED: %s\n", __text); \ + FAILURES += 1; \ + } else { \ + printf("ok: %s\n", __text); \ + } + +static akerr_ErrorContext *run_claims(void) +{ + galaga_Enemy enemy; + akgl_Actor actor; + int i = 0; + PREPARE_ERROR(errctx); + + PASS(errctx, galaga_script_boot((char *)GALAGA_SCRIPT_PATH)); + + /* --- 1: formation sway lands in the actor ---------------------------- */ + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BEE; + enemy.state = GALAGA_ES_FORMATION; + enemy.homex = 400.0f; + enemy.homey = 300.0f; + enemy.hp = 1; + actor.x = 0.0f; + actor.y = 0.0f; + PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f)); + CLAIM((fabsf(actor.x - enemy.homex) <= 16.5f) && (actor.y == enemy.homey), + "a formation bee's sway is written into akgl_Actor.x/y by the script"); + + /* --- 2: the fire outbox ---------------------------------------------- */ + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BEE; + enemy.state = GALAGA_ES_DIVING; + enemy.rnd = 0.0f; /* 0.0 < DT% * 1.5: always willing */ + actor.x = 600.0f; + actor.y = 200.0f; + galaga_shared.playerx = 610.0f; /* just off the shot's column */ + galaga_shared.playery = 860.0f; /* well below */ + PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f)); + CLAIM(enemy.fire == 1, + "a diving bee above the player raises FIRE# for the engine to consume"); + + /* --- 3: the boss's hurt bit ------------------------------------------ */ + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BOSS; + enemy.state = GALAGA_ES_FORMATION; + enemy.homex = 500.0f; + enemy.homey = 120.0f; + enemy.hp = 1; + actor.state = AKGL_ACTOR_STATE_ALIVE; + PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f)); + CLAIM((actor.state & AKGL_ACTOR_STATE_UNDEFINED_13) != 0, + "a boss at one hit point raises actor state bit 13 from BASIC"); + + /* --- 4: sustained calling --------------------------------------------- */ + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BEE; + enemy.state = GALAGA_ES_FORMATION; + enemy.homex = 400.0f; + enemy.homey = 300.0f; + for ( i = 0; i < 24000; i++ ) { + enemy.rnd = 0.9f; /* never dive: keep the state put */ + PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f)); + } + CLAIM(galaga_game.script_errors == 0, + "24000 calls survive the per-call akbasic_environment_zero() regime"); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------ benchmark --- */ + +/** + * @brief UPDATEBEE's state machine, translated line for line into C. + * + * The native comparator for the benchmark below: the same guard, the same + * three branches, the same arithmetic as galaga.bas's UPDATEBEE with its + * helpers inlined. Nothing is simplified, so the timing difference is the + * interpreter's, not the algorithm's. + */ +static void native_updatebee(galaga_Enemy *enemy, akgl_Actor *actor, float dt) +{ + float dx = 0.0f; + float dy = 0.0f; + float k = 0.0f; + int32_t s = 0; + + enemy->t += dt; + if ( enemy->t < 0.0f ) { + return; + } + s = enemy->state; + if ( (s & GALAGA_ES_ENTERING) != 0 ) { + dx = enemy->homex - actor->x; + dy = enemy->homey - actor->y; + k = dt * 4.5f; + if ( k > 1.0f ) { + k = 1.0f; + } + actor->x += dx * k + sinf(enemy->t * 6.0f) * 90.0f * dt; + actor->y += dy * k; + if ( fabsf(dx) < 3.0f && fabsf(dy) < 3.0f ) { + enemy->state = GALAGA_ES_FORMATION; + enemy->t = 0.0f; + } + } + if ( (s & GALAGA_ES_FORMATION) != 0 ) { + actor->x = enemy->homex + sinf(enemy->t * 1.7f) * 16.0f; + actor->y = enemy->homey; + if ( enemy->rnd < dt * 0.04f ) { + enemy->state = GALAGA_ES_DIVING; + enemy->t = 0.0f; + } + } + if ( (s & GALAGA_ES_DIVING) != 0 ) { + actor->y += (enemy->t * 150.0f + 260.0f) * dt; + actor->x += sinf(enemy->t * 4.0f) * 130.0f * dt; + dx = galaga_shared.playerx - actor->x; + if ( dx > 220.0f ) { + dx = 220.0f; + } + if ( dx < -220.0f ) { + dx = -220.0f; + } + actor->x += dx * 0.2f * dt; + if ( actor->y > 1040.0f ) { + actor->y = -90.0f; + enemy->state = GALAGA_ES_ENTERING; + enemy->t = 0.0f; + } + dx = galaga_shared.playerx - actor->x; + if ( fabsf(dx) <= 140.0f && actor->y <= galaga_shared.playery + && enemy->rnd < dt * 1.5f ) { + enemy->fire = 1; + } + } +} + +static double seconds_since(const struct timespec *t0) +{ + struct timespec t1; + + clock_gettime(CLOCK_MONOTONIC, &t1); + return (double)(t1.tv_sec - t0->tv_sec) + (double)(t1.tv_nsec - t0->tv_nsec) / 1e9; +} + +/** + * @brief The cost of thinking in BASIC, measured against the same logic in C. + * + * Both loops run the identical formation-hold workload, 24,000 calls -- forty + * enemies at sixty frames a second for ten seconds. Informational: nothing + * asserts on the timing, because CI machines vary; the numbers print so the + * tutorial can quote a real measurement. + */ +static akerr_ErrorContext *run_benchmark(void) +{ + galaga_Enemy enemy; + akgl_Actor actor; + struct timespec t0; + double basic_s = 0.0; + double native_s = 0.0; + int i = 0; + PREPARE_ERROR(errctx); + + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BEE; + enemy.state = GALAGA_ES_FORMATION; + enemy.homex = 400.0f; + enemy.homey = 300.0f; + enemy.rnd = 0.9f; + + clock_gettime(CLOCK_MONOTONIC, &t0); + for ( i = 0; i < 24000; i++ ) { + PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f)); + } + basic_s = seconds_since(&t0); + + memset(&enemy, 0, sizeof(enemy)); + memset(&actor, 0, sizeof(actor)); + enemy.kind = GALAGA_ENEMY_BEE; + enemy.state = GALAGA_ES_FORMATION; + enemy.homex = 400.0f; + enemy.homey = 300.0f; + enemy.rnd = 0.9f; + + clock_gettime(CLOCK_MONOTONIC, &t0); + for ( i = 0; i < 24000; i++ ) { + native_updatebee(&enemy, &actor, 0.016f); + } + native_s = seconds_since(&t0); + + printf("benchmark: 24000 formation-hold updates, dt 0.016\n"); + printf(" BASIC through the boundary: %8.3f s %7.2f us/call %6.3f ms per 40-enemy frame\n", + basic_s, basic_s / 24000.0 * 1e6, basic_s / 24000.0 * 40.0 * 1e3); + printf(" the same logic in C: %8.3f s %7.2f us/call %6.3f ms per 40-enemy frame\n", + native_s, native_s / 24000.0 * 1e6, native_s / 24000.0 * 40.0 * 1e3); + printf(" ratio: %.0fx\n", basic_s / native_s); + SUCCEED_RETURN(errctx); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, run_claims()); + CATCH(errctx, run_benchmark()); + } CLEANUP { + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the interop test could not run"); + FAILURES += 1; + } FINISH_NORETURN(errctx); + + return FAILURES; +} diff --git a/examples/galaga/main.c b/examples/galaga/main.c new file mode 100644 index 0000000..25de084 --- /dev/null +++ b/examples/galaga/main.c @@ -0,0 +1,698 @@ +/** + * @file main.c + * @brief Startup, the frame loop, the screens, and teardown. + * + * The startup order is libakgl's one sequence that works (deps/libakgl + * include/akgl/game.h): metadata, akgl_game_init(), screen properties, + * akgl_render_2d_init(), a physics backend -- and then, new in this example, + * the interpreter. The scripts compute, the engine draws; the interpreter is + * lent no devices at all, so a script that tries SPRITE is refused by name. + */ + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "galaga.h" + +/** @brief Where the example's assets live. CMake defines it; `--assets` overrides. */ +#ifndef GALAGA_ASSET_DIR +#define GALAGA_ASSET_DIR "." +#endif + +/** @brief The enemy script. CMake defines it; `--script` overrides. */ +#ifndef GALAGA_SCRIPT_PATH +#define GALAGA_SCRIPT_PATH "galaga.bas" +#endif + +/** @brief The HUD font. CMake defines it; headless runs still need it for the UI. */ +#ifndef GALAGA_FONT_PATH +#define GALAGA_FONT_PATH "font.ttf" +#endif + +#define GALAGA_PATH_MAX 1024 + +galaga_Game galaga_game; +galaga_Shared galaga_shared; + +/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */ +static char *SHOTPATH = NULL; +static int SHOTFRAME = 0; + +/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */ +static int FAILED = 0; + +/* ------------------------------------------------------------- starfield --- */ + +/* + * No parallax facility exists in libakgl and none is needed: a fixed array of + * stars advanced per frame and drawn with akgl_draw_point() between + * frame_start and akgl_game_update(). Two speed bands give the depth for + * free -- the slow band reads as far away. + */ +#define GALAGA_STARS 96 + +static struct +{ + float x; + float y; + float speed; + Uint8 bright; +} STARS[GALAGA_STARS]; + +static void starfield_seed(void) +{ + int i = 0; + + for ( i = 0; i < GALAGA_STARS; i++ ) { + STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH; + STARS[i].y = galaga_random() * (float)GALAGA_VIEW_HEIGHT; + if ( (i % 2) == 0 ) { + STARS[i].speed = 40.0f; /* the far band */ + STARS[i].bright = 110; + } else { + STARS[i].speed = 110.0f; /* the near band */ + STARS[i].bright = 220; + } + } +} + +static akerr_ErrorContext *starfield_draw(void) +{ + SDL_Color color = { 255, 255, 255, 255 }; + int i = 0; + PREPARE_ERROR(errctx); + + for ( i = 0; i < GALAGA_STARS; i++ ) { + STARS[i].y += STARS[i].speed * galaga_game.dt; + if ( STARS[i].y > (float)GALAGA_VIEW_HEIGHT ) { + STARS[i].y -= (float)GALAGA_VIEW_HEIGHT; + STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH; + } + color.r = STARS[i].bright; + color.g = STARS[i].bright; + color.b = STARS[i].bright; + PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color)); + } + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------ screenshots --- */ + +/** + * @brief Read the render target back and write it out as a PNG. + * + * Called after everything has drawn and before the frame is presented, + * because SDL_RenderPresent is where the target stops being readable. The + * figures in docs/20 and docs/21 are output from this program rather than + * pictures somebody took once, so they cannot show a game that no longer + * exists. + */ +static akerr_ErrorContext *save_screenshot(char *path) +{ + SDL_Surface *shot = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError()); + ATTEMPT { + FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL, + "IMG_SavePNG(%s): %s", path, SDL_GetError()); + } CLEANUP { + SDL_DestroySurface(shot); + } PROCESS(errctx) { + } FINISH(errctx, true); + SDL_Log("Wrote %s", path); + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- assets --- */ + +static char *SPRITE_FILES[] = { + "sprite_galaga_player.json", + "sprite_galaga_bee.json", + "sprite_galaga_butterfly.json", + "sprite_galaga_boss.json", + "sprite_galaga_boss_hurt.json", + "sprite_galaga_playershot.json", + "sprite_galaga_enemyshot.json", + "sprite_galaga_boom.json", + NULL +}; + +static char *CHARACTER_FILES[] = { + "character_galaga_player.json", + "character_galaga_bee.json", + "character_galaga_butterfly.json", + "character_galaga_boss.json", + "character_galaga_playershot.json", + "character_galaga_enemyshot.json", + "character_galaga_boom.json", + NULL +}; + +static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size) +{ + int count = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir"); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Sprites first, characters second. Not a preference: a character's + * JSON names its sprites by registry name, so a character loaded first fails + * on the first sprite it cannot find. + */ +static akerr_ErrorContext *load_assets(char *assetdir) +{ + char path[GALAGA_PATH_MAX]; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); + for ( i = 0; SPRITE_FILES[i] != NULL; i++ ) { + PASS(errctx, asset_path(assetdir, SPRITE_FILES[i], (char *)&path, sizeof(path))); + PASS(errctx, akgl_sprite_load_json((char *)&path)); + } + for ( i = 0; CHARACTER_FILES[i] != NULL; i++ ) { + PASS(errctx, asset_path(assetdir, CHARACTER_FILES[i], (char *)&path, sizeof(path))); + PASS(errctx, akgl_character_load_json((char *)&path)); + } + SUCCEED_RETURN(errctx); +} + +/* --------------------------------------------------------------- startup --- */ + +/** @brief Replacement for akgl_game.lowfpsfunc, which logs a line per frame. */ +static void galaga_lowfps(void) +{ +} + +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name), + "akbasic galaga tutorial", sizeof(akgl_game.name) - 1)); + PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version), + "1.0.0", sizeof(akgl_game.version) - 1)); + PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri), + "net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &galaga_lowfps; + + /* Properties before the renderer: akgl_render_2d_init reads both, and an + * unset one defaults to the string "0" -- a zero-sized window. */ + PASS(errctx, akgl_set_property("game.screenwidth", "1280")); + PASS(errctx, akgl_set_property("game.screenheight", "960")); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + FAIL_ZERO_RETURN( + errctx, + SDL_SetRenderLogicalPresentation( + akgl_renderer->sdl_renderer, + GALAGA_VIEW_WIDTH, + GALAGA_VIEW_HEIGHT, + SDL_LOGICAL_PRESENTATION_INTEGER_SCALE), + AKGL_ERR_SDL, + "%s", + SDL_GetError() + ); + + /* The view is what the camera looks through, so it says the same thing. */ + akgl_camera->x = 0.0f; + akgl_camera->y = 0.0f; + akgl_camera->w = (float)GALAGA_VIEW_WIDTH; + akgl_camera->h = (float)GALAGA_VIEW_HEIGHT; + + /* + * akgl_game_init does NOT install a physics backend, whatever physics.h's + * file comment says (libakgl docs/14-physics.md). Null physics accepts + * every call and moves nothing: whatever writes x and y directly is the + * mover, and in this game that is BASIC writing through ACTOR@. + */ + PASS(errctx, akgl_physics_init_null(akgl_physics)); + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- the UI --- */ + +static akgl_UiMenu TITLE_MENU = { + "titlemenu", { "START", "QUIT" }, 2, 0, false, NULL +}; +static akgl_UiMenu AGAIN_MENU = { + "againmenu", { "PLAY AGAIN", "QUIT" }, 2, 0, false, NULL +}; + +/* Clay borrows label text until frame_end, so these cannot be locals. */ +static char HUD_SCORE[64]; +static char HUD_LIVES[64]; + +/** + * @brief Draw a headline centred above the menu, in the banner font. + * + * Direct text rather than a ui label: the menu owns AKGL_UI_ANCHOR_CENTER, + * and a label anchored there disappears behind it -- there is no + * top-centre anchor to reach for. Drawn before the UI bracket, so the menu + * still paints over it if the two ever meet. + */ +static akerr_ErrorContext *draw_banner(char *text) +{ + SDL_Color ink = { 235, 235, 235, 255 }; + TTF_Font *font = NULL; + int w = 0; + int h = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text"); + font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL); + FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded"); + PASS(errctx, akgl_text_measure(font, text, &w, &h)); + PASS(errctx, akgl_text_rendertextat(font, text, ink, 0, + (GALAGA_VIEW_WIDTH - w) / 2, 280)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *declare_title(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, akgl_ui_menu(&TITLE_MENU)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *declare_play(void) +{ + int count = 0; + PREPARE_ERROR(errctx); + + PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE), + "SCORE %06d", galaga_game.score)); + PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES), + "LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave)); + PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *declare_end(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, akgl_ui_menu(&AGAIN_MENU)); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------ transitions --- */ + +static akerr_ErrorContext *start_game(void) +{ + PREPARE_ERROR(errctx); + + galaga_game.score = 0; + galaga_game.lives = 3; + memset(galaga_game.kills, 0, sizeof(galaga_game.kills)); + memset(galaga_game.shots, 0, sizeof(galaga_game.shots)); + galaga_game.respawn_timer = 0.0f; + galaga_shared.wave = 1; + + galaga_game.player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f; + PASS(errctx, galaga_wave_spawn()); + galaga_game.screen = GALAGA_SCREEN_PLAY; + SUCCEED_RETURN(errctx); +} + +/** + * @brief End-of-round bookkeeping: notice a cleared wave or a spent ship. + */ +static akerr_ErrorContext *check_transitions(bool *running) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); + (void)running; + if ( galaga_game.screen != GALAGA_SCREEN_PLAY ) { + SUCCEED_RETURN(errctx); + } + if ( galaga_game.lives <= 0 ) { + PASS(errctx, galaga_wave_release()); + galaga_game.screen = GALAGA_SCREEN_GAMEOVER; + SUCCEED_RETURN(errctx); + } + if ( galaga_enemies_alive() == 0 ) { + galaga_game.screen = GALAGA_SCREEN_VICTORY; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Consume a menu activation. The menu never clears `activated`; the + * state machine that acts on it does. + */ +static akerr_ErrorContext *consume_menus(bool *running) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); + if ( galaga_game.screen == GALAGA_SCREEN_TITLE && TITLE_MENU.activated ) { + TITLE_MENU.activated = false; + if ( TITLE_MENU.selected == 0 ) { + PASS(errctx, start_game()); + } else { + *running = false; + } + } + if ( (galaga_game.screen == GALAGA_SCREEN_GAMEOVER + || galaga_game.screen == GALAGA_SCREEN_VICTORY) + && AGAIN_MENU.activated ) { + AGAIN_MENU.activated = false; + if ( AGAIN_MENU.selected == 0 ) { + PASS(errctx, galaga_wave_release()); + PASS(errctx, start_game()); + } else { + *running = false; + } + } + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------- the frame --- */ + +/** + * @brief Route one event: the UI gets first refusal, then the menus, then + * the controller. A consumed event goes no further. + */ +static akerr_ErrorContext *route_event(SDL_Event *event, bool *running) +{ + bool consumed = false; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); + + if ( event->type == SDL_EVENT_QUIT ) { + *running = false; + SUCCEED_RETURN(errctx); + } + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) { + PASS(errctx, akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed)); + } else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER + || galaga_game.screen == GALAGA_SCREEN_VICTORY ) { + PASS(errctx, akgl_ui_menu_handle_event(&AGAIN_MENU, event, &consumed)); + } + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + /* Every event, unconditionally: one that no control map binds is not an + * error, it is a call that did nothing. */ + PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, event)); + SUCCEED_RETURN(errctx); +} + +/** @brief The previous frame's timestamp, for dt. Stamped once at loop start. */ +static uint64_t LAST_NS = 0; + +static akerr_ErrorContext *frame(bool *running) +{ + SDL_Event event; + uint64_t now = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running"); + + while ( SDL_PollEvent(&event) == true ) { + PASS(errctx, route_event(&event, running)); + } + + galaga_game.frame += 1; + if ( galaga_game.autoplay ) { + if ( galaga_game.screen == GALAGA_SCREEN_TITLE && galaga_game.frame >= 8 ) { + PASS(errctx, start_game()); + } + /* + * On an end screen the pilot presses Return, which drives the real + * menu path -- declare, handle, activate, restart -- so a headless + * run that dies keeps exercising the game instead of idling. + */ + if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER + || galaga_game.screen == GALAGA_SCREEN_VICTORY ) { + if ( (galaga_game.frame % 30) == 0 ) { + SDL_Event press; + memset(&press, 0, sizeof(press)); + press.type = SDL_EVENT_KEY_DOWN; + press.key.key = SDLK_RETURN; + PASS(errctx, route_event(&press, running)); + } + } + PASS(errctx, galaga_player_autoplay(galaga_game.frame)); + } + + /* + * dt from the wall clock, clamped: a debugger pause or a stalled runner + * must not become one frame of teleporting enemies. The clamp is a 30 Hz + * frame, the slowest game this is still worth playing at. + */ + now = SDL_GetTicksNS(); + galaga_game.dt = (float)(now - LAST_NS) / 1e9f; + LAST_NS = now; + if ( galaga_game.dt > (1.0f / 30.0f) ) { + galaga_game.dt = 1.0f / 30.0f; + } + + /* The shared frame state, refreshed before any enemy thinks. The engine + * filling GAME@.RND% is the issue #16 route: no RND verb exists. */ + galaga_shared.playerx = galaga_game.player->x + 50.0f; + galaga_shared.playery = galaga_game.player->y; + galaga_shared.rnd = galaga_random(); + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + PASS(errctx, starfield_draw()); + + /* + * akgl_game_update is update-every-actor, step-the-physics, draw-the- + * world. Updating every actor is where the forty scripts run: each + * enemy's updatefunc is the hook in enemies.c, and that hook is a BASIC + * call. Held back on the menu screens so the world stands still there. + */ + if ( galaga_game.screen == GALAGA_SCREEN_PLAY ) { + PASS(errctx, akgl_game_update(NULL)); + PASS(errctx, check_transitions(running)); + } + + /* The banner is direct text, drawn before the UI bracket. */ + if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) { + PASS(errctx, draw_banner("GALAGA")); + } else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER ) { + PASS(errctx, draw_banner("GAME OVER")); + } else if ( galaga_game.screen == GALAGA_SCREEN_VICTORY ) { + PASS(errctx, draw_banner("VICTORY")); + } + + /* The UI bracket sits between akgl_game_update and frame_end, exactly as + * libakgl docs/22-ui.md draws it. */ + PASS(errctx, akgl_ui_frame_begin()); + switch ( galaga_game.screen ) { + case GALAGA_SCREEN_TITLE: + PASS(errctx, declare_title()); + break; + case GALAGA_SCREEN_PLAY: + PASS(errctx, declare_play()); + break; + case GALAGA_SCREEN_GAMEOVER: + case GALAGA_SCREEN_VICTORY: + PASS(errctx, declare_end()); + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + + PASS(errctx, consume_menus(running)); + + if ( (SHOTPATH != NULL) && (galaga_game.frame == SHOTFRAME) ) { + PASS(errctx, save_screenshot(SHOTPATH)); + } + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *run(int frames) +{ + bool running = true; + PREPARE_ERROR(errctx); + + LAST_NS = SDL_GetTicksNS(); + while ( running == true ) { + PASS(errctx, frame(&running)); + if ( (frames > 0) && (galaga_game.frame >= frames) ) { + running = false; + } + /* A crude frame limiter. A game on a real display should ask SDL for + * vsync; this one has to work under the dummy video driver, where + * there is nothing to sync to. */ + SDL_Delay(16); + } + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- teardown --- */ + +/** + * @brief Give back what the process is holding. + * + * There is no akgl_game_shutdown; teardown is the application's. Fonts have + * to unload before TTF_Quit destroys them underneath the registry. IGNORE() + * on every call: a teardown failure must not mask whatever error is already + * being reported. + */ +static void shutdown_game(void) +{ + int i = 0; + + IGNORE(akgl_ui_shutdown()); + IGNORE(akgl_text_unloadallfonts()); + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + if ( akgl_heap_actors[i].refcount > 0 ) { + IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i])); + } + } + TTF_Quit(); + SDL_Quit(); +} + +/* ------------------------------------------------------------------ args --- */ + +static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, + char **script, int *frames) +{ + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir"); + FAIL_ZERO_RETURN(errctx, script, AKERR_NULLPOINTER, "script"); + FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames"); + + for ( i = 1; i < argc; i++ ) { + if ( strcmp(argv[i], "--autoplay") == 0 ) { + galaga_game.autoplay = true; + } else if ( strcmp(argv[i], "--frames") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count"); + PASS(errctx, aksl_atoi(argv[i], frames)); + } else if ( strcmp(argv[i], "--assets") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory"); + *assetdir = argv[i]; + } else if ( strcmp(argv[i], "--script") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--script needs a path"); + *script = argv[i]; + } else if ( strcmp(argv[i], "--screenshot") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot needs a path"); + SHOTPATH = argv[i]; + } else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) { + i += 1; + FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, + "--screenshot-frame needs a number"); + PASS(errctx, aksl_atoi(argv[i], &SHOTFRAME)); + } else { + FAIL_RETURN( + errctx, + AKERR_VALUE, + "usage: galaga [--assets DIR] [--script PATH] [--frames N]" + " [--autoplay] [--screenshot PATH] [--screenshot-frame N]" + ); + } + } + SUCCEED_RETURN(errctx); +} + +int main(int argc, char *argv[]) +{ + char *assetdir = GALAGA_ASSET_DIR; + char *script = GALAGA_SCRIPT_PATH; + int frames = 0; + uint16_t fontid = 0; + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv, &assetdir, &script, &frames)); + CATCH(errctx, startup()); + CATCH(errctx, load_assets(assetdir)); + /* The engine refuses to start when the script will not boot: a game + * whose enemies cannot think is not a game missing a feature. */ + CATCH(errctx, galaga_script_boot(script)); + CATCH(errctx, akgl_ui_init(GALAGA_VIEW_WIDTH, GALAGA_VIEW_HEIGHT)); + CATCH(errctx, akgl_text_loadfont("hud", GALAGA_FONT_PATH, 28)); + CATCH(errctx, akgl_text_loadfont("banner", GALAGA_FONT_PATH, 84)); + CATCH(errctx, akgl_ui_font_register("hud", &fontid)); + CATCH(errctx, galaga_player_spawn()); + CATCH(errctx, galaga_player_controls()); + starfield_seed(); + galaga_game.screen = GALAGA_SCREEN_TITLE; + galaga_game.lives = 3; + CATCH(errctx, run(frames)); + } CLEANUP { + shutdown_game(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run"); + /* Set a flag rather than returning: leaving a HANDLE block early + * skips FINISH's RELEASE_ERROR and leaks the context's pool slot. */ + FAILED = 1; + /* FINISH_NORETURN rather than FINISH: FINISH expands a return that an + * int-returning function cannot compile. */ + } FINISH_NORETURN(errctx); + + /* + * The readout is the evidence: exiting 0 is not proof the wave flew. A + * headless CI log gets the same line a reader's terminal does, and the + * script-error count is the line's whole reason to exist -- a wave of + * dumb enemies still exits 0. + */ + SDL_Log( + "galaga: %d frames, screen %d, score %d, alive %d, kills bee %d bfly %d boss %d," + " shots bee %d bfly %d boss %d, script errors %d", + galaga_game.frame, + (int)galaga_game.screen, + galaga_game.score, + galaga_enemies_alive(), + galaga_game.kills[GALAGA_ENEMY_BEE], + galaga_game.kills[GALAGA_ENEMY_BUTTERFLY], + galaga_game.kills[GALAGA_ENEMY_BOSS], + galaga_game.shots[GALAGA_ENEMY_BEE], + galaga_game.shots[GALAGA_ENEMY_BUTTERFLY], + galaga_game.shots[GALAGA_ENEMY_BOSS], + galaga_game.script_errors); + return FAILED; +} diff --git a/examples/galaga/player.c b/examples/galaga/player.c new file mode 100644 index 0000000..f15ba65 --- /dev/null +++ b/examples/galaga/player.c @@ -0,0 +1,407 @@ +/** + * @file player.c + * @brief The player's ship, its shots, and every collision in the game. + * + * Bullets and collision are C forever -- they are engine, not behavior. The + * per-frame budget for the script is spent on the forty things that think; + * nothing here thinks, it just moves and intersects. + */ + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "galaga.h" + +/* Points per kill, indexed by enemy kind. */ +static const int KILL_SCORE[GALAGA_ENEMY_KINDS] = { + /* bee butterfly boss */ + 50, 80, 150 +}; + +static uint32_t PSHOT_SERIAL = 0; + +/* -------------------------------------------------------------- hitboxes --- */ + +/* + * Actor x/y is a sprite's top-left corner. Every box is inset from the + * artwork's rectangle, because the PNGs carry transparent margin and wing + * tips that should not kill anybody. + */ +static void player_box(akgl_Actor *actor, SDL_FRect *dest) +{ + dest->x = actor->x + 12.0f; + dest->y = actor->y + 8.0f; + dest->w = 75.0f; + dest->h = 60.0f; +} + +static void enemy_box(akgl_Actor *actor, SDL_FRect *dest) +{ + dest->x = actor->x + 8.0f; + dest->y = actor->y + 8.0f; + dest->w = 78.0f; + dest->h = 68.0f; +} + +static void shot_box(akgl_Actor *actor, SDL_FRect *dest) +{ + dest->x = actor->x; + dest->y = actor->y; + dest->w = 9.0f; + dest->h = 54.0f; +} + +/* ---------------------------------------------------------- player shots --- */ + +/** + * @brief Kill one enemy: score it, blow it up, free its slot. + */ +static akerr_ErrorContext *kill_enemy(int index) +{ + akgl_Actor *actor = NULL; + galaga_Enemy *enemy = NULL; + PREPARE_ERROR(errctx); + + FAIL_NONZERO_RETURN(errctx, (index < 0 || index >= GALAGA_MAX_ENEMIES), + AKERR_OUTOFBOUNDS, "enemy index %d", index); + actor = galaga_enemy_actors[index]; + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "enemy %d is already gone", index); + enemy = &galaga_enemies[index]; + + galaga_game.score += KILL_SCORE[enemy->kind]; + galaga_game.kills[enemy->kind] += 1; + PASS(errctx, galaga_boom_spawn(actor->x + 20.0f, actor->y + 20.0f)); + PASS(errctx, akgl_heap_release_actor(actor)); + galaga_enemy_actors[index] = NULL; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Move a player shot and test it against every live enemy. + * + * The classic O(shots x enemies) sweep: at most 2 x 40 rectangle tests a + * frame, which is noise. A hit costs the enemy a point of hp; the boss's + * second point is the script's business to survive, not this file's. + */ +static akerr_ErrorContext *player_shot_update(akgl_Actor *obj) +{ + SDL_FRect mine; + SDL_FRect theirs; + bool hit = false; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + obj->y -= 900.0f * galaga_game.dt; + if ( obj->y < -60.0f ) { + galaga_game.player_shots_live -= 1; + PASS(errctx, akgl_heap_release_actor(obj)); + SUCCEED_RETURN(errctx); + } + + shot_box(obj, &mine); + for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) { + if ( galaga_enemy_actors[i] == NULL ) { + continue; + } + enemy_box(galaga_enemy_actors[i], &theirs); + PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit)); + if ( !hit ) { + continue; + } + galaga_enemies[i].hp -= 1; + if ( galaga_enemies[i].hp <= 0 ) { + PASS(errctx, kill_enemy(i)); + } + galaga_game.player_shots_live -= 1; + PASS(errctx, akgl_heap_release_actor(obj)); + SUCCEED_RETURN(errctx); + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *player_fire(akgl_Actor *player) +{ + akgl_Actor *shot = NULL; + char name[32]; + int count = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player"); + + PSHOT_SERIAL += 1; + PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "pshot%u", PSHOT_SERIAL)); + PASS(errctx, akgl_heap_next_actor(&shot)); + PASS(errctx, akgl_actor_initialize(shot, name)); + PASS(errctx, akgl_actor_set_character(shot, "galaga_playershot")); + /* AFTER initialize: it resets all seven hooks. */ + shot->updatefunc = &player_shot_update; + shot->movement_controls_face = false; + shot->state = AKGL_ACTOR_STATE_ALIVE; + shot->visible = true; + shot->x = player->x + 45.0f; + shot->y = player->y - 44.0f; + + galaga_game.player_shots_live += 1; + galaga_game.fire_cooldown = 0.22f; + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------- player hit --- */ + +static akerr_ErrorContext *player_hit(akgl_Actor *player) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player"); + galaga_game.lives -= 1; + galaga_game.respawn_timer = 2.0f; + PASS(errctx, galaga_boom_spawn(player->x + 25.0f, player->y + 10.0f)); + player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f; + SUCCEED_RETURN(errctx); +} + +/** + * @brief The player's own update hook: motion, fire, and what can kill it. + */ +static akerr_ErrorContext *player_update(akgl_Actor *obj) +{ + SDL_FRect mine; + SDL_FRect theirs; + bool hit = false; + float dx = 0.0f; + int i = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj"); + + dx = 0.0f; + if ( galaga_game.moveleft ) { + dx -= GALAGA_PLAYER_SPEED; + } + if ( galaga_game.moveright ) { + dx += GALAGA_PLAYER_SPEED; + } + obj->x += dx * galaga_game.dt; + if ( obj->x < GALAGA_PLAYER_MARGIN ) { + obj->x = GALAGA_PLAYER_MARGIN; + } + if ( obj->x > (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f ) { + obj->x = (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f; + } + + galaga_game.fire_cooldown -= galaga_game.dt; + if ( galaga_game.firing + && galaga_game.fire_cooldown <= 0.0f + && galaga_game.player_shots_live < GALAGA_MAX_PLAYER_SHOTS + && galaga_game.screen == GALAGA_SCREEN_PLAY ) { + PASS(errctx, player_fire(obj)); + } + + /* + * Respawn grace: two seconds of blinking invulnerability. The blink is + * the `visible` flag, which is deliberate hiding -- the actor still + * updates, it just is not drawn on the off frames. + */ + if ( galaga_game.respawn_timer > 0.0f ) { + galaga_game.respawn_timer -= galaga_game.dt; + obj->visible = ((galaga_game.frame / 6) % 2) == 0; + SUCCEED_RETURN(errctx); + } + obj->visible = true; + + player_box(obj, &mine); + + /* Enemy shots. */ + for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) { + if ( akgl_heap_actors[i].refcount == 0 ) { + continue; + } + if ( strncmp(akgl_heap_actors[i].name, "eshot", 5) != 0 ) { + continue; + } + shot_box(&akgl_heap_actors[i], &theirs); + PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit)); + if ( hit ) { + galaga_game.enemy_shots_live -= 1; + PASS(errctx, akgl_heap_release_actor(&akgl_heap_actors[i])); + PASS(errctx, player_hit(obj)); + SUCCEED_RETURN(errctx); + } + } + + /* Diving enemies. The formation never reaches this low, so testing all + * forty is the same answer as testing the divers, without a state read. */ + for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) { + if ( galaga_enemy_actors[i] == NULL ) { + continue; + } + enemy_box(galaga_enemy_actors[i], &theirs); + PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit)); + if ( hit ) { + PASS(errctx, kill_enemy(i)); + PASS(errctx, player_hit(obj)); + SUCCEED_RETURN(errctx); + } + } + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- controls --- */ + +static akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.moveleft = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.moveleft = false; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *right_on(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.moveright = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *right_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.moveright = false; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *fire_on(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.firing = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *fire_off(akgl_Actor *obj, SDL_Event *event) +{ + PREPARE_ERROR(errctx); + (void)obj; (void)event; + galaga_game.firing = false; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_player_controls(void) +{ + akgl_Control control; + PREPARE_ERROR(errctx); + + memset(&control, 0, sizeof(control)); + control.event_on = SDL_EVENT_KEY_DOWN; + control.event_off = SDL_EVENT_KEY_UP; + + control.key = SDLK_LEFT; + control.handler_on = &left_on; + control.handler_off = &left_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + control.key = SDLK_RIGHT; + control.handler_on = &right_on; + control.handler_off = &right_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + control.key = SDLK_SPACE; + control.handler_on = &fire_on; + control.handler_off = &fire_off; + PASS(errctx, akgl_controller_pushmap(0, &control)); + + akgl_controlmaps[0].target = galaga_game.player; + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------------- spawn --- */ + +akerr_ErrorContext *galaga_player_spawn(void) +{ + akgl_Actor *player = NULL; + PREPARE_ERROR(errctx); + + PASS(errctx, akgl_heap_next_actor(&player)); + PASS(errctx, akgl_actor_initialize(player, "player")); + PASS(errctx, akgl_actor_set_character(player, "galaga_player")); + /* AFTER initialize: it resets all seven hooks. */ + player->updatefunc = &player_update; + player->movement_controls_face = false; + player->state = AKGL_ACTOR_STATE_ALIVE; + player->visible = true; + player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f; + player->y = GALAGA_PLAYER_Y; + + galaga_game.player = player; + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- autoplay --- */ + +/** + * @brief Send one synthetic key event through the controller. + * + * Through akgl_controller_handle_event(), never the handlers directly: the + * point of autoplay is to exercise the same path a keyboard does. + */ +static akerr_ErrorContext *synth_key(SDL_Keycode key, bool down) +{ + SDL_Event event; + PREPARE_ERROR(errctx); + + memset(&event, 0, sizeof(event)); + event.type = (down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP); + event.key.key = key; + PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief The scripted pilot for headless runs: hold fire, sweep the floor. + */ +akerr_ErrorContext *galaga_player_autoplay(int frame) +{ + int phase = 0; + PREPARE_ERROR(errctx); + + /* Hold fire until the wave has mostly assembled: shooting the entry + * stream point-blank empties the formation before it exists, which makes + * both the game and its figure worse. */ + if ( frame == 300 ) { + PASS(errctx, synth_key(SDLK_SPACE, true)); + } + phase = frame % 240; + if ( phase == 30 ) { + PASS(errctx, synth_key(SDLK_LEFT, true)); + } else if ( phase == 90 ) { + PASS(errctx, synth_key(SDLK_LEFT, false)); + PASS(errctx, synth_key(SDLK_RIGHT, true)); + } else if ( phase == 210 ) { + PASS(errctx, synth_key(SDLK_RIGHT, false)); + } + SUCCEED_RETURN(errctx); +} diff --git a/examples/galaga/script.c b/examples/galaga/script.c new file mode 100644 index 0000000..b588430 --- /dev/null +++ b/examples/galaga/script.c @@ -0,0 +1,281 @@ +/** + * @file script.c + * @brief The boundary: everything that touches the interpreter lives here. + * + * One runtime, one script, three host types, three bindings. The engine calls + * exactly one thing per enemy per frame -- galaga_script_update_enemy() -- and + * that function is the whole protocol: rebind, call, recover, reset. + * + * The structure types are declared once, in C, right below. The script never + * declares a TYPE of its own; akbasic_host_register_type() makes these structs + * *be* the BASIC types, offsets taken from offsetof() so the two sides cannot + * drift (include/akbasic/host.h). + */ + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include + +#include "galaga.h" + +/* The interpreter. Static because an akbasic_Runtime is far too big for a + * stack frame -- 2.40 MiB on this branch. */ +static akbasic_Runtime SCRIPT; +static akbasic_TextSink SINK; +static akbasic_StdioSink SINKSTATE; + +/** @brief Longest galaga.bas this loader will accept. */ +#define GALAGA_MAX_SCRIPT_BYTES 16384 + +static char SOURCE[GALAGA_MAX_SCRIPT_BYTES]; + +/* + * Enemy kind -> BASIC function name. Dispatch is a table, not a conditional: + * adding a kind is one row here and one DEF in galaga.bas. + */ +static const char *UPDATE_FUNCTION[GALAGA_ENEMY_KINDS] = { + "UPDATEBEE", /* GALAGA_ENEMY_BEE */ + "UPDATEBFLY", /* GALAGA_ENEMY_BUTTERFLY */ + "UPDATEBOSS" /* GALAGA_ENEMY_BOSS */ +}; + +/* ---------------------------------------------------------- host types --- */ + +static const akbasic_HostField ENEMY_FIELDS[] = { + /* struct member BASIC name C representation */ + AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT ) +}; +static const akbasic_HostType ENEMY_TYPE = { + "ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8 +}; + +/* + * The live actor itself. This is the demonstrative point of the whole + * example: the script writes the engine's *real* actor memory -- the same x + * the renderer reads -- with no copy in either direction. + */ +static const akbasic_HostField ACTOR_FIELDS[] = { + AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL ) +}; +static const akbasic_HostType ACTOR_TYPE = { + "ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4 +}; + +static const akbasic_HostField GAME_FIELDS[] = { + AKBASIC_HOST_FIELD( galaga_Shared, playerx, "PLAYERX%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( galaga_Shared, playery, "PLAYERY%", AKBASIC_HOSTFIELD_FLOAT ), + AKBASIC_HOST_FIELD( galaga_Shared, wave, "WAVE#", AKBASIC_HOSTFIELD_INT32 ), + AKBASIC_HOST_FIELD( galaga_Shared, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT ) +}; +static const akbasic_HostType GAME_TYPE = { + "GAME", sizeof(galaga_Shared), GAME_FIELDS, 4 +}; + +/* Placeholders the boot bindings point at until the first real rebind. A + * binding is borrowed, never copied, so these must be static storage. */ +static galaga_Enemy SCRATCH_ENEMY; +static akgl_Actor SCRATCH_ACTOR; + +/* ---------------------------------------------------------------- boot --- */ + +static akerr_ErrorContext *read_script(char *path) +{ + FILE *fp = NULL; + size_t got = 0; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path"); + fp = fopen(path, "rb"); + FAIL_ZERO_RETURN(errctx, fp, AKERR_IO, "Cannot open the enemy script %s", path); + ATTEMPT { + got = fread(SOURCE, 1, sizeof(SOURCE) - 1, fp); + SOURCE[got] = '\0'; + FAIL_NONZERO_BREAK(errctx, (got >= sizeof(SOURCE) - 1), AKERR_OUTOFBOUNDS, + "%s does not fit in the %d byte script buffer", + path, GALAGA_MAX_SCRIPT_BYTES); + } CLEANUP { + fclose(fp); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief One dry call of every dispatch-table function, at boot. + * + * A missing or misspelled DEF fails here, at startup, with the function's name + * in the message -- not on frame one of the first wave. The scratch enemy's + * state word is zero, so no maneuver block runs and nothing moves. + */ +static akerr_ErrorContext *dry_run(void) +{ + akbasic_Value dt; + akbasic_Value *argp[1]; + akbasic_Value *result = NULL; + int i = 0; + PREPARE_ERROR(errctx); + + memset(&SCRATCH_ENEMY, 0, sizeof(SCRATCH_ENEMY)); + memset(&dt, 0, sizeof(dt)); + dt.valuetype = AKBASIC_TYPE_FLOAT; + dt.floatval = 0.0; + argp[0] = &dt; + + for ( i = 0; i < GALAGA_ENEMY_KINDS; i++ ) { + PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", &SCRATCH_ENEMY)); + PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", &SCRATCH_ACTOR)); + PASS(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[i], + argp, 1, &result)); + /* + * A body that died reports through the sink and answers zero; the + * dropped mode is the only signal C gets. At boot that must be fatal + * and must say which function -- not frame one of the first wave. + */ + FAIL_NONZERO_RETURN(errctx, (SCRIPT.mode != AKBASIC_MODE_RUN), AKERR_VALUE, + "%s died during the boot dry run; the interpreter's report" + " is above", UPDATE_FUNCTION[i]); + PASS(errctx, akbasic_environment_zero(SCRIPT.environment)); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *galaga_script_boot(char *path) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path"); + + PASS(errctx, akbasic_error_register()); + PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL)); + PASS(errctx, akbasic_runtime_init(&SCRIPT, &SINK)); + + PASS(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE)); + PASS(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE)); + PASS(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE)); + + PASS(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY)); + PASS(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR)); + PASS(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared)); + + PASS(errctx, read_script(path)); + PASS(errctx, akbasic_runtime_load(&SCRIPT, SOURCE)); + + /* + * A "no top level code" script still has to run once: executing the DEF + * statements is what files the functions. The run is bounded because a + * script that is all definitions has no business taking more than a step + * per line, and an accidental loop at boot should be a diagnosis, not a + * hang. + */ + PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN)); + PASS(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES)); + + /* + * The program has now ended and the runtime sits in QUIT mode, where a + * multi-line DEF called from the host returns a silent zero. Forcing the + * mode back makes the bodies run, and it stays put because nothing here + * ever steps the runtime again. Issue #8 tracks making this unnecessary. + */ + PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)); + + PASS(errctx, dry_run()); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------ per frame --- */ + +akerr_ErrorContext *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt) +{ + akbasic_Value dtval; + akbasic_Value *argp[1]; + akbasic_Value *result = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy"); + FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor"); + FAIL_NONZERO_RETURN(errctx, (enemy->kind < 0 || enemy->kind >= GALAGA_ENEMY_KINDS), + AKERR_VALUE, "Enemy kind %d has no update function", enemy->kind); + + PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy)); + PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor)); + + memset(&dtval, 0, sizeof(dtval)); + dtval.valuetype = AKBASIC_TYPE_FLOAT; + dtval.floatval = (double)dt; + argp[0] = &dtval; + + /* + * An error in an enemy's function is that script's problem, not the + * engine's: the enemy goes dumb -- cleared to a formation hold it will + * never leave -- and the frame lives. HANDLE_DEFAULT absorbs whatever the + * interpreter raised; the first failure is logged with the function's + * name, the rest are counted, because sixty a second of the same message + * is how a log stops being read. + */ + ATTEMPT { + CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[enemy->kind], + argp, 1, &result)); + } CLEANUP { + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + if ( galaga_game.script_errors == 0 ) { + LOG_ERROR_WITH_MESSAGE(errctx, "first script error; this enemy is now dumb"); + } + galaga_game.script_errors += 1; + enemy->state = GALAGA_ES_FORMATION; + enemy->fire = 0; + } FINISH(errctx, true); + + /* + * A BASIC-level error in the body is quieter than an interpreter error: + * it reports through the sink, the call answers a stale value, and the + * runtime falls out of RUN mode -- after which every later call is a + * silent no-op. The mode is the tell. Revival is two calls: + * clear_error(), because a run's first error latches and every line is + * skipped while it stands, and the same set_mode(RUN) the boot needed + * (issue #8's mechanics). The enemy is treated exactly like the + * interpreter-error case above. + */ + if ( SCRIPT.mode != AKBASIC_MODE_RUN ) { + if ( galaga_game.script_errors == 0 ) { + SDL_Log("first script error (reported by the interpreter above);" + " enemy %s is now dumb", UPDATE_FUNCTION[enemy->kind]); + } + galaga_game.script_errors += 1; + enemy->state = GALAGA_ES_FORMATION; + enemy->fire = 0; + PASS(errctx, akbasic_runtime_clear_error(&SCRIPT)); + PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)); + } + + /* + * Load-bearing: akbasic_runtime_call_function() parks every result in the + * caller environment's per-line value scratch, and a host calling in a + * loop never crosses the line boundary that would reset it. Without this + * the pool drains in under two frames of a 40-enemy wave. + */ + PASS(errctx, akbasic_environment_zero(SCRIPT.environment)); + SUCCEED_RETURN(errctx); +} diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 8de41fa..592acc5 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -632,6 +632,27 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_println(akbasic_Runtime *obj, */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_set_mode(akbasic_Runtime *obj, int mode); +/** + * @brief Forgive the last BASIC-level error, so a host may call again. + * + * A program run latches its first runtime error and ends -- deliberately, and + * a host cannot un-decide that with akbasic_runtime_set_mode() alone: the + * latch survives the mode change, every later line is skipped, and every + * later akbasic_runtime_call_function() answers a stale value after walking + * the whole source table doing nothing. + * + * A host that absorbed a script error -- reported through the sink, actor + * marked dumb, frame preserved -- calls this beside + * `akbasic_runtime_set_mode(obj, AKBASIC_MODE_RUN)` to put the runtime back + * in service. It is for hosts between calls, not for verbs during a run: a + * running program's first error still ends it, exactly once, with one line. + * + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_clear_error(akbasic_Runtime *obj); + /** * @brief Evaluate one AST leaf, drawing scratch values from the environment. * @param obj Object to initialize, inspect, or modify. diff --git a/src/runtime.c b/src/runtime.c index 0b61e0f..3a34754 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -1114,9 +1114,33 @@ akerr_ErrorContext *akbasic_runtime_call_function(akbasic_Runtime *obj, const ch * answering wrongly -- which is worse. The fix wants the REPL's own line * cycle, and that is a larger change than a condition. */ - while ( obj->environment != targetenv && obj->mode == AKBASIC_MODE_RUN ) { - PASS(errctx, akbasic_runtime_process_line_run(obj)); - } + ATTEMPT { + while ( obj->environment != targetenv && obj->mode == AKBASIC_MODE_RUN ) { + /* + * The same per-line prologue akbasic_runtime_step() runs. Without + * it the call environment's value scratch accumulates across the + * whole body, and a body of ten real lines dies with "Maximum + * values per line reached" -- a limit that is supposed to be per + * line, not per call. step() cannot do this for us: this loop + * drives process_line_run() directly. + */ + CATCH(errctx, akbasic_runtime_zero(obj)); + CATCH(errctx, akbasic_scanner_zero(obj)); + CATCH(errctx, akbasic_runtime_process_line_run(obj)); + } + } CLEANUP { + /* + * A body that died mid-line -- a runtime error set run_finished_mode, + * or a scanner error escaped (issue #4) -- left its scopes active. + * Give them back, or a host absorbing script errors drains the + * twelve-slot environment pool after twelve dead calls and every + * call after that fails for a reason nobody can see in the script. + */ + while ( obj->environment != targetenv && obj->environment->parent != NULL ) { + IGNORE(akbasic_runtime_prev_environment(obj)); + } + } PROCESS(errctx) { + } FINISH(errctx, true); PASS(errctx, akbasic_environment_new_value(targetenv, &out)); PASS(errctx, akbasic_value_clone(&targetenv->returnValue, out)); *dest = out; @@ -1918,6 +1942,15 @@ akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj) SUCCEED_RETURN(errctx); } +akerr_ErrorContext *akbasic_runtime_clear_error(akbasic_Runtime *obj) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in clear_error"); + obj->errclass = AKBASIC_ERRCLASS_NONE; + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps) { PREPARE_ERROR(errctx); diff --git a/src/scanner.c b/src/scanner.c index 2f7ed40..c49fc69 100644 --- a/src/scanner.c +++ b/src/scanner.c @@ -23,6 +23,7 @@ akerr_ErrorContext *akbasic_scanner_zero(akbasic_Runtime *obj) obj->current = 0; obj->start = 0; obj->hasError = false; + obj->tokentype = AKBASIC_TOK_UNDEFINED; SUCCEED_RETURN(errctx); } @@ -409,6 +410,16 @@ akerr_ErrorContext *akbasic_scanner_scan(akbasic_Runtime *obj, const char *line, obj->current = 0; obj->start = 0; obj->hasError = false; + /* + * The `REM` early-exit below leaves `tokentype` holding AKBASIC_TOK_REM, + * and the loop's post-switch check reads it before the first character of + * the *next* line has assigned anything. A line whose first character + * carries no token of its own -- leading whitespace -- then re-triggered + * the REM break and scanned to nothing: every indented line after a REM + * was silently skipped. A numbered program never saw it, because the line + * number is the first token and overwrites the leftover. + */ + obj->tokentype = AKBASIC_TOK_UNDEFINED; /* * Cleared here rather than by each caller, so the flag always describes the * line this call just scanned. It used to be cleared only in diff --git a/tests/docs_preludes/galagacalls.post b/tests/docs_preludes/galagacalls.post new file mode 100644 index 0000000..e6d321b --- /dev/null +++ b/tests/docs_preludes/galagacalls.post @@ -0,0 +1,10 @@ + } 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; + (void)SCRATCH_ENEMY; (void)SCRATCH_ACTOR; (void)galaga_shared; + (void)ENEMY_TYPE; (void)ACTOR_TYPE; (void)GAME_TYPE; + SUCCEED_RETURN(errctx); +} diff --git a/tests/docs_preludes/galagacalls.pre b/tests/docs_preludes/galagacalls.pre new file mode 100644 index 0000000..781efa5 --- /dev/null +++ b/tests/docs_preludes/galagacalls.pre @@ -0,0 +1,84 @@ +/* + * 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; + +typedef struct galaga_docs_Shared +{ + float playerx; + float playery; + int32_t wave; + float rnd; +} galaga_docs_Shared; + +static akbasic_Runtime SCRIPT; +static akbasic_TextSink SINK; +static akbasic_StdioSink SINKSTATE; +static char SOURCE[16384]; + +static galaga_docs_Enemy SCRATCH_ENEMY; +static akgl_Actor SCRATCH_ACTOR; +static galaga_docs_Shared galaga_shared; + +static const akbasic_HostField ENEMY_FIELDS[] = { + AKBASIC_HOST_FIELD( galaga_docs_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ) +}; +static const akbasic_HostType ENEMY_TYPE = { + "ENEMY", sizeof(galaga_docs_Enemy), ENEMY_FIELDS, 1 +}; +static const akbasic_HostField ACTOR_FIELDS[] = { + AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ) +}; +static const akbasic_HostType ACTOR_TYPE = { + "ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 1 +}; +static const akbasic_HostField GAME_FIELDS[] = { + AKBASIC_HOST_FIELD( galaga_docs_Shared, wave, "WAVE#", AKBASIC_HOSTFIELD_INT32 ) +}; +static const akbasic_HostType GAME_TYPE = { + "GAME", sizeof(galaga_docs_Shared), GAME_FIELDS, 1 +}; + +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 { diff --git a/tests/language/statements/rem_indented_line.bas b/tests/language/statements/rem_indented_line.bas new file mode 100644 index 0000000..5628d03 --- /dev/null +++ b/tests/language/statements/rem_indented_line.bas @@ -0,0 +1,14 @@ +REM The line after this comment is indented, and it must still run: the +REM scanner's REM early-exit used to leave TOK_REM armed, and the next +REM line's leading whitespace re-triggered it -- every indented line +REM after a REM was silently skipped. Unnumbered on purpose: a numbered +REM line's first token overwrites the leftover and hides the defect. +PRINT 1 +REM an indented statement follows + PRINT 2 +REM an indented multi-line DEF body, the shape that found it +DEF F(N#) + REM a comment inside the body + RETURN N# + 5 +PRINT F(10) +END diff --git a/tests/language/statements/rem_indented_line.txt b/tests/language/statements/rem_indented_line.txt new file mode 100644 index 0000000..4b5aa34 --- /dev/null +++ b/tests/language/statements/rem_indented_line.txt @@ -0,0 +1,3 @@ +1 +2 +15 diff --git a/tests/user_functions.c b/tests/user_functions.c index d2d60d5..3bc0414 100644 --- a/tests/user_functions.c +++ b/tests/user_functions.c @@ -337,6 +337,113 @@ static void test_call_from_c(void) harness_stop(); } +/** + * @brief A long multi-line body called from C completes. + * + * The value scratch is a *per line* limit, and the call loop has to reset it + * per line the way akbasic_runtime_step() does. It did not: the scratch + * accumulated across the whole body, and any body past about ten real lines + * died with "Maximum values per line reached" -- silently, from the caller's + * point of view, because a BASIC-level error inside the loop reports through + * the sink and comes back as a zero (issue #7's shape). Found by the galaga + * example, whose enemy functions are all longer than ten lines. + * + * The set_mode(RUN) between the load and the call is the issue #8 workaround: + * the body only runs in RUN mode, and the program has already ended. + */ +static void test_long_body_called_from_c(void) +{ + akbasic_Value arg; + akbasic_Value *argp[1]; + akbasic_Value *result = NULL; + + TEST_REQUIRE_OK(run_program("10 DEF LONGB(N#)\n" + "20 A# = N# + 1\n" + "30 B# = A# + 1\n" + "40 C# = B# + 1\n" + "50 D# = C# + 1\n" + "60 E# = D# + 1\n" + "70 F# = E# + 1\n" + "80 G# = F# + 1\n" + "90 H# = G# + 1\n" + "100 I# = H# + 1\n" + "110 J# = I# + 1\n" + "120 K# = J# + 1\n" + "130 L# = K# + 1\n" + "140 M# = L# + 1\n" + "150 P# = M# + 1\n" + "160 Q# = P# + 1\n" + "170 R# = Q# + 1\n" + "180 RETURN R#\n")); + TEST_REQUIRE_OK(akbasic_runtime_set_mode(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + + memset(&arg, 0, sizeof(arg)); + arg.valuetype = AKBASIC_TYPE_INTEGER; + arg.intval = 1; + argp[0] = &arg; + TEST_REQUIRE_OK(akbasic_runtime_call_function(&HARNESS_RUNTIME, "LONGB", argp, 1, &result)); + TEST_REQUIRE(result != NULL, "a call should have produced a result"); + TEST_REQUIRE_INT(result->intval, 17); + TEST_REQUIRE_STR(HARNESS_OUTPUT, ""); + harness_stop(); +} + +/** + * @brief A body that dies leaves the environment stack balanced. + * + * A runtime error inside a called body reports through the sink and ends the + * run -- that part is unchanged, and the caller still gets the zeroed slot + * (issue #7 tracks whether it should). What must NOT happen is what did: the + * call's environment was never given back, so a host that absorbed script + * errors and kept calling drained the twelve-slot pool after twelve dead + * calls, and every later call failed with "Environment pool exhausted" -- an + * exhaustion nothing in the script explains. + * + * The revival dance after each death is two calls: clear_error(), because a + * run's first error latches and every later line is skipped while it stands, + * and set_mode(RUN), the issue #8 workaround the boot already needed -- + * the error dropped the runtime out of RUN mode. + */ +static void test_dead_body_releases_environments(void) +{ + akbasic_Value arg; + akbasic_Value *argp[1]; + akbasic_Value *result = NULL; + akbasic_Environment *root = NULL; + int i = 0; + + TEST_REQUIRE_OK(run_program("10 DEF DIE(N#)\n" + "20 X# = NOSUCH(N#)\n" + "30 RETURN X#\n" + "40 DEF FINE(N#)\n" + "50 Y# = N# * 2\n" + "60 RETURN Y#\n")); + TEST_REQUIRE_OK(akbasic_runtime_set_mode(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + root = HARNESS_RUNTIME.environment; + + memset(&arg, 0, sizeof(arg)); + arg.valuetype = AKBASIC_TYPE_INTEGER; + arg.intval = 7; + argp[0] = &arg; + + /* Fifteen deaths: more than the pool holds, so a single leaked scope + * fails this loop even if the first twelve limp through. */ + for ( i = 0; i < 15; i++ ) { + TEST_REQUIRE_OK(akbasic_runtime_call_function(&HARNESS_RUNTIME, "DIE", argp, 1, &result)); + TEST_REQUIRE(HARNESS_RUNTIME.environment == root, + "a dead call must unwind back to the caller's environment"); + TEST_REQUIRE_OK(akbasic_runtime_clear_error(&HARNESS_RUNTIME)); + TEST_REQUIRE_OK(akbasic_runtime_set_mode(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + } + + /* And the runtime is still whole: a healthy function runs to the right + * answer after every one of those deaths. */ + TEST_REQUIRE_OK(akbasic_runtime_call_function(&HARNESS_RUNTIME, "FINE", argp, 1, &result)); + TEST_REQUIRE(result != NULL, "a call should have produced a result"); + TEST_REQUIRE_INT(result->intval, 14); + harness_stop(); +} + int main(void) { TEST_REQUIRE_OK(akbasic_error_register()); @@ -351,6 +458,8 @@ int main(void) test_call_scopes_are_reclaimed(); test_calls_do_not_leak_value_slots(); test_call_from_c(); + test_long_body_called_from_c(); + test_dead_body_releases_environments(); return akbasic_test_failures; }