Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d715bc0625
|
|||
|
350deb3a45
|
|||
|
8a02674af5
|
@@ -17,7 +17,7 @@ jobs:
|
|||||||
# Not recursive, deliberately. The top-level build needs
|
# Not recursive, deliberately. The top-level build needs
|
||||||
# deps/libakerror and deps/libakstdlib, via add_subdirectory, and
|
# deps/libakerror and deps/libakstdlib, via add_subdirectory, and
|
||||||
# nothing else: the golden corpus and the Commodore font now live in
|
# nothing else: the golden corpus and the Commodore font now live in
|
||||||
# this repository (tests/reference/ and assets/fonts/), so
|
# this repository (tests/language/ and assets/fonts/), so
|
||||||
# deps/basicinterpret is no longer a build dependency at all.
|
# deps/basicinterpret is no longer a build dependency at all.
|
||||||
# It does *not* need deps/libakgl, which is guarded behind
|
# It does *not* need deps/libakgl, which is guarded behind
|
||||||
# AKBASIC_WITH_AKGL and defaults OFF -- and recursing into it would
|
# AKBASIC_WITH_AKGL and defaults OFF -- and recursing into it would
|
||||||
@@ -62,11 +62,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cmake -S . -B build
|
cmake -S . -B build
|
||||||
cmake --build build --parallel 2
|
cmake --build build --parallel 2
|
||||||
# The suite is 78 cases: 41 golden files byte-compared against the Go
|
# The suite is 112 cases: 65 language files with sibling expectations,
|
||||||
# reference's own corpus (checked in at tests/reference/, see its README),
|
# 43 unit tests, 3 embedding examples, and docs_examples.
|
||||||
# 9 local golden cases for verbs the reference never implemented, 25 unit
|
# Some unit tests assert the *correct* contract for known defects (TODO.md
|
||||||
# tests, 2 embedding examples, and 1 known-failing test that asserts the
|
|
||||||
# *correct* contract for defects carried over from the reference (TODO.md
|
|
||||||
# section 6). A green run therefore does not mean defect-free -- see
|
# section 6). A green run therefore does not mean defect-free -- see
|
||||||
# AKBASIC_KNOWN_FAILING_TESTS.
|
# AKBASIC_KNOWN_FAILING_TESTS.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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: 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/` |
|
| [`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/` |
|
||||||
| `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** |
|
||||||
@@ -57,9 +57,9 @@ repeating where you will see them:
|
|||||||
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script.
|
`include/akgl/SDL_GameControllerDB.h`. Change the template or the generator script.
|
||||||
- **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces
|
- **Do not reformat code you are not otherwise changing.** Several files mix tabs and spaces
|
||||||
and there is no repo-wide formatter; style conversions get their own commit.
|
and there is no repo-wide formatter; style conversions get their own commit.
|
||||||
- **Do not edit `tests/reference/`.** Those expectations came from the Go implementation and
|
- **Keep `tests/language/` editable.** Its `.bas` programs and sibling `.txt` expectations
|
||||||
are never edited to suit this interpreter. A deliberate divergence goes in
|
are changed together when behavior changes. Record deliberate language decisions in
|
||||||
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`.
|
`TODO.md` or `docs/13-differences.md`.
|
||||||
- **Open an issue for outstanding work; do not add it to `TODO.md`.**
|
- **Open an issue for outstanding work; do not add it to `TODO.md`.**
|
||||||
<https://source.starfort.tech/andrew/akbasic/issues>, or `tea issues create --repo
|
<https://source.starfort.tech/andrew/akbasic/issues>, or `tea issues create --repo
|
||||||
andrew/akbasic`. Name the file and line, the functional consequence, and what closing it would
|
andrew/akbasic`. Name the file and line, the functional consequence, and what closing it would
|
||||||
|
|||||||
128
CMakeLists.txt
128
CMakeLists.txt
@@ -268,69 +268,6 @@ 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.
|
||||||
#
|
#
|
||||||
@@ -511,7 +448,7 @@ if(AKBASIC_WITH_AKGL)
|
|||||||
# against.
|
# against.
|
||||||
#
|
#
|
||||||
# **A byte comparison of a rendered PNG is a deliberate bet**, the same bet
|
# **A byte comparison of a rendered PNG is a deliberate bet**, the same bet
|
||||||
# tests/reference/ already makes about golden output: that the dummy video
|
# the language corpus makes about golden output: that the dummy video
|
||||||
# driver and the software renderer are reproducible. They are, run to run and
|
# driver and the software renderer are reproducible. They are, run to run and
|
||||||
# build to build. What is untested is an SDL upgrade that shifts one pixel of
|
# build to build. What is untested is an SDL upgrade that shifts one pixel of
|
||||||
# a diagonal, and the answer to that is to regenerate the figures in the same
|
# a diagonal, and the answer to that is to regenerate the figures in the same
|
||||||
@@ -594,63 +531,20 @@ if(AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
|
|||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# The reference's own corpus, byte-compared against the sibling .txt. One CTest
|
# The editable language corpus. One CTest case per .bas so a failure names the
|
||||||
# case per .bas so a failure names the file.
|
# file. Each program is paired with a sibling .txt expectation; see
|
||||||
|
# tests/language/README.md for the editing rule.
|
||||||
#
|
#
|
||||||
# It used to be driven in place out of deps/basicinterpret, on the reasoning that
|
# The corpus includes programs carried over from the deprecated Go implementation
|
||||||
# copying a submodule's corpus guarantees drift. That reasoning was sound and it
|
# and cases written for this interpreter. Their provenance is useful when
|
||||||
# has been overruled deliberately: the Go dependency is being deprecated, and a
|
# investigating a regression, but it does not make any case immutable.
|
||||||
# build that cannot run its acceptance suite without cloning the implementation
|
|
||||||
# it replaced is not finished. The copy is byte-identical to
|
|
||||||
# basicinterpreter@d76162c and tests/reference/README.md records
|
|
||||||
# where it came from and what the drift now costs.
|
|
||||||
file(GLOB_RECURSE AKBASIC_GOLDEN_CASES
|
|
||||||
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/tests/reference"
|
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/*.bas"
|
|
||||||
)
|
|
||||||
|
|
||||||
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
|
|
||||||
string(REGEX REPLACE "^tests/" "" _name "${_case}")
|
|
||||||
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
|
|
||||||
string(REPLACE "/" "_" _name "${_name}")
|
|
||||||
_add_test(
|
|
||||||
NAME golden_${_name}
|
|
||||||
COMMAND ${CMAKE_COMMAND}
|
|
||||||
-DBASIC=$<TARGET_FILE:basic>
|
|
||||||
-DCASE=${CMAKE_CURRENT_SOURCE_DIR}/tests/reference/${_case}
|
|
||||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden.cmake
|
|
||||||
)
|
|
||||||
endforeach()
|
|
||||||
|
|
||||||
if(AKBASIC_GOLDEN_CASES)
|
|
||||||
set(AKBASIC_GOLDEN_NAMES)
|
|
||||||
foreach(_case IN LISTS AKBASIC_GOLDEN_CASES)
|
|
||||||
string(REGEX REPLACE "^tests/" "" _name "${_case}")
|
|
||||||
string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
|
|
||||||
string(REPLACE "/" "_" _name "${_name}")
|
|
||||||
list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name})
|
|
||||||
endforeach()
|
|
||||||
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
|
|
||||||
# An AKGL build of `basic` opens a window, and forty-one of them is not what
|
|
||||||
# anybody running the suite wanted. The dummy driver produces the same stdout,
|
|
||||||
# which is the only thing a golden case compares.
|
|
||||||
if(AKBASIC_WITH_AKGL)
|
|
||||||
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES
|
|
||||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
# The local golden corpus, for verbs the reference never implemented.
|
|
||||||
#
|
#
|
||||||
# Still separate now that the reference's corpus lives in this repository too,
|
# Programs carried over from the deprecated Go implementation and cases written
|
||||||
# and the reason changed rather than went away: tests/reference/ is a *record* of
|
# for this interpreter use the same editable `.bas`/`.txt` contract. Registered
|
||||||
# what the Go implementation did and nothing in it should ever be edited to suit
|
# under local_ so every failure identifies the program that produced it.
|
||||||
# this one, while tests/language/ is ours to change. Registered under local_ so a
|
|
||||||
# failure says at a glance which of the two it came from -- and so a diff that
|
|
||||||
# touches tests/reference/ stands out as the thing it is.
|
|
||||||
#
|
#
|
||||||
# Note what this can and cannot cover. The graphics and sound verbs draw and play
|
# Note what this can and cannot cover. The graphics and sound verbs draw and play
|
||||||
# rather than print, so what a golden file sees of them is their *refusals* and
|
# rather than print, so what a golden file sees of them is their refusals and
|
||||||
# whatever a program can PRINT about the state they changed. The behaviour that
|
# whatever a program can PRINT about the state they changed. The behaviour that
|
||||||
# reaches a device is asserted against tests/mockdevice.h instead.
|
# reaches a device is asserted against tests/mockdevice.h instead.
|
||||||
file(GLOB_RECURSE AKBASIC_LOCAL_CASES
|
file(GLOB_RECURSE AKBASIC_LOCAL_CASES
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ source stays readable as documentation of what the original did. Neither is bind
|
|||||||
|
|
||||||
**It is not a build or test dependency.** Both configurations have been configured, built and
|
**It is not a build or test dependency.** Both configurations have been configured, built and
|
||||||
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
|
run from scratch with it moved out of the tree. Its acceptance corpus is checked in at
|
||||||
`tests/reference/` and its Commodore font at `assets/fonts/`.
|
`tests/language/` and its Commodore font at `assets/fonts/`.
|
||||||
|
|
||||||
```sh norun
|
```sh norun
|
||||||
cd deps/basicinterpret
|
cd deps/basicinterpret
|
||||||
@@ -351,15 +351,10 @@ name. That is not cosmetic: `add_executable` creates a dependency's targets even
|
|||||||
|
|
||||||
### The golden corpora
|
### The golden corpora
|
||||||
|
|
||||||
`tests/reference/` is the Go implementation's own acceptance suite, byte-compared.
|
`tests/language/` is the editable language corpus. It includes cases carried over from the
|
||||||
**Nothing in it is ever edited to suit this interpreter.** If a case fails, either this
|
deprecated Go implementation as well as cases written for this interpreter. Every `.bas` file
|
||||||
interpreter is wrong or the divergence is deliberate — and a deliberate one goes in
|
has a sibling `.txt` expectation, and a new language feature needs that pair as well as unit
|
||||||
`tests/reference/README.md`'s divergence table and `docs/13-differences.md`, not into the
|
tests. Change both deliberately in the same commit; provenance does not make a case immutable.
|
||||||
expectation file. `tests/reference/README.md`
|
|
||||||
says the same thing at more length.
|
|
||||||
|
|
||||||
`tests/language/` is ours and may be changed freely. A new language feature needs a
|
|
||||||
`.bas`/`.txt` pair there as well as unit tests.
|
|
||||||
|
|
||||||
### Mutation-check a fix before you believe it
|
### Mutation-check a fix before you believe it
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ implementation that started from the Java Lox instructions in
|
|||||||
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
|
[craftinginterpreters.com](https://craftinginterpreters.com) and then struck off on its own. That
|
||||||
project is deprecated. It is vendored here as the behavioural spec to read when a question about
|
project is deprecated. It is vendored here as the behavioural spec to read when a question about
|
||||||
semantics comes up, and its acceptance corpus is checked in at
|
semantics comes up, and its acceptance corpus is checked in at
|
||||||
[`tests/reference/`](tests/reference/README.md) and runs on every build — so nothing about
|
[`tests/language/`](tests/language/README.md) and runs on every build — so nothing about
|
||||||
building or testing this project needs it.
|
building or testing this project needs it.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
@@ -24,7 +24,7 @@ ctest --test-dir build --output-on-failure
|
|||||||
|
|
||||||
```sh norun
|
```sh norun
|
||||||
./build/basic # the REPL
|
./build/basic # the REPL
|
||||||
./build/basic tests/reference/language/functions.bas # run a program
|
./build/basic tests/language/functions.bas # run a program
|
||||||
```
|
```
|
||||||
|
|
||||||
```basic
|
```basic
|
||||||
@@ -126,10 +126,10 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`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 |
|
| [`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 |
|
||||||
| [`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/language/README.md`](tests/language/README.md) | The editable language corpus and the rule for changing it |
|
||||||
|
|
||||||
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.
|
API documentation builds with `doxygen Doxyfile`, into `build/docs/html`.
|
||||||
|
|
||||||
|
|||||||
37
TODO.md
37
TODO.md
@@ -38,7 +38,7 @@ What it changes:
|
|||||||
- **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs
|
- **§1.8's message-text contract is now a convention.** Improving a message is allowed; it costs
|
||||||
a golden file, which is a cost rather than a veto.
|
a golden file, which is a cost rather than a veto.
|
||||||
- **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits.
|
- **§5's bar drops** from "defensible against the golden suite" to defensible on its own merits.
|
||||||
- **`tests/reference/` becomes a regression suite rather than a specification.** Diverging from
|
- **`tests/language/` is the editable language corpus rather than a protected specification.** Diverging from
|
||||||
it is allowed and must be deliberate and recorded — see its README.
|
it is allowed and must be deliberate and recorded — see its README.
|
||||||
|
|
||||||
What it does **not** change:
|
What it does **not** change:
|
||||||
@@ -306,13 +306,13 @@ to `Println`, which adds another.
|
|||||||
**This used to be a hard contract and is now a default.** The Go implementation is deprecated
|
**This used to be a hard contract and is now a default.** The Go implementation is deprecated
|
||||||
and will not be updated, so the two projects are no longer required to match — see §0.1. What
|
and will not be updated, so the two projects are no longer required to match — see §0.1. What
|
||||||
survives is the practical half: these strings and this newline behaviour are what every
|
survives is the practical half: these strings and this newline behaviour are what every
|
||||||
expectation in `tests/reference/` was written against, so changing one means changing golden
|
expectation in `tests/language/` was written against, so changing one means changing the paired
|
||||||
files, and that is worth doing on purpose rather than by accident. A message that reads
|
files, and that is worth doing on purpose rather than by accident. A message that reads
|
||||||
awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit,
|
awkwardly *may* now be improved; do it deliberately, move the expectations in the same commit,
|
||||||
and add a line to §5.
|
and add a line to §5.
|
||||||
|
|
||||||
Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f`
|
Numeric formatting still matches the reference: integers via `%" PRId64 "`, floats via `%f`
|
||||||
(Go's `%f` and C's `%f` both give six decimals — `tests/reference/language/arithmetic/float.txt`
|
(Go's `%f` and C's `%f` both give six decimals — `tests/language/arithmetic/float.txt`
|
||||||
confirms). No reason to change it, which is different from not being allowed to.
|
confirms). No reason to change it, which is different from not being allowed to.
|
||||||
|
|
||||||
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
|
### 1.9 Which `libakstdlib` calls are cleared for use — **the bans are lifted**
|
||||||
@@ -375,7 +375,7 @@ Phases 0 through 6 of the original plan are done. The interpreter builds clean u
|
|||||||
|
|
||||||
It *did* reproduce the reference byte for byte, and that claim is retired rather than broken:
|
It *did* reproduce the reference byte for byte, and that claim is retired rather than broken:
|
||||||
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
|
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
|
||||||
`tests/reference/README.md`). Everything else still matches, which is worth knowing but is no
|
`tests/language/README.md`). Everything else still matches, which is worth knowing but is no
|
||||||
longer a gate.
|
longer a gate.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -408,17 +408,16 @@ and the only path that existed — `AKBASIC_MODE_RUNSTREAM` reading through the
|
|||||||
is the code the README quotes, built by every build and registered as a CTest case so a
|
is the code the README quotes, built by every build and registered as a CTest case so a
|
||||||
signature change breaks the build rather than rotting the document.
|
signature change breaks the build rather than rotting the document.
|
||||||
|
|
||||||
**The acceptance suite is the reference's own corpus, checked in at `tests/reference/`.** All
|
**The acceptance suite is the editable language corpus, checked in at `tests/language/`.** All
|
||||||
41 `.bas` files are registered as individual CTest cases and byte-compared against their `.txt`
|
65 `.bas` files are registered as individual CTest cases and compared against their `.txt`
|
||||||
— including the trailing double newline on an error line (§1.8).
|
— including the trailing double newline on an error line (§1.8).
|
||||||
|
|
||||||
It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that
|
It was driven *in place* out of `deps/basicinterpret` until 2026-07-31, on the reasoning that
|
||||||
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
|
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
|
||||||
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
|
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
|
||||||
acceptance suite without cloning the implementation it replaced is not finished. The copy is
|
acceptance suite without cloning the implementation it replaced is not finished. The copy is
|
||||||
byte-identical to `basicinterpreter@d76162c`, and `tests/reference/README.md` records the
|
originally byte-identical to `basicinterpreter@d76162c`, and `tests/language/README.md` records the
|
||||||
provenance, the cost of the drift nobody is watching for now, and the rule that those
|
provenance and the rule that programs and expectations are edited together deliberately.
|
||||||
expectations are never edited to suit this interpreter.
|
|
||||||
|
|
||||||
**Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked
|
**Nothing in the build or the suite needs `deps/basicinterpret` any more**, and that is checked
|
||||||
rather than assumed — both configurations were configured, built and run from scratch with the
|
rather than assumed — both configurations were configured, built and run from scratch with the
|
||||||
@@ -1026,7 +1025,7 @@ deviations from the reference's *program*: `main.go` and the SDL half of
|
|||||||
|
|
||||||
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
|
**What it costs a program:** a listing that used `INPUT$`, `LEN#` or `GOTO%` as a variable
|
||||||
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
|
stops parsing, and the fix is to rename the variable. One case in the reference's own corpus
|
||||||
did exactly that; see `tests/reference/README.md`.
|
did exactly that; see `tests/language/examples/strreverse.bas`.
|
||||||
|
|
||||||
### Deviations in statement separation
|
### Deviations in statement separation
|
||||||
|
|
||||||
@@ -1391,10 +1390,10 @@ deviations from the reference's *program*: `main.go` and the SDL half of
|
|||||||
**This one moved a golden file.** A line with no number used to be filed under the loader's
|
**This one moved a golden file.** A line with no number used to be filed under the loader's
|
||||||
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
|
cursor unchanged — that is, on top of the line before it — so two unnumbered lines in a row
|
||||||
silently lost the first, and a *blank* line erased whatever preceded it. The reference does
|
silently lost the first, and a *blank* line erased whatever preceded it. The reference does
|
||||||
the same, and `tests/reference/language/arithmetic/integer.bas` is the proof: four `PRINT`
|
the same, and `tests/language/arithmetic/integer.bas` is the proof: four `PRINT`
|
||||||
statements, an expectation with three values, and a trailing blank line that erased
|
statements, an expectation with three values, and a trailing blank line that erased
|
||||||
`40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and
|
`40 PRINT 4 - 2` before the program ran. The expectation is now `4 4 2 2` and
|
||||||
`tests/reference/README.md` records it.
|
`tests/language/README.md` records it.
|
||||||
|
|
||||||
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
|
In its place: `akbasic_runtime_file_line()` (`src/runtime.c`) is the one implementation of
|
||||||
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
|
the rule, shared by `akbasic_runtime_load()`, RUNSTREAM and `DLOAD`. A numbered line is
|
||||||
@@ -1586,9 +1585,9 @@ be reproduced before it can be fixed.
|
|||||||
|
|
||||||
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
|
**It cost one golden case, exactly as predicted, and the cost turned out to be nothing.**
|
||||||
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
|
The reference's `examples/strreverse.bas` names a variable `INPUT$`; the variable is renamed
|
||||||
to `SOURCE$` in `tests/reference/`, the expectation is byte-for-byte unchanged — the program
|
to `SOURCE$` in `tests/language/examples/strreverse.bas`, the expectation is byte-for-byte unchanged — the program
|
||||||
still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in
|
still prints `REVERSED: OLLEH` — and the case keeps every bit of its coverage. Recorded in
|
||||||
`tests/reference/README.md`'s divergence table, which this is the first entry in.
|
`tests/language/README.md` records the corpus editing rule.
|
||||||
|
|
||||||
This item sat parked because the corpus was the acceptance contract and lived in a submodule
|
This item sat parked because the corpus was the acceptance contract and lived in a submodule
|
||||||
this repository could not edit. Both premises are gone: the corpus is checked in, and
|
this repository could not edit. Both premises are gone: the corpus is checked in, and
|
||||||
@@ -2232,10 +2231,10 @@ update --init --recursive` gets them.
|
|||||||
|
|
||||||
## 8. Status
|
## 8. Status
|
||||||
|
|
||||||
**The port is done.** The C interpreter passes the Go reference's entire corpus and passes clean
|
**The port is done.** The C interpreter passes the language corpus and passes clean
|
||||||
under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the
|
under ASan and UBSan. It reproduced that corpus byte for byte until §0.1 retired the
|
||||||
requirement; two cases have diverged on purpose since, and
|
requirement; two cases have diverged on purpose since, and
|
||||||
`tests/reference/README.md` lists them.
|
`tests/language/README.md` lists them.
|
||||||
|
|
||||||
| Gate | Result |
|
| Gate | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -2243,7 +2242,7 @@ requirement; two cases have diverged on purpose since, and
|
|||||||
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips |
|
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 112/112 headless, with `akgl_typing` skipping itself. The same set minus the four `no_device` cases the SDL driver contradicts, plus `akgl_backends`, `akgl_frontend`, `docs_screenshots` and `akgl_typing` — the last of which is the skip, and the `akgl_build` CI job is where it skips |
|
||||||
| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention |
|
| `docs_examples` | Every fenced block in `README.md`, `MAINTENANCE.md` and `docs/` executed and byte-compared: 71 programs, 9 transcripts, 79 output comparisons, 4 C snippets, 2 excerpts, 2 shell blocks and 19 figures in the default build. The C-snippet count reads 0 when the harness is run by hand without `--cflags-file`; CTest passes it. `MAINTENANCE.md` documents the fence-tag convention |
|
||||||
| `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half |
|
| `docs_screenshots` | 19/19 figures re-rendered and byte-identical to the checked-in PNGs. AKGL build only — rendering a picture needs the SDL half |
|
||||||
| Golden corpus | 41/41 byte-exact from `tests/reference/` — **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
|
| Language corpus | 65/65 paired expectations — **and 65/65 again through the SDL binary**, which is most of what proves the frontend changes no output |
|
||||||
| ASan + UBSan | 112/112 |
|
| ASan + UBSan | 112/112 |
|
||||||
| Line coverage | 94.1% (7227/7681) — above the 90% gate |
|
| Line coverage | 94.1% (7227/7681) — above the 90% gate |
|
||||||
| Function coverage | 97.9% (474/484) |
|
| Function coverage | 97.9% (474/484) |
|
||||||
@@ -2553,7 +2552,7 @@ What remains, in priority order:
|
|||||||
this whole arrangement exists against.
|
this whole arrangement exists against.
|
||||||
|
|
||||||
The byte comparison is a deliberate bet that the dummy driver and the software renderer
|
The byte comparison is a deliberate bet that the dummy driver and the software renderer
|
||||||
are reproducible, which is the same bet `tests/reference/` already makes about golden
|
are reproducible, which is the same bet `tests/language/` makes about golden
|
||||||
output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade
|
output. Verified run-to-run and build-to-build here; what is untested is an SDL upgrade
|
||||||
that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same
|
that moves one pixel of a diagonal. **If that happens, regenerate the figures in the same
|
||||||
commit as the bump** — do not weaken the test to a size check, which would pass for every
|
commit as the bump** — do not weaken the test to a size check, which would pass for every
|
||||||
@@ -2636,7 +2635,7 @@ reduced against `build/basic`, the stdio build, unless it says otherwise.
|
|||||||
|
|
||||||
**It is narrower than it looks, and the golden corpus is why.** The first attempt
|
**It is narrower than it looks, and the golden corpus is why.** The first attempt
|
||||||
released the scope on *any* skip, which broke
|
released the scope on *any* skip, which broke
|
||||||
`tests/reference/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero-
|
`tests/language/flowcontrol/nestedforloopwaitingforcommand.bas`: a zero-
|
||||||
iteration `FOR` skips its body by the same mechanism, and there the orphan is
|
iteration `FOR` skips its body by the same mechanism, and there the orphan is
|
||||||
load-bearing -- it is what absorbs the inner `NEXT` so the outer one still finds its
|
load-bearing -- it is what absorbs the inner `NEXT` so the outer one still finds its
|
||||||
`FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the
|
`FOR`. Releasing it turns that case into "NEXT outside the context of FOR". So the
|
||||||
|
|||||||
@@ -100,50 +100,6 @@ 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
|
||||||
|
|||||||
@@ -743,8 +743,8 @@ way to see that something pushed a scope and never popped it.
|
|||||||
|
|
||||||
```sh norun
|
```sh norun
|
||||||
ctest --test-dir build --output-on-failure -R for_next # one unit test
|
ctest --test-dir build --output-on-failure -R for_next # one unit test
|
||||||
ctest --test-dir build --output-on-failure -R golden_ # the reference corpus
|
ctest --test-dir build --output-on-failure -R local_ # the language corpus
|
||||||
./build/basic tests/reference/language/functions.bas | diff - tests/reference/language/functions.txt
|
./build/basic tests/language/functions.bas | diff - tests/language/functions.txt
|
||||||
./tests/docs_examples.sh --root . --basic ./build/basic \
|
./tests/docs_examples.sh --root . --basic ./build/basic \
|
||||||
--cflags-file build/docs_cflags.txt docs/14-architecture.md
|
--cflags-file build/docs_cflags.txt docs/14-architecture.md
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ nothing would say which type it is.
|
|||||||
```
|
```
|
||||||
|
|
||||||
```output
|
```output
|
||||||
? 40 : PARSE ERROR TYPE POINT: POINT is a reserved word and cannot name a type
|
? 10 : PARSE ERROR TYPE POINT: POINT is a reserved word and cannot name a type
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 12
|
that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32
|
||||||
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
|
||||||
twelfth one:
|
thirty-second 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 (12 in use)
|
? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,849 +0,0 @@
|
|||||||
# 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);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Four conventions worth keeping:
|
|
||||||
|
|
||||||
- **`akgl_collide_rectangles()` is the whole collision system.** At most 2
|
|
||||||
shots x 40 enemies of axis-aligned tests per frame is noise; the full
|
|
||||||
`akgl_CollisionWorld` machinery earns its keep on tilemaps, not here. The
|
|
||||||
`shot_box`/`enemy_box` helpers inset each box from the artwork's rectangle,
|
|
||||||
because the PNGs carry transparent margin that should not kill anybody.
|
|
||||||
- **Releasing is despawning.** `akgl_heap_release_actor()` unregisters the
|
|
||||||
actor and stops it drawing; releasing mid-sweep is safe because
|
|
||||||
`akgl_game_update()` re-reads each slot's refcount as it goes.
|
|
||||||
- **Names carry a serial** — `pshot17`, not `pshot1` reused — because the actor
|
|
||||||
registry is keyed by name, and two live actors with one name is a fight.
|
|
||||||
- **Spawn caps are C-side refusals.** Two player shots, eight enemy shots; the
|
|
||||||
spawn functions simply decline past the cap.
|
|
||||||
|
|
||||||
Give the enemy shots the same shape falling downward, and the ship a sweep over
|
|
||||||
both — `examples/galaga/player.c` has all three loops.
|
|
||||||
|
|
||||||
Explosions are the fourth actor kind, and they carry the one place this game
|
|
||||||
*absorbs* an error instead of propagating it. `HANDLE` names the status it
|
|
||||||
forgives; everything else still travels:
|
|
||||||
|
|
||||||
```c wrap=galagagame requires=akgl
|
|
||||||
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
|
|
||||||
static uint32_t BOOM_SERIAL = 0;
|
|
||||||
|
|
||||||
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
|
|
||||||
{
|
|
||||||
ptrdiff_t slot = 0;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
slot = obj - akgl_heap_actors;
|
|
||||||
BOOM_TTL[slot] -= galaga_game.dt;
|
|
||||||
if ( BOOM_TTL[slot] <= 0.0f ) {
|
|
||||||
PASS(errctx, akgl_heap_release_actor(obj));
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
|
|
||||||
{
|
|
||||||
akgl_Actor *boom = NULL;
|
|
||||||
char name[32];
|
|
||||||
int count = 0;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
ATTEMPT {
|
|
||||||
CATCH(errctx, akgl_heap_next_actor(&boom));
|
|
||||||
BOOM_SERIAL += 1;
|
|
||||||
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
|
|
||||||
CATCH(errctx, akgl_actor_initialize(boom, name));
|
|
||||||
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
|
|
||||||
boom->updatefunc = &boom_update;
|
|
||||||
boom->movement_controls_face = false;
|
|
||||||
boom->state = AKGL_ACTOR_STATE_ALIVE;
|
|
||||||
boom->visible = true;
|
|
||||||
boom->x = x;
|
|
||||||
boom->y = y;
|
|
||||||
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
|
|
||||||
} CLEANUP {
|
|
||||||
} PROCESS(errctx) {
|
|
||||||
} HANDLE(errctx, AKGL_ERR_HEAP) {
|
|
||||||
/* Explosions are decoration. When the heap is momentarily full the
|
|
||||||
* right outcome is no explosion, not a dead frame. */
|
|
||||||
} FINISH(errctx, true);
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Boot the interpreter
|
|
||||||
|
|
||||||
**Goal: the engine calls a BASIC function and prints its answer.**
|
|
||||||
|
|
||||||
Everything so far was libakgl. Now link the interpreter into the same
|
|
||||||
executable. The whole CMake recipe, inside an akbasic checkout with
|
|
||||||
`AKBASIC_WITH_AKGL=ON`:
|
|
||||||
|
|
||||||
```cmake
|
|
||||||
add_executable(mygalaga
|
|
||||||
main.c
|
|
||||||
script.c
|
|
||||||
enemies.c
|
|
||||||
player.c)
|
|
||||||
target_compile_options(mygalaga PRIVATE -Wall -Wextra)
|
|
||||||
target_compile_definitions(mygalaga PRIVATE
|
|
||||||
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
|
|
||||||
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas"
|
|
||||||
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
|
|
||||||
target_link_libraries(mygalaga PRIVATE akbasic akgl
|
|
||||||
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
|
||||||
```
|
|
||||||
|
|
||||||
The three baked-in paths are what let the program launch from any working
|
|
||||||
directory; `--assets` and `--script` flags can override them at runtime.
|
|
||||||
|
|
||||||
Link `akbasic` — the interpreter only. Not `akbasic_akgl` (the device backends
|
|
||||||
that let a script draw), and not `akbasic_frontend` (the standalone program's
|
|
||||||
host). This game lends the script **no devices at all**: the scripts compute,
|
|
||||||
the engine draws, and a script that tries `SPRITE` is refused by name. That
|
|
||||||
refusal is enforced by the interpreter, not by convention —
|
|
||||||
[Chapter 10](10-embedding.md) explains the device-lending model this game
|
|
||||||
declines to use.
|
|
||||||
|
|
||||||
The boot is the embedding host from Chapter 10, adapted to a script that only
|
|
||||||
defines. Keep every line that touches the interpreter in one file — the
|
|
||||||
example's `script.c` — so the boundary stays a place rather than a habit. That
|
|
||||||
file's interpreter-facing includes and statics, exactly:
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
#include <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());
|
|
||||||
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
|
|
||||||
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
|
|
||||||
|
|
||||||
CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
|
|
||||||
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
|
||||||
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
|
|
||||||
CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
|
|
||||||
```
|
|
||||||
|
|
||||||
Two of those lines are the ones a first embedding gets wrong.
|
|
||||||
|
|
||||||
**A "no top level code" script still has to run once.** The script is nothing
|
|
||||||
but `DEF` blocks and a final `END`, and executing the `DEF` statements is what
|
|
||||||
files the functions. The run is bounded — a script that is all definitions has
|
|
||||||
no business taking more than a few steps per line, and an accidental loop at
|
|
||||||
boot should be a diagnosis, not a hang.
|
|
||||||
|
|
||||||
**The `set_mode` after the run is load-bearing.** The program has now ended and
|
|
||||||
the runtime sits in QUIT mode, where a multi-line `DEF` called from the host
|
|
||||||
returns a silent zero. Forcing the mode back to RUN makes the bodies run, and it
|
|
||||||
stays put because nothing here ever steps the runtime again. Issue #8 tracks
|
|
||||||
making this workaround unnecessary.
|
|
||||||
|
|
||||||
`PRINT` inside the script goes through the stdio sink and lands on stdout —
|
|
||||||
that is the script's debug channel for the rest of both chapters.
|
|
||||||
|
|
||||||
Prove the wiring with one function. Put this in the script:
|
|
||||||
|
|
||||||
```basic
|
|
||||||
DEF ADDEM(A#, B#) = A# + B#
|
|
||||||
END
|
|
||||||
```
|
|
||||||
|
|
||||||
And call it from C, with values you already have:
|
|
||||||
|
|
||||||
```c wrap=galagacalls requires=akgl
|
|
||||||
memset(&args[0], 0, sizeof(args[0]));
|
|
||||||
memset(&args[1], 0, sizeof(args[1]));
|
|
||||||
args[0].valuetype = AKBASIC_TYPE_INTEGER;
|
|
||||||
args[0].intval = 17;
|
|
||||||
args[1].valuetype = AKBASIC_TYPE_INTEGER;
|
|
||||||
args[1].intval = 25;
|
|
||||||
argp[0] = &args[0];
|
|
||||||
argp[1] = &args[1];
|
|
||||||
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result));
|
|
||||||
printf("ADDEM(17, 25) = %lld\n", (long long)result->intval);
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
ADDEM(17, 25) = 42
|
|
||||||
```
|
|
||||||
|
|
||||||
`akbasic_runtime_call_function()` is the host's entry point: a name and
|
|
||||||
already-evaluated values in, the function's result out. The engine refuses to
|
|
||||||
start when the script will not boot — a game whose enemies cannot think is not
|
|
||||||
a game missing a feature, it is a game that does not run.
|
|
||||||
|
|
||||||
## Step 6: The update hook
|
|
||||||
|
|
||||||
**Goal: one actor whose every frame is a BASIC call.**
|
|
||||||
|
|
||||||
`akgl_game_update()` calls each live actor's `updatefunc` exactly once per
|
|
||||||
frame. Replacing that pointer is the whole integration: the actor's frame *is*
|
|
||||||
a script call.
|
|
||||||
|
|
||||||
```c wrap=galagagame requires=akgl
|
|
||||||
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
|
|
||||||
{
|
|
||||||
galaga_Enemy *enemy = NULL;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
|
||||||
enemy = (galaga_Enemy *)obj->actorData;
|
|
||||||
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
|
|
||||||
|
|
||||||
enemy->rnd = galaga_random();
|
|
||||||
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
|
|
||||||
if ( enemy->fire != 0 ) {
|
|
||||||
PASS(errctx, enemy_fire(enemy, obj));
|
|
||||||
}
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The hook's body is a protocol, and `galaga_script_update_enemy()` is its
|
|
||||||
middle: **rebind, call, recover, reset.**
|
|
||||||
|
|
||||||
```c wrap=galagacalls requires=akgl
|
|
||||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
|
|
||||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
|
|
||||||
|
|
||||||
memset(&dtval, 0, sizeof(dtval));
|
|
||||||
dtval.valuetype = AKBASIC_TYPE_FLOAT;
|
|
||||||
dtval.floatval = (double)dt;
|
|
||||||
argp[0] = &dtval;
|
|
||||||
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result));
|
|
||||||
|
|
||||||
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
|
|
||||||
```
|
|
||||||
|
|
||||||
`SELF@` and `ACTOR@` are **host bindings** — the enemy's record and the
|
|
||||||
engine's live actor, shared with the script as structures it can read and
|
|
||||||
write directly. [Chapter 21](21-tutorial-galaga-enemies.md) builds them; for
|
|
||||||
this chapter, know that `akbasic_host_rebind()` points an existing binding at
|
|
||||||
a different instance, which is how forty enemies share one script: one name,
|
|
||||||
rebound per enemy, rather than forty names.
|
|
||||||
|
|
||||||
**The `akbasic_environment_zero()` after every call is load-bearing.** Each
|
|
||||||
call parks its result in the caller environment's per-line value scratch, and a
|
|
||||||
host calling in a loop never crosses the line boundary that would reset it.
|
|
||||||
Without this line the scratch drains in under two frames of a 40-enemy wave and
|
|
||||||
every later call fails with `Maximum values per line reached`. Chapter 10's
|
|
||||||
["Calling a function every frame"](10-embedding.md#calling-a-function-every-frame)
|
|
||||||
section is the rule's home.
|
|
||||||
|
|
||||||
## Step 7: First light
|
|
||||||
|
|
||||||
**Goal: a C actor moving under BASIC control, and proof it is one memory.**
|
|
||||||
|
|
||||||
Before any real AI, the smallest demonstration. One enemy, one function, a sine
|
|
||||||
drift written entirely in BASIC through the actor binding:
|
|
||||||
|
|
||||||
```basic
|
|
||||||
DEF UPDATEBEE(DT%)
|
|
||||||
SELF@.T% = SELF@.T% + DT%
|
|
||||||
ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200
|
|
||||||
ACTOR@.Y% = 300.0
|
|
||||||
PRINT "BASIC SEES X = " + ACTOR@.X%
|
|
||||||
RETURN 0
|
|
||||||
END
|
|
||||||
```
|
|
||||||
|
|
||||||
Spawn one enemy with the hook from Step 6, and have the engine print the same
|
|
||||||
actor's position each frame from C:
|
|
||||||
|
|
||||||
```c wrap=galagahost requires=akgl
|
|
||||||
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
BASIC SEES X = 593.191094
|
|
||||||
INFO: C SEES X = 593.191094
|
|
||||||
BASIC SEES X = 596.378593
|
|
||||||
INFO: C SEES X = 596.378593
|
|
||||||
```
|
|
||||||
|
|
||||||
Same numbers, one memory. The script wrote `ACTOR@.X%`; the renderer read
|
|
||||||
`akgl_Actor.x`; nothing copied anything anywhere. The ship swings in a slow
|
|
||||||
arc, and the whole architecture is visible in that one motion: C owns the
|
|
||||||
frame, BASIC owns the decision, and the actor is the same bytes to both.
|
|
||||||
|
|
||||||
## Step 8: Screens
|
|
||||||
|
|
||||||
**Goal: title, playing, game over, victory — a state machine around the loop.**
|
|
||||||
|
|
||||||
The screens are libakgl's UI layer, in the three-state pattern of its uidemo
|
|
||||||
example (libakgl docs/22-ui.md). A `galaga_Screen` enum, one `declare_*()`
|
|
||||||
function per screen, and the UI bracket between `akgl_game_update()` and
|
|
||||||
`frame_end` — exactly where the frame contract puts it:
|
|
||||||
|
|
||||||
```c wrap=galagahost requires=akgl
|
|
||||||
CATCH(errctx, akgl_ui_frame_begin());
|
|
||||||
switch ( galaga_game.screen ) {
|
|
||||||
case GALAGA_SCREEN_TITLE:
|
|
||||||
CATCH(errctx, declare_title());
|
|
||||||
break;
|
|
||||||
case GALAGA_SCREEN_PLAY:
|
|
||||||
CATCH(errctx, declare_play());
|
|
||||||
break;
|
|
||||||
case GALAGA_SCREEN_GAMEOVER:
|
|
||||||
case GALAGA_SCREEN_VICTORY:
|
|
||||||
CATCH(errctx, declare_end());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
CATCH(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
||||||
```
|
|
||||||
|
|
||||||
The playing screen is two `akgl_ui_label()` calls — a widget call per label,
|
|
||||||
not a struct — formatted into `static` buffers, because the UI borrows label
|
|
||||||
text until `frame_end` and a local buffer would be dangling by the time it
|
|
||||||
draws:
|
|
||||||
|
|
||||||
```c wrap=galagagame requires=akgl
|
|
||||||
static char HUD_SCORE[64];
|
|
||||||
static char HUD_LIVES[64];
|
|
||||||
|
|
||||||
static akerr_ErrorContext *declare_play(void)
|
|
||||||
{
|
|
||||||
int count = 0;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE),
|
|
||||||
"SCORE %06d", galaga_game.score));
|
|
||||||
PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES),
|
|
||||||
"LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave));
|
|
||||||
PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
|
|
||||||
PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The title and end screens are an `akgl_ui_menu()` at the center, fed an
|
|
||||||
`akgl_UiMenu` that lives in a `static` for the same borrowing reason. The
|
|
||||||
struct is an id, the item strings, a count, the selected index, the
|
|
||||||
`activated` output flag, and a style (`NULL` for the default):
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
static akgl_UiMenu TITLE_MENU = {
|
|
||||||
"titlemenu", { "START", "QUIT" }, 2, 0, false, NULL
|
|
||||||
};
|
|
||||||
|
|
||||||
static akerr_ErrorContext *declare_title(void)
|
|
||||||
{
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
PASS(errctx, akgl_ui_menu(&TITLE_MENU));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Route events to the menu with
|
|
||||||
`akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed)` — the menu for
|
|
||||||
whichever screen is up, a `bool` out-parameter reporting whether the event was
|
|
||||||
taken. Up and down move `selected`, return sets `activated`.
|
|
||||||
|
|
||||||
The big **GALAGA** headline is direct text rather than a label:
|
|
||||||
|
|
||||||
```c wrap=galagagame requires=akgl
|
|
||||||
static akerr_ErrorContext *draw_banner(char *text)
|
|
||||||
{
|
|
||||||
SDL_Color ink = { 235, 235, 235, 255 };
|
|
||||||
TTF_Font *font = NULL;
|
|
||||||
int w = 0;
|
|
||||||
int h = 0;
|
|
||||||
PREPARE_ERROR(errctx);
|
|
||||||
|
|
||||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
|
|
||||||
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
|
|
||||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
|
|
||||||
PASS(errctx, akgl_text_measure(font, text, &w, &h));
|
|
||||||
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0, (1280 - w) / 2, 280));
|
|
||||||
SUCCEED_RETURN(errctx);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The menu owns `AKGL_UI_ANCHOR_CENTER`, a label anchored there disappears
|
|
||||||
behind it, and there is no top-center anchor — so the headline measures itself
|
|
||||||
and draws at a coordinate, before the UI bracket so the menu still paints over
|
|
||||||
it if the two ever meet.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Screen transitions are three rules read after the world updates: lives spent is
|
|
||||||
GAME OVER, an empty wave is VICTORY, and a menu activation either restarts or
|
|
||||||
quits. The menu never clears its own `activated` flag — the state machine that
|
|
||||||
acts on it does.
|
|
||||||
|
|
||||||
## Step 9: Run it headless
|
|
||||||
|
|
||||||
**Goal: the same game, playable by a script, in CI every night.**
|
|
||||||
|
|
||||||
The example takes five flags, in the pattern of libakgl's sidescroller:
|
|
||||||
|
|
||||||
```sh norun
|
|
||||||
$ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SDL_RENDER_DRIVER=software \
|
|
||||||
./build-akgl/akbasic_example_galaga --frames 600 --autoplay
|
|
||||||
```
|
|
||||||
|
|
||||||
`--frames N` bounds the run; `--autoplay` is a scripted pilot that starts the
|
|
||||||
game, sweeps the floor and holds fire until the wave assembles; `--screenshot
|
|
||||||
PATH --screenshot-frame N` write a PNG from the render target — the figures in
|
|
||||||
this chapter are that flag's output, not pictures somebody took once. Synthetic
|
|
||||||
input goes through `akgl_controller_handle_event()` with constructed
|
|
||||||
`SDL_Event`s, never by calling the handlers directly — the point of autoplay is
|
|
||||||
to exercise the same path a keyboard does.
|
|
||||||
|
|
||||||
The last line of every run is the evidence:
|
|
||||||
|
|
||||||
```text
|
|
||||||
galaga: 600 frames, screen 1, score 1910, alive 9, kills bee 19 bfly 12 boss 0, shots bee 0 bfly 3 boss 0, script errors 0
|
|
||||||
```
|
|
||||||
|
|
||||||
Exiting 0 is not proof the wave flew. The readout is: kills and shots counted
|
|
||||||
per kind say the enemies entered, thought and fired, and **`script errors 0`**
|
|
||||||
says every one of the ~24,000 BASIC calls in those ten seconds came back clean.
|
|
||||||
A wave of dumb enemies still exits 0, and that count is how you notice. The
|
|
||||||
CTest entry `example_galaga` runs exactly this under the dummy SDL drivers,
|
|
||||||
which is what keeps both chapters honest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
That is the engine: a window, a starfield, a ship, bullets, screens, and an
|
|
||||||
interpreter that answers when called. Everything on screen so far is C. What
|
|
||||||
turns it into a GALAGA is [Chapter 21](21-tutorial-galaga-enemies.md) — the
|
|
||||||
three shared structures, the script that thinks through them, and a full wave
|
|
||||||
entering, breathing, diving and firing without another line of engine code.
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
# 21. Tutorial: GALAGA — the structures and the AI
|
|
||||||
|
|
||||||
[Chapter 20](20-tutorial-galaga.md) built a C engine that boots the interpreter
|
|
||||||
and hands one actor to BASIC. This chapter builds everything that crosses the
|
|
||||||
boundary — the three shared structures — and then the script that thinks
|
|
||||||
through them: a full wave that enters, forms up, breathes, dives, fires and
|
|
||||||
dies, without another line of engine code.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The finished script is
|
|
||||||
[`examples/galaga/galaga.bas`](../examples/galaga/galaga.bas) — six `DEF`
|
|
||||||
functions and an `END`, nothing else. Editing it and re-running the game is the
|
|
||||||
whole development loop; the engine never rebuilds.
|
|
||||||
|
|
||||||
## What you will do
|
|
||||||
|
|
||||||
- **[Step 1](#step-1-declare-the-enemy-once-in-c)** — declare the enemy record
|
|
||||||
once, in C, and register it as a BASIC type
|
|
||||||
- **[Step 2](#step-2-bind-the-engines-own-actor)** — bind the engine's own
|
|
||||||
actor as the second type, which is the point of the whole exercise
|
|
||||||
- **[Step 3](#step-3-share-the-frame-and-the-dice)** — share the frame state,
|
|
||||||
and give the script randomness it cannot make itself
|
|
||||||
- **[Step 4](#step-4-why-bindings-and-not-arguments)** — see why the structures
|
|
||||||
are bindings rather than function arguments
|
|
||||||
- **[Step 5](#step-5-the-shape-of-the-script)** — learn the three language
|
|
||||||
rules that shape every enemy function
|
|
||||||
- **[Step 6](#step-6-the-shared-maneuvers)** — write the shared maneuvers:
|
|
||||||
glide home, dive, decide to fire
|
|
||||||
- **[Step 7](#step-7-the-three-kinds)** — write the bee, the butterfly and the
|
|
||||||
boss
|
|
||||||
- **[Step 8](#step-8-the-formation-c-or-basic)** — decide who owns the
|
|
||||||
formation, and lay it out
|
|
||||||
- **[Step 9](#step-9-when-a-script-dies)** — decide what a script error does to
|
|
||||||
the game, and make it do that
|
|
||||||
- **[Step 10](#step-10-prove-it)** — prove the boundary with a test that links
|
|
||||||
the real files
|
|
||||||
- **[Step 11](#step-11-the-cost-measured)** — measure what thinking in BASIC
|
|
||||||
costs, against the same logic in C
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Declare the enemy once, in C
|
|
||||||
|
|
||||||
**Goal: one struct that both languages read and write, with one source of truth.**
|
|
||||||
|
|
||||||
An enemy is what the state machine needs to remember between frames, plus one
|
|
||||||
inbox and one outbox:
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
#define GALAGA_ENEMY_BEE 0
|
|
||||||
#define GALAGA_ENEMY_BUTTERFLY 1
|
|
||||||
#define GALAGA_ENEMY_BOSS 2
|
|
||||||
|
|
||||||
/*
|
|
||||||
* galaga_Enemy.state bits. The script owns these transitions; the engine only
|
|
||||||
* writes the word at spawn.
|
|
||||||
*
|
|
||||||
* 8 0
|
|
||||||
* 0 0 0 0 0 1 1 1
|
|
||||||
* | | `-- ENTERING: flying its entry path toward the formation slot
|
|
||||||
* | `---- FORMATION: holding (and breathing around) homex/homey
|
|
||||||
* `------ DIVING: attacking, off the grid until it leaves the screen
|
|
||||||
*/
|
|
||||||
#define GALAGA_ES_ENTERING (1 << 0)
|
|
||||||
#define GALAGA_ES_FORMATION (1 << 1)
|
|
||||||
#define GALAGA_ES_DIVING (1 << 2)
|
|
||||||
|
|
||||||
typedef struct galaga_Enemy
|
|
||||||
{
|
|
||||||
int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */
|
|
||||||
int32_t state; /* GALAGA_ES_* bit flags */
|
|
||||||
float homex; /* formation slot, in map pixels */
|
|
||||||
float homey;
|
|
||||||
float t; /* parametric clock for the current maneuver */
|
|
||||||
int32_t hp;
|
|
||||||
int32_t fire; /* outbox: script sets 1, engine consumes */
|
|
||||||
float rnd; /* inbox: engine writes fresh 0..1 each call */
|
|
||||||
} galaga_Enemy;
|
|
||||||
```
|
|
||||||
|
|
||||||
The C struct *is* the BASIC type. `akbasic_host_register_type()` takes a table
|
|
||||||
of field descriptors — the BASIC name with its suffix, the C representation,
|
|
||||||
and where the member sits — and after that the language's own machinery works
|
|
||||||
across the boundary with no second set of rules
|
|
||||||
([Chapter 16](16-structures.md)):
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
typedef struct galaga_Enemy
|
|
||||||
{
|
|
||||||
int32_t kind;
|
|
||||||
int32_t state;
|
|
||||||
float homex;
|
|
||||||
float homey;
|
|
||||||
float t;
|
|
||||||
int32_t hp;
|
|
||||||
int32_t fire;
|
|
||||||
float rnd;
|
|
||||||
} galaga_Enemy;
|
|
||||||
|
|
||||||
static const akbasic_HostField ENEMY_FIELDS[] = {
|
|
||||||
/* struct member BASIC name C representation */
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ),
|
|
||||||
AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
|
|
||||||
};
|
|
||||||
static const akbasic_HostType ENEMY_TYPE = {
|
|
||||||
"ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Three decisions are load-bearing here:
|
|
||||||
|
|
||||||
- **`AKBASIC_HOST_FIELD` takes the offset and the width from the member
|
|
||||||
itself**, via `offsetof` — so the two sides cannot drift. Writing them out by
|
|
||||||
hand is two chances to name the wrong member and no way to notice.
|
|
||||||
- **The script never declares a `TYPE`.** A host type and a script `TYPE` share
|
|
||||||
one namespace, and a script that tries to redeclare `ENEMY` is refused. The
|
|
||||||
"structure definitions" half of the boundary lives here, once.
|
|
||||||
- **The suffixes are the dialect's**: `#` is integer, `%` is float
|
|
||||||
([Chapter 3](03-the-language.md)). `HOMEX%` because a formation slot is a
|
|
||||||
pixel coordinate the glide arithmetic must not truncate.
|
|
||||||
|
|
||||||
The limits that shape the struct: a type may carry 16 fields and the runtime 16
|
|
||||||
types ([Chapter 16](16-structures.md)). `ENEMY` spends 8 fields; the game
|
|
||||||
spends 3 types.
|
|
||||||
|
|
||||||
## Step 2: Bind the engine's own actor
|
|
||||||
|
|
||||||
**Goal: the script writes the same bytes the renderer reads.**
|
|
||||||
|
|
||||||
The enemy record is the game's own invention. The second type is not — it is
|
|
||||||
libakgl's `akgl_Actor`, registered field-for-field over the engine's real
|
|
||||||
struct:
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
static const akbasic_HostField ACTOR_FIELDS[] = {
|
|
||||||
AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
|
|
||||||
AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ),
|
|
||||||
AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
|
|
||||||
AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL )
|
|
||||||
};
|
|
||||||
static const akbasic_HostType ACTOR_TYPE = {
|
|
||||||
"ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the demonstrative point of the whole exercise. When the script writes
|
|
||||||
`ACTOR@.X%`, it writes `akgl_Actor.x` — the same memory the renderer reads on
|
|
||||||
the same frame. There is no copy going in, no copy coming out, and no code
|
|
||||||
between the script's decision and the engine's pixel. Null physics
|
|
||||||
(Chapter 20, Step 1) is what makes that safe: nothing else is trying to move
|
|
||||||
the actor.
|
|
||||||
|
|
||||||
Registration and the first binding happen at boot, before the script loads —
|
|
||||||
between `akbasic_runtime_init()` and `akbasic_runtime_load()` in Chapter 20's
|
|
||||||
boot sequence. A binding is **borrowed, never copied**, so the placeholders it
|
|
||||||
points at must be static storage:
|
|
||||||
|
|
||||||
```c wrap=galagacalls requires=akgl
|
|
||||||
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE));
|
|
||||||
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE));
|
|
||||||
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE));
|
|
||||||
|
|
||||||
CATCH(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY));
|
|
||||||
CATCH(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR));
|
|
||||||
CATCH(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared));
|
|
||||||
```
|
|
||||||
|
|
||||||
`akbasic_host_bind()` takes the script name, the registered type's name, and
|
|
||||||
the instance; after that, `SELF@` and `ACTOR@` are only ever *re*bound.
|
|
||||||
|
|
||||||
The per-frame call binds both names to *this* enemy before dispatching — one
|
|
||||||
binding per name, pointed at forty enemies in turn, which is what
|
|
||||||
`akbasic_host_rebind()` is for:
|
|
||||||
|
|
||||||
```c wrap=galagacalls requires=akgl
|
|
||||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
|
|
||||||
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Share the frame, and the dice
|
|
||||||
|
|
||||||
**Goal: everything a diving enemy needs to know about the world, in one record.**
|
|
||||||
|
|
||||||
```c wrap=galagatypes requires=akgl
|
|
||||||
typedef struct galaga_Shared
|
|
||||||
{
|
|
||||||
float playerx; /* the player actor's position, this frame */
|
|
||||||
float playery;
|
|
||||||
int32_t wave;
|
|
||||||
float rnd; /* fresh 0..1 each frame; the issue #16 route */
|
|
||||||
} galaga_Shared;
|
|
||||||
```
|
|
||||||
|
|
||||||
`GAME@` is bound once at boot to this one global instance and never rebound;
|
|
||||||
the engine refreshes it at the top of every frame. The boss reads
|
|
||||||
`GAME@.PLAYERX%` to lead its dive; the fire decision reads it to know whether
|
|
||||||
anything is worth shooting at.
|
|
||||||
|
|
||||||
The `rnd` fields — one here per frame, one on each enemy per call — exist
|
|
||||||
because the engine's PRNG is the script's **only** source of randomness: write
|
|
||||||
`SELF@.RND% < DT% * 1.5` and an enemy's trigger finger is a dice roll. There
|
|
||||||
is no `RND` verb in this dialect; issue #16 tracks adding one, and Chapter
|
|
||||||
17's breakout hand-rolls a linear congruential generator in BASIC as the other
|
|
||||||
route. Here the engine fills the field, which also keeps a headless run the
|
|
||||||
same game on every machine — the PRNG is the example's own, not libc's.
|
|
||||||
|
|
||||||
## Step 4: Why bindings, and not arguments
|
|
||||||
|
|
||||||
**Goal: know why `SELF@` is a bound global rather than a parameter.**
|
|
||||||
|
|
||||||
The language can pass structures to functions — by value with `E@ AS ENEMY`,
|
|
||||||
by reference with `E@ AS PTR TO ENEMY` ([Chapter 16](16-structures.md)) — and
|
|
||||||
a host can construct those argument values, so the obvious alternative
|
|
||||||
interface is honest functions:
|
|
||||||
|
|
||||||
```basic norun
|
|
||||||
DEF UPDATEBEE(E@ AS PTR TO ENEMY, A@ AS PTR TO ACTOR, G@ AS PTR TO GAME, DT%)
|
|
||||||
```
|
|
||||||
|
|
||||||
It was measured before this chapter chose. Pointer arguments work — writes
|
|
||||||
through `E@->X%` land in the host struct, the type check refuses a wrong type,
|
|
||||||
by-value copies exactly as documented. What rules them out is the pool math:
|
|
||||||
|
|
||||||
| | bound globals | pointer arguments |
|
|
||||||
|---|---|---|
|
|
||||||
| value-pool slots per call | 0 | 1 per structure parameter, never returned |
|
|
||||||
| calls before exhaustion | unbounded | 1,015 measured (2,048-slot pool, 2 pointer args) |
|
|
||||||
| at 40 enemies per frame | unbounded | 25 frames |
|
|
||||||
| per-call cost | 148 us | 251 us |
|
|
||||||
|
|
||||||
A `@`-suffixed name always takes value-pool storage, and that pool never
|
|
||||||
reclaims — a documented property of structures, because a pointer may outlive
|
|
||||||
the scope that `DIM`med it. A *parameter* is a local that dies with the call,
|
|
||||||
but it pays the storage price of a `DIM` that must survive one; the pool
|
|
||||||
drains, and the wave stops thinking mid-flight. Issue #36 tracks it, with the
|
|
||||||
reduction for whoever fixes it. Until then: **bind and rebind for per-frame
|
|
||||||
host calls; pass structures only to functions called a bounded number of
|
|
||||||
times.**
|
|
||||||
|
|
||||||
## Step 5: The shape of the script
|
|
||||||
|
|
||||||
**Goal: the three rules every enemy function is written under.**
|
|
||||||
|
|
||||||
`galaga.bas` is definitions and an `END` — no top-level code, no line numbers,
|
|
||||||
no `LABEL`s. Three rules of the dialect shape every body in it.
|
|
||||||
|
|
||||||
**Rule 1: the left operand decides integer or float arithmetic**
|
|
||||||
([Chapter 3](03-the-language.md)). This will bite every enemy script exactly
|
|
||||||
once, so meet it now. The natural spelling of "move by speed times dt" moves
|
|
||||||
nothing:
|
|
||||||
|
|
||||||
```basic norun
|
|
||||||
ACTOR@.Y% = ACTOR@.Y% + 260 * DT%
|
|
||||||
```
|
|
||||||
|
|
||||||
`260` is an integer, it is on the left of `*`, so `DT%` — a float around
|
|
||||||
0.016 — is converted to integer **zero** before the multiply. Nothing fails;
|
|
||||||
the enemy simply does not move. The working spelling puts the float first:
|
|
||||||
|
|
||||||
```basic norun
|
|
||||||
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
|
|
||||||
SPD% = SELF@.T% * 150 + 260
|
|
||||||
```
|
|
||||||
|
|
||||||
Every expression in the finished script is written float-first. When an enemy
|
|
||||||
of yours will not move, this is the first thing to check.
|
|
||||||
|
|
||||||
**Rule 2: only the last `RETURN` may start a line.** A multi-line `DEF` body
|
|
||||||
runs until `RETURN` — and the *definition* is scanned the same way, ending at
|
|
||||||
the first line that begins with one. An early return therefore always rides an
|
|
||||||
`IF ... THEN RETURN 0` on one line, and exactly one line-leading `RETURN` ends
|
|
||||||
each function. The stagger guard at the top of every update function is the
|
|
||||||
idiom:
|
|
||||||
|
|
||||||
```basic norun
|
|
||||||
DEF UPDATEBEE(DT%)
|
|
||||||
SELF@.T% = SELF@.T% + DT%
|
|
||||||
IF SELF@.T% < 0 THEN RETURN 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule 3: the budgets are small and named.** Eight function slots exist
|
|
||||||
(`AKBASIC_MAX_FUNCTIONS`), each a measured 36 KiB of the runtime's 2.40 MiB.
|
|
||||||
This game defines six: three update functions, two shared maneuvers, one fire
|
|
||||||
decision. Nesting draws from the twelve-slot environment pool exactly as
|
|
||||||
`GOSUB` does; the deepest chain here is three (update → maneuver → nothing).
|
|
||||||
If a design needs a ninth function, raising the limit is one `#define` and
|
|
||||||
+36 KiB per slot — weighed, not assumed.
|
|
||||||
|
|
||||||
## Step 6: The shared maneuvers
|
|
||||||
|
|
||||||
**Goal: three helpers that make the three kinds one page each.**
|
|
||||||
|
|
||||||
Ease toward the formation slot, with a little entry swirl. Answers 1 once the
|
|
||||||
slot is reached — the caller flips the state on that answer:
|
|
||||||
|
|
||||||
```basic
|
|
||||||
DEF GLIDEHOME(DT%)
|
|
||||||
DX% = SELF@.HOMEX% - ACTOR@.X%
|
|
||||||
DY% = SELF@.HOMEY% - ACTOR@.Y%
|
|
||||||
K% = DT% * 4.5
|
|
||||||
IF K% > 1 THEN K% = 1
|
|
||||||
ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT%
|
|
||||||
ACTOR@.Y% = ACTOR@.Y% + DY% * K%
|
|
||||||
IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1
|
|
||||||
RETURN 0
|
|
||||||
END
|
|
||||||
```
|
|
||||||
|
|
||||||
One frame of a dive: accelerate downward, weave, lean toward the player's
|
|
||||||
column, and glide back in from the top after falling out the bottom. The
|
|
||||||
weave and the lean are parameters, which is what makes three kinds out of one
|
|
||||||
maneuver:
|
|
||||||
|
|
||||||
```basic
|
|
||||||
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
|
|
||||||
SPD% = SELF@.T% * 150 + 260
|
|
||||||
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
|
|
||||||
ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT%
|
|
||||||
DX% = GAME@.PLAYERX% - ACTOR@.X%
|
|
||||||
IF DX% > 220 THEN DX% = 220
|
|
||||||
IF DX% < -220 THEN DX% = -220
|
|
||||||
ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT%
|
|
||||||
IF ACTOR@.Y% > 1040 THEN BEGIN
|
|
||||||
ACTOR@.Y% = 0.0 - 90
|
|
||||||
SELF@.STATE# = 1
|
|
||||||
SELF@.T% = 0
|
|
||||||
BEND
|
|
||||||
RETURN 0
|
|
||||||
END
|
|
||||||