Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
dd10dc143a
|
|||
|
54ab85a276
|
|||
|
d5a0edd692
|
|||
|
47c6be58c5
|
|||
|
27837aeabc
|
|||
|
743e610f8f
|
|||
|
dde1d91c6e
|
|||
|
17af2d406c
|
@@ -26,7 +26,7 @@ scripting engine for game authors.
|
|||||||
| [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet |
|
| [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet |
|
||||||
| [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
|
| [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
|
||||||
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
|
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
|
||||||
| [`docs/`](docs/README.md) | The language itself: eighteen chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/` |
|
| [`docs/`](docs/README.md) | The language itself: twenty-one chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/`; [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) build the embedding host in `examples/galaga/` |
|
||||||
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
|
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
|
||||||
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
|
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
|
||||||
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |
|
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |
|
||||||
|
|||||||
@@ -268,6 +268,69 @@ if(AKBASIC_BUILD_EXAMPLES)
|
|||||||
endforeach()
|
endforeach()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# The galaga example: a C game on libakgl with the interpreter embedded as its
|
||||||
|
# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it
|
||||||
|
# is compiled and run by every AKGL build rather than rotting in a document.
|
||||||
|
# The asset, script and font paths are baked in so the smoke test can launch
|
||||||
|
# from any working directory; --assets and --script override them at runtime.
|
||||||
|
if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
|
||||||
|
add_executable(akbasic_example_galaga
|
||||||
|
examples/galaga/main.c
|
||||||
|
examples/galaga/script.c
|
||||||
|
examples/galaga/enemies.c
|
||||||
|
examples/galaga/player.c)
|
||||||
|
target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra)
|
||||||
|
target_compile_definitions(akbasic_example_galaga PRIVATE
|
||||||
|
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets"
|
||||||
|
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas"
|
||||||
|
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
|
||||||
|
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
|
||||||
|
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
||||||
|
akbasic_instrument(akbasic_example_galaga)
|
||||||
|
# Ten seconds of scripted play under the headless drivers: the script boots,
|
||||||
|
# a wave enters and forms, the autoplay pilot shoots at it, and the program
|
||||||
|
# tears down and exits 0. A tutorial that stops working fails here rather
|
||||||
|
# than in front of a reader.
|
||||||
|
_add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay)
|
||||||
|
_set_tests_properties(example_galaga PROPERTIES TIMEOUT 120
|
||||||
|
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
|
||||||
|
|
||||||
|
# The boundary's round-trip test: links the real script.c and the real
|
||||||
|
# galaga.bas, and fails the moment the two sides of the interop disagree.
|
||||||
|
add_executable(akbasic_example_galaga_interop
|
||||||
|
examples/galaga/interop_test.c
|
||||||
|
examples/galaga/script.c)
|
||||||
|
target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra)
|
||||||
|
target_compile_definitions(akbasic_example_galaga_interop PRIVATE
|
||||||
|
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas")
|
||||||
|
target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl
|
||||||
|
SDL3::SDL3 m)
|
||||||
|
akbasic_instrument(akbasic_example_galaga_interop)
|
||||||
|
_add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop)
|
||||||
|
_set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120)
|
||||||
|
|
||||||
|
# Regenerating the game figures in docs/ is a deliberate act, never part of
|
||||||
|
# a build, for the same reason docs_screenshots is: the PNGs are checked in.
|
||||||
|
# Wall-clock dt makes each regeneration differ by a few pixels of starfield,
|
||||||
|
# so expect a binary diff every time this runs; commit one only when the
|
||||||
|
# content changed on purpose. (docs_galaga_figures, not docs_game_figures:
|
||||||
|
# the libakgl submodule already owns that target name.)
|
||||||
|
add_custom_target(docs_galaga_figures
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
|
||||||
|
SDL_RENDER_DRIVER=software
|
||||||
|
$<TARGET_FILE:akbasic_example_galaga> --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
|
||||||
|
$<TARGET_FILE:akbasic_example_galaga> --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.
|
# Tests.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice |
|
| [`docs/`](docs/README.md) | The guide: twenty-one chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice, and [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) building a C game that embeds the interpreter |
|
||||||
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
|
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
|
||||||
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
|
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
|
||||||
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |
|
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |
|
||||||
|
|||||||
@@ -100,6 +100,50 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
|
|||||||
dies when the body pops — silently, with the script reading it correctly right up until
|
dies when the body pops — silently, with the script reading it correctly right up until
|
||||||
it stops.
|
it stops.
|
||||||
|
|
||||||
|
## Calling a function every frame
|
||||||
|
|
||||||
|
`akbasic_runtime_call_function()` calls a `DEF` by name with values you already
|
||||||
|
hold — the entry point a game loop wants. A host that calls it repeatedly signs
|
||||||
|
up for three rules the one-shot examples never meet:
|
||||||
|
|
||||||
|
```c wrap=hostcalls
|
||||||
|
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "THINK", argp, 1, &result));
|
||||||
|
/* ...consume the result... */
|
||||||
|
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Reset the value scratch after every call, once the result is consumed.**
|
||||||
|
Each call parks its result in the caller environment's per-line scratch
|
||||||
|
(`AKBASIC_MAX_VALUES` slots), and a host calling in a loop never crosses the
|
||||||
|
line boundary that would reset it. Skip the `akbasic_environment_zero()` and
|
||||||
|
the pool drains — measured at under two frames of forty calls — after which
|
||||||
|
every call fails with `Maximum values per line reached`. The reset also
|
||||||
|
invalidates `result`, which is why it comes after the consumption.
|
||||||
|
2. **Force RUN mode once after the boot run.** A multi-line `DEF` body only
|
||||||
|
runs while the runtime is in RUN mode, and by the time a host can call, the
|
||||||
|
program that filed the definitions has ended. One
|
||||||
|
`akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)` after
|
||||||
|
`akbasic_runtime_run()` makes the bodies run, and the mode stays put because
|
||||||
|
nothing steps the runtime between calls. Issue #8 tracks making this
|
||||||
|
unnecessary.
|
||||||
|
3. **Revive after a script error, deliberately.** A BASIC-level error inside a
|
||||||
|
called body reports through the sink, answers a stale value, and latches:
|
||||||
|
the runtime leaves RUN mode and every later call does nothing. When your
|
||||||
|
policy is to absorb the error and keep calling — a game marking one actor
|
||||||
|
dumb rather than killing the frame — the revival is two calls:
|
||||||
|
`akbasic_runtime_clear_error()`, then `akbasic_runtime_set_mode(RUN)` again.
|
||||||
|
The latch is deliberate for *programs* — the first error ends a run, once,
|
||||||
|
with one line — so nothing clears it for you.
|
||||||
|
|
||||||
|
Do not pass structures as per-frame arguments. A structure or pointer parameter
|
||||||
|
spends a value-pool slot on every call and the pool never reclaims, so the
|
||||||
|
interface dies after about a thousand calls — issue #36 has the measurements.
|
||||||
|
Bind the instance once with `akbasic_host_bind()` and point it at each object
|
||||||
|
with `akbasic_host_rebind()` ([Chapter 16](16-structures.md)), which spends
|
||||||
|
nothing per call. The GALAGA tutorial ([Chapters 20](20-tutorial-galaga.md)
|
||||||
|
and [21](21-tutorial-galaga-enemies.md)) is this whole recipe as a working
|
||||||
|
game, forty calls a frame.
|
||||||
|
|
||||||
## Where the output goes
|
## Where the output goes
|
||||||
|
|
||||||
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus
|
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus
|
||||||
|
|||||||
@@ -685,9 +685,9 @@ seconds asks for fifty frames a second.
|
|||||||
### Why `GOTO` rather than `DO ... LOOP`
|
### Why `GOTO` rather than `DO ... LOOP`
|
||||||
|
|
||||||
A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO`
|
A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO`
|
||||||
that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32
|
that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 12
|
||||||
scopes, so a game that leaves its main loop once per lost life stops on the
|
scopes, so a game that leaves its main loop once per lost life stops on the
|
||||||
thirty-second one:
|
twelfth one:
|
||||||
|
|
||||||
```basic
|
```basic
|
||||||
N# = 0
|
N# = 0
|
||||||
@@ -700,7 +700,7 @@ PRINT "SURVIVED " + N#
|
|||||||
```
|
```
|
||||||
|
|
||||||
```output
|
```output
|
||||||
? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
|
? 3 : PARSE ERROR Environment pool exhausted at line 3 (12 in use)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
849
docs/20-tutorial-galaga.md
Normal file
849
docs/20-tutorial-galaga.md
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
# 20. Tutorial: GALAGA — a C engine with a BASIC brain
|
||||||
|
|
||||||
|
This chapter and [Chapter 21](21-tutorial-galaga-enemies.md) build a GALAGA-style
|
||||||
|
fixed shooter from an empty file. The engine — window, starfield, bullets,
|
||||||
|
collision, score, screens — is C on libakgl. The enemies think in BASIC: one
|
||||||
|
script of `DEF` functions is called once per enemy per frame, and it reads and
|
||||||
|
writes the engine's own structures with no marshalling in either direction.
|
||||||
|
This chapter builds the engine and proves the boundary works; the next one
|
||||||
|
fills in the data structures and the AI.
|
||||||
|
|
||||||
|
The split is the point. Everything mechanical stays compiled, and everything an
|
||||||
|
enemy *decides* is a text file you can edit and re-run without rebuilding. It is
|
||||||
|
an academic exercise in *how* such an embed is done, not a claim that it is the
|
||||||
|
best way to write a GALAGA.
|
||||||
|
|
||||||
|
This is what the two chapters build:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The finished program is [`examples/galaga/`](../examples/galaga/): four C files,
|
||||||
|
one `galaga.bas`, and the assets. You do not need it to follow along, but it is
|
||||||
|
the same program assembled.
|
||||||
|
|
||||||
|
```sh norun
|
||||||
|
$ cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
|
||||||
|
$ cmake --build build-akgl --target akbasic_example_galaga
|
||||||
|
$ ./build-akgl/akbasic_example_galaga
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Does |
|
||||||
|
|---|---|
|
||||||
|
| left / right | move the ship |
|
||||||
|
| space | fire — two shots on screen at a time, the classic rule |
|
||||||
|
| return | choose a menu entry |
|
||||||
|
|
||||||
|
## What you will do
|
||||||
|
|
||||||
|
- **[Step 1](#step-1-open-a-window)** — open a window, in the one startup order
|
||||||
|
that works
|
||||||
|
- **[Step 2](#step-2-scatter-a-starfield)** — scatter a starfield and scroll it,
|
||||||
|
with no parallax machinery at all
|
||||||
|
- **[Step 3](#step-3-put-a-ship-on-screen)** — put a ship on screen from a
|
||||||
|
sprite and a character file, and drive it from the keyboard
|
||||||
|
- **[Step 4](#step-4-shots-and-collision)** — spawn shots from the actor heap
|
||||||
|
and collide them by hand
|
||||||
|
- **[Step 5](#step-5-boot-the-interpreter)** — link the interpreter in, load a
|
||||||
|
script of definitions, and call one from C
|
||||||
|
- **[Step 6](#step-6-the-update-hook)** — replace an actor's update hook so its
|
||||||
|
every frame is a BASIC call
|
||||||
|
- **[Step 7](#step-7-first-light)** — watch one enemy move under BASIC control,
|
||||||
|
and read the same numbers from both sides
|
||||||
|
- **[Step 8](#step-8-screens)** — add the title, game over and victory screens
|
||||||
|
- **[Step 9](#step-9-run-it-headless)** — run the whole game headless, so CI can
|
||||||
|
play it every night
|
||||||
|
|
||||||
|
Each step compiles and runs. The C fragments quote the finished example; the
|
||||||
|
file layout there — `main.c` for the harness, `script.c` for the boundary,
|
||||||
|
`enemies.c` and `player.c` for the actors — is a good one to copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Open a window
|
||||||
|
|
||||||
|
**Goal: a black window with a title, from the canonical startup order.**
|
||||||
|
|
||||||
|
libakgl has one startup sequence that works, documented at the top of its
|
||||||
|
`include/akgl/game.h` and walked through in its own tutorial (libakgl
|
||||||
|
docs/20-tutorial-sidescroller.md). The order matters twice: the screen
|
||||||
|
properties are read by the renderer, so they must be set before it exists, and
|
||||||
|
`akgl_game_init()` does **not** install a physics backend, so the application
|
||||||
|
must.
|
||||||
|
|
||||||
|
```c wrap=galagatypes requires=akgl
|
||||||
|
static akerr_ErrorContext *startup(void)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
|
||||||
|
"akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
|
||||||
|
"1.0.0", sizeof(akgl_game.version) - 1));
|
||||||
|
PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
|
||||||
|
"net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));
|
||||||
|
|
||||||
|
PASS(errctx, akgl_game_init());
|
||||||
|
|
||||||
|
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
|
||||||
|
PASS(errctx, akgl_set_property("game.screenheight", "960"));
|
||||||
|
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(
|
||||||
|
errctx,
|
||||||
|
SDL_SetRenderLogicalPresentation(
|
||||||
|
akgl_renderer->sdl_renderer,
|
||||||
|
1280,
|
||||||
|
960,
|
||||||
|
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
||||||
|
AKGL_ERR_SDL,
|
||||||
|
"%s",
|
||||||
|
SDL_GetError()
|
||||||
|
);
|
||||||
|
akgl_camera->x = 0.0f;
|
||||||
|
akgl_camera->y = 0.0f;
|
||||||
|
akgl_camera->w = 1280.0f;
|
||||||
|
akgl_camera->h = 960.0f;
|
||||||
|
|
||||||
|
PASS(errctx, akgl_physics_init_null(akgl_physics));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three of those lines deserve their reasons.
|
||||||
|
|
||||||
|
**The view is 1280x960 because the artwork is ~100 pixels wide.** libakgl draws
|
||||||
|
a sprite at the sprite's own size — `akgl_Actor.scale` is overwritten every
|
||||||
|
frame, so there is no way to draw one smaller (libakgl docs/12-actors.md) — and
|
||||||
|
a ten-column formation of 100-pixel ships needs 1120 pixels plus margins. The
|
||||||
|
view is sized to the art rather than the art resized to a view.
|
||||||
|
|
||||||
|
**`akgl_physics_init_null()` is not optional.** Skip it and the first
|
||||||
|
`akgl_game_update()` calls through a NULL `simulate` pointer. Null physics
|
||||||
|
accepts every call and moves nothing, which is exactly right here: whatever
|
||||||
|
writes `x` and `y` directly is the mover, and in this game that will be BASIC.
|
||||||
|
|
||||||
|
**Error handling is the house protocol.** Every function returns
|
||||||
|
`akerr_ErrorContext *`, `PASS` propagates, `ATTEMPT`/`CATCH`/`CLEANUP` brackets
|
||||||
|
anything that must unwind. libakgl's docs/04-errors.md teaches it; this chapter
|
||||||
|
just uses it, with two rules that keep the fragments compiling: **`CATCH` is
|
||||||
|
only legal inside an `ATTEMPT` block, and `PASS` everywhere else** — swap them
|
||||||
|
and the compiler objects about a stray `break` — and `main()` alone ends its
|
||||||
|
block with `FINISH_NORETURN(errctx)` instead of `FINISH`, because `FINISH`
|
||||||
|
expands a `return` of the context that an `int`-returning function cannot
|
||||||
|
compile:
|
||||||
|
|
||||||
|
```c wrap=galagatypes requires=akgl
|
||||||
|
static int FAILED = 0;
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
ATTEMPT {
|
||||||
|
/* CATCH each stage in order: startup, assets, the script boot,
|
||||||
|
* the spawns, then the frame loop. */
|
||||||
|
} CLEANUP {
|
||||||
|
/* ...teardown, every call wrapped in IGNORE()... */
|
||||||
|
} PROCESS(errctx) {
|
||||||
|
} HANDLE_DEFAULT(errctx) {
|
||||||
|
LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run");
|
||||||
|
/* Set a flag rather than returning: leaving a HANDLE block early
|
||||||
|
* skips FINISH's release and leaks the context's pool slot. */
|
||||||
|
FAILED = 1;
|
||||||
|
} FINISH_NORETURN(errctx);
|
||||||
|
return FAILED;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The status codes this game raises are `AKERR_NULLPOINTER`,
|
||||||
|
`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL`
|
||||||
|
and `AKGL_ERR_HEAP` — there is no code this tutorial invents.
|
||||||
|
|
||||||
|
The includes the engine files draw on, so nothing later has to be guessed —
|
||||||
|
the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not
|
||||||
|
`SDL3/SDL_ttf.h`):
|
||||||
|
|
||||||
|
```c wrap=galagatypes requires=akgl
|
||||||
|
#include <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:
|
||||||
|
|
||||||
|
```c wrap=galagahost requires=akgl
|
||||||
|
while ( SDL_PollEvent(&event) == true ) {
|
||||||
|
CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||||
|
}
|
||||||
|
CATCH(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||||
|
CATCH(errctx, akgl_game_update(NULL));
|
||||||
|
CATCH(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||||
|
```
|
||||||
|
|
||||||
|
`akgl_game_update(NULL)` is update-every-actor, step-the-physics,
|
||||||
|
draw-the-world. It neither clears nor presents; the `frame_start` and
|
||||||
|
`frame_end` calls own that.
|
||||||
|
|
||||||
|
## Step 2: Scatter a starfield
|
||||||
|
|
||||||
|
**Goal: a scrolling two-depth starfield, from an array and one draw call.**
|
||||||
|
|
||||||
|
No parallax facility exists in libakgl and none is needed. A fixed array of
|
||||||
|
stars, advanced per frame and drawn with `akgl_draw_point()` between
|
||||||
|
`frame_start` and `akgl_game_update()`, is the whole feature. Two speed bands
|
||||||
|
give the depth for free — the slow band reads as far away:
|
||||||
|
|
||||||
|
```c wrap=galagatypes requires=akgl
|
||||||
|
#define GALAGA_STARS 96
|
||||||
|
|
||||||
|
static struct
|
||||||
|
{
|
||||||
|
float x;
|
||||||
|
float y;
|
||||||
|
float speed;
|
||||||
|
Uint8 bright;
|
||||||
|
} STARS[GALAGA_STARS];
|
||||||
|
|
||||||
|
static akerr_ErrorContext *starfield_draw(float dt)
|
||||||
|
{
|
||||||
|
SDL_Color color = { 255, 255, 255, 255 };
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
for ( i = 0; i < GALAGA_STARS; i++ ) {
|
||||||
|
STARS[i].y += STARS[i].speed * dt;
|
||||||
|
if ( STARS[i].y > 960.0f ) {
|
||||||
|
STARS[i].y -= 960.0f;
|
||||||
|
}
|
||||||
|
color.r = STARS[i].bright;
|
||||||
|
color.g = STARS[i].bright;
|
||||||
|
color.b = STARS[i].bright;
|
||||||
|
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed the array once at startup — even indexes slow and dim (speed 40, bright
|
||||||
|
110), odd indexes fast and bright (speed 110, bright 220) — and the effect is
|
||||||
|
done. A point is exactly one pixel (libakgl docs/09-drawing.md).
|
||||||
|
|
||||||
|
## Step 3: Put a ship on screen
|
||||||
|
|
||||||
|
**Goal: a player actor, drawn from a character file, moving on key input.**
|
||||||
|
|
||||||
|
The art is Kenney's Space Shooter pack, CC0, used byte for byte — see
|
||||||
|
[`examples/galaga/assets/art/PROVENANCE.md`](../examples/galaga/assets/art/PROVENANCE.md)
|
||||||
|
for what each file is. An actor gets its looks from a **character**, which maps
|
||||||
|
actor state words to **sprites** (libakgl docs/10 and 12). Both are JSON; load
|
||||||
|
sprites first, because a character names its sprites and a character loaded
|
||||||
|
first fails on the first name it cannot find.
|
||||||
|
|
||||||
|
These are the names, so the loading lists and every
|
||||||
|
`akgl_actor_set_character()` call in both chapters agree — each `sprite_*.json`
|
||||||
|
and `character_*.json` lives in `assets/`:
|
||||||
|
|
||||||
|
| Character | Sprite(s) it maps | Worn by |
|
||||||
|
|---|---|---|
|
||||||
|
| `galaga_player` | `galaga_player` | the ship |
|
||||||
|
| `galaga_bee` | `galaga_bee` | bees |
|
||||||
|
| `galaga_butterfly` | `galaga_butterfly` | butterflies |
|
||||||
|
| `galaga_boss` | `galaga_boss`, and `galaga_boss_hurt` on state bit 13 | bosses |
|
||||||
|
| `galaga_playershot` | `galaga_playershot` | the ship's shots |
|
||||||
|
| `galaga_enemyshot` | `galaga_enemyshot` | enemy shots |
|
||||||
|
| `galaga_boom` | `galaga_boom` | explosions |
|
||||||
|
|
||||||
|
The spawn is four decisions after the two boilerplate calls:
|
||||||
|
|
||||||
|
```c wrap=galagagame requires=akgl
|
||||||
|
static akerr_ErrorContext *galaga_player_spawn(void)
|
||||||
|
{
|
||||||
|
akgl_Actor *player = NULL;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
PASS(errctx, akgl_heap_next_actor(&player));
|
||||||
|
PASS(errctx, akgl_actor_initialize(player, "player"));
|
||||||
|
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
|
||||||
|
/* AFTER initialize: it resets all seven hooks. */
|
||||||
|
player->updatefunc = &player_update;
|
||||||
|
player->movement_controls_face = false;
|
||||||
|
player->state = AKGL_ACTOR_STATE_ALIVE;
|
||||||
|
player->visible = true;
|
||||||
|
player->x = 590.0f;
|
||||||
|
player->y = 860.0f;
|
||||||
|
|
||||||
|
galaga_game.player = player;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of the four lines under the comment closes a trap:
|
||||||
|
|
||||||
|
- **`updatefunc` after `akgl_actor_initialize()`**, never before — initialize
|
||||||
|
installs all seven default hooks, and a hook set first is a hook reset.
|
||||||
|
- **`movement_controls_face = false`.** The default facing logic edits the
|
||||||
|
state word, a character mapping matches the **whole** word, and an actor
|
||||||
|
whose state matches no mapping is *silently not drawn*. Nothing here moves by
|
||||||
|
state bits, so facing stays out of the word entirely.
|
||||||
|
- **`state = AKGL_ACTOR_STATE_ALIVE`** — the word the character mapping names.
|
||||||
|
- **`visible = true`.** `akgl_actor_initialize()` does not raise it. In a
|
||||||
|
tilemap game the map loader copies visibility from map data; there is no map
|
||||||
|
here, so an actor that skips this line exists, moves, fires and collides —
|
||||||
|
invisibly. This one line cost this example its first screenshot.
|
||||||
|
|
||||||
|
Input goes through a control map: push a control per key with handlers that set
|
||||||
|
flags, and let the actor's update hook read the flags. A handler receives the
|
||||||
|
map's target actor and the event, and returns through the error protocol like
|
||||||
|
everything else — this pair is the whole pattern, repeated per key:
|
||||||
|
|
||||||
|
```c wrap=galagagame requires=akgl
|
||||||
|
static bool MOVELEFT = false;
|
||||||
|
|
||||||
|
akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
(void)obj; (void)event;
|
||||||
|
MOVELEFT = true;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event)
|
||||||
|
{
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
(void)obj; (void)event;
|
||||||
|
MOVELEFT = false;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The example keeps the flags in its `galaga_Game` struct rather than statics;
|
||||||
|
either works.) The bindings themselves are pushes onto map 0:
|
||||||
|
|
||||||
|
```c wrap=galagagame requires=akgl
|
||||||
|
static akerr_ErrorContext *galaga_player_controls(void)
|
||||||
|
{
|
||||||
|
akgl_Control control;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
memset(&control, 0, sizeof(control));
|
||||||
|
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||||
|
control.event_off = SDL_EVENT_KEY_UP;
|
||||||
|
|
||||||
|
control.key = SDLK_LEFT;
|
||||||
|
control.handler_on = &left_on;
|
||||||
|
control.handler_off = &left_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||||
|
|
||||||
|
control.key = SDLK_RIGHT;
|
||||||
|
control.handler_on = &right_on;
|
||||||
|
control.handler_off = &right_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||||
|
|
||||||
|
control.key = SDLK_SPACE;
|
||||||
|
control.handler_on = &fire_on;
|
||||||
|
control.handler_off = &fire_off;
|
||||||
|
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||||
|
|
||||||
|
akgl_controlmaps[0].target = galaga_game.player;
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hand **every** polled event to `akgl_controller_handle_event()` — one that no
|
||||||
|
control binds is not an error, it is a call that did nothing.
|
||||||
|
|
||||||
|
## Step 4: Shots and collision
|
||||||
|
|
||||||
|
**Goal: bullets that fly, hit, and give their actor slot back.**
|
||||||
|
|
||||||
|
Bullets and collision are C forever — they are engine, not behavior. A shot is
|
||||||
|
an actor from the same 64-slot heap pool, with its own tiny update hook: move,
|
||||||
|
test, release.
|
||||||
|
|
||||||
|
```c wrap=galagagame requires=akgl
|
||||||
|
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
|
||||||
|
{
|
||||||
|
SDL_FRect mine;
|
||||||
|
SDL_FRect theirs;
|
||||||
|
bool hit = false;
|
||||||
|
int i = 0;
|
||||||
|
PREPARE_ERROR(errctx);
|
||||||
|
|
||||||
|
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||||
|
obj->y -= 900.0f * galaga_game.dt;
|
||||||
|
if ( obj->y < -60.0f ) {
|
||||||
|
galaga_game.player_shots_live -= 1;
|
||||||
|
PASS(errctx, akgl_heap_release_actor(obj));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
shot_box(obj, &mine);
|
||||||
|
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
|
||||||
|
if ( galaga_enemy_actors[i] == NULL ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
enemy_box(galaga_enemy_actors[i], &theirs);
|
||||||
|
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
|
||||||
|
if ( !hit ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
galaga_enemies[i].hp -= 1;
|
||||||
|
if ( galaga_enemies[i].hp <= 0 ) {
|
||||||
|
PASS(errctx, kill_enemy(i));
|
||||||
|
}
|
||||||
|
galaga_game.player_shots_live -= 1;
|
||||||
|
PASS(errctx, akgl_heap_release_actor(obj));
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
}
|
||||||
|
SUCCEED_RETURN(errctx);
|
||||||
|
|||||||