A GALAGA tutorial: C/libakgl engine with akbasic embedded as the enemy-behavior engine #37
@@ -304,7 +304,7 @@ if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -125,7 +125,41 @@ 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.
|
||||
just uses it. The status codes this game raises are `AKERR_NULLPOINTER`,
|
||||
`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL`
|
||||
and `AKGL_ERR_HEAP` — there is no code this tutorial invents.
|
||||
|
||||
The includes the engine files draw on, so nothing later has to be guessed —
|
||||
the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not
|
||||
`SDL3/SDL_ttf.h`):
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/ui.h>
|
||||
#include <akgl/util.h>
|
||||
```
|
||||
|
||||
The frame loop is the standard bracket, with one addition you will meet in
|
||||
Step 6 — for now, events in, world drawn, frame out:
|
||||
@@ -198,6 +232,20 @@ 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
|
||||
@@ -338,18 +386,84 @@ Four conventions worth keeping:
|
||||
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. In CMake:
|
||||
executable. The whole CMake recipe, inside an akbasic checkout with
|
||||
`AKBASIC_WITH_AKGL=ON`:
|
||||
|
||||
```cmake
|
||||
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
|
||||
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,
|
||||
@@ -360,7 +474,25 @@ 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:
|
||||
example's `script.c` — so the boundary stays a place rather than a habit. That
|
||||
file's interpreter-facing includes and statics, exactly:
|
||||
|
||||
```c wrap=galagatypes requires=akgl
|
||||
#include <akbasic/environment.h>
|
||||
#include <akbasic/error.h>
|
||||
#include <akbasic/host.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/* Static because an akbasic_Runtime is far too big for a stack frame --
|
||||
* 2.40 MiB on this branch. */
|
||||
static akbasic_Runtime SCRIPT;
|
||||
static akbasic_TextSink SINK;
|
||||
static akbasic_StdioSink SINKSTATE;
|
||||
static char SOURCE[16384];
|
||||
```
|
||||
|
||||
The boot itself:
|
||||
|
||||
```c wrap=galagacalls requires=akgl
|
||||
CATCH(errctx, akbasic_error_register());
|
||||
|
||||
@@ -35,6 +35,8 @@ whole development loop; the engine never rebuilds.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -507,6 +509,56 @@ the readout tells you how each brain did:
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
@@ -115,12 +116,144 @@ static akerr_ErrorContext *run_claims(void)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user