diff --git a/CMakeLists.txt b/CMakeLists.txt index 21229cf..f4ae118 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) + 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/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..775d1c9 --- /dev/null +++ b/examples/galaga/interop_test.c @@ -0,0 +1,132 @@ +/** + * @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 "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); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, run_claims()); + } 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); +}