Deliver GALAGA tutorial and akbasic enemy scripting example #39
@@ -268,6 +268,69 @@ if(AKBASIC_BUILD_EXAMPLES)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# The galaga example: a C game on libakgl with the interpreter embedded as its
|
||||
# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it
|
||||
# is compiled and run by every AKGL build rather than rotting in a document.
|
||||
# The asset, script and font paths are baked in so the smoke test can launch
|
||||
# from any working directory; --assets and --script override them at runtime.
|
||||
if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
|
||||
add_executable(akbasic_example_galaga
|
||||
examples/galaga/main.c
|
||||
examples/galaga/script.c
|
||||
examples/galaga/enemies.c
|
||||
examples/galaga/player.c)
|
||||
target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra)
|
||||
target_compile_definitions(akbasic_example_galaga PRIVATE
|
||||
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets"
|
||||
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas"
|
||||
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
|
||||
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
|
||||
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
|
||||
akbasic_instrument(akbasic_example_galaga)
|
||||
# Ten seconds of scripted play under the headless drivers: the script boots,
|
||||
# a wave enters and forms, the autoplay pilot shoots at it, and the program
|
||||
# tears down and exits 0. A tutorial that stops working fails here rather
|
||||
# than in front of a reader.
|
||||
_add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay)
|
||||
_set_tests_properties(example_galaga PROPERTIES TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
|
||||
|
||||
# The boundary's round-trip test: links the real script.c and the real
|
||||
# galaga.bas, and fails the moment the two sides of the interop disagree.
|
||||
add_executable(akbasic_example_galaga_interop
|
||||
examples/galaga/interop_test.c
|
||||
examples/galaga/script.c)
|
||||
target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra)
|
||||
target_compile_definitions(akbasic_example_galaga_interop PRIVATE
|
||||
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas")
|
||||
target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl
|
||||
SDL3::SDL3)
|
||||
akbasic_instrument(akbasic_example_galaga_interop)
|
||||
_add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop)
|
||||
_set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120)
|
||||
|
||||
# Regenerating the game figures in docs/ is a deliberate act, never part of
|
||||
# a build, for the same reason docs_screenshots is: the PNGs are checked in.
|
||||
# Wall-clock dt makes each regeneration differ by a few pixels of starfield,
|
||||
# so expect a binary diff every time this runs; commit one only when the
|
||||
# content changed on purpose. (docs_galaga_figures, not docs_game_figures:
|
||||
# the libakgl submodule already owns that target name.)
|
||||
add_custom_target(docs_galaga_figures
|
||||
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
|
||||
SDL_RENDER_DRIVER=software
|
||||
$<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.
|
||||
#
|
||||
|
||||
BIN
docs/images/galaga-title.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
docs/images/galaga-wave.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
63
examples/galaga/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# GALAGA — a C engine with akbasic embedded as its enemy brain
|
||||
|
||||
A GALAGA-style fixed shooter whose core engine is C on libakgl, with akbasic
|
||||
linked in as the scripting engine that owns every enemy's behavior. One BASIC
|
||||
script — `galaga.bas`, nothing but `DEF` functions and an `END` — is called
|
||||
once per enemy per frame through a custom `akgl_Actor` update hook. Bullets,
|
||||
collision, scoring and screens are C forever; everything an enemy *decides* is
|
||||
BASIC.
|
||||
|
||||
This is the checked-in example behind two tutorial chapters, and the chapters
|
||||
are the intended way in:
|
||||
|
||||
* **[Chapter 20](../../docs/20-tutorial-galaga.md)** — the engine and the
|
||||
boundary: from an empty file to a C game that boots a script and hands one
|
||||
actor to BASIC.
|
||||
* **[Chapter 21](../../docs/21-tutorial-galaga-enemies.md)** — the data
|
||||
structures and the AI: from `SELF@` to a full attacking wave.
|
||||
|
||||
It is an academic exercise demonstrating *how* such an embed is done, not a
|
||||
claim that it is the best way to write a GALAGA.
|
||||
|
||||
## Building and running
|
||||
|
||||
The example builds when both example and graphics builds are on:
|
||||
|
||||
```
|
||||
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
|
||||
cmake --build build-akgl --target akbasic_example_galaga
|
||||
build-akgl/akbasic_example_galaga
|
||||
```
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| Left / Right | move the ship |
|
||||
| Space | fire (two shots on screen, the classic rule) |
|
||||
| Return | choose a menu entry |
|
||||
|
||||
## Flags
|
||||
|
||||
```
|
||||
akbasic_example_galaga [--assets DIR] [--script PATH] [--frames N]
|
||||
[--autoplay] [--screenshot PATH] [--screenshot-frame N]
|
||||
```
|
||||
|
||||
`--script` points at a different enemy script, which is the whole point of the
|
||||
architecture: edit `galaga.bas`, run again, no rebuild. `--frames N` with
|
||||
`--autoplay` is the headless smoke test CI runs under the dummy SDL drivers;
|
||||
the final log line reports frames, score, kills and shots per kind, and the
|
||||
script-error count — a wave of dumb enemies still exits 0, and that count is
|
||||
how you notice.
|
||||
|
||||
## The files
|
||||
|
||||
| File | Owns |
|
||||
|---|---|
|
||||
| `galaga.h` | the shared structs — the whole boundary in one header |
|
||||
| `main.c` | startup order, the frame loop, screens, the starfield |
|
||||
| `script.c` | everything that touches the interpreter |
|
||||
| `enemies.c` | wave table, formation grid, the enemy update hook |
|
||||
| `player.c` | the ship, both bullet kinds, every collision |
|
||||
| `galaga.bas` | every decision an enemy makes |
|
||||
| `interop_test.c` | round-trip proof the boundary works, run by CTest |
|
||||
| `assets/` | sprite/character JSON, and Kenney CC0 art under `assets/art/` |
|
||||
14
examples/galaga/assets/art/License.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
###############################################################################
|
||||
|
||||
Space Shooter (Remastered, plus fonts and sounds) by Kenney Vleugels (www.kenney.nl)
|
||||
|
||||
------------------------------
|
||||
|
||||
License (CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
You may use these graphics in personal and commercial projects.
|
||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||
|
||||
###############################################################################
|
||||
39
examples/galaga/assets/art/PROVENANCE.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Where this art came from
|
||||
|
||||
Every PNG in this directory is from **Kenney's Space Shooter (Remastered)**, released
|
||||
into the public domain under
|
||||
[Creative Commons Zero](http://creativecommons.org/publicdomain/zero/1.0/).
|
||||
`License.txt` is the pack's own licence file, copied here unedited.
|
||||
|
||||
* Source: <https://kenney.nl/assets/space-shooter-remastered>
|
||||
* Downloaded: 2026-08-04, `kenney_space-shooter-remastered.zip`
|
||||
* Author: Kenney (<https://www.kenney.nl>)
|
||||
* Licence: CC0 1.0. Crediting is not required; it is here because it should be.
|
||||
|
||||
The files are the pack's `PNG/` versions, byte for byte — nothing is resized,
|
||||
recoloured or re-encoded, so the checksum of any of them still matches the
|
||||
distributed archive. `playerShip1_blue.png` sits at the top of `PNG/`; the enemies
|
||||
are from `PNG/Enemies/` and the lasers from `PNG/Lasers/`.
|
||||
|
||||
| File | Size | Used for |
|
||||
|---|---|---|
|
||||
| `playerShip1_blue.png` | 99x75 | the player's ship |
|
||||
| `enemyBlue1.png` | 93x84 | the bee |
|
||||
| `enemyRed2.png` | 104x84 | the butterfly |
|
||||
| `enemyGreen3.png` | 103x84 | the boss, at full health |
|
||||
| `enemyBlack3.png` | 103x84 | the boss at one hit point — same silhouette, drained colour |
|
||||
| `laserBlue01.png` | 9x54 | the player's shot |
|
||||
| `laserRed01.png` | 9x54 | an enemy's shot |
|
||||
| `laserBlue08.png` | 48x46 | the explosion burst |
|
||||
|
||||
The sprites are used at their distributed size: libakgl draws a sprite at the
|
||||
sprite's own dimensions (`akgl_Actor.scale` is overwritten every frame — libakgl
|
||||
docs/12-actors.md), so there is no way to draw these smaller, and the game's
|
||||
1280x960 view is sized to fit a ten-column formation of them instead. The boss's
|
||||
damage state is the same shape in a different colour deliberately: the swap has to
|
||||
read at a glance from the top of the screen.
|
||||
|
||||
Everything else on screen — the starfield and the HUD — is drawn by the program
|
||||
with `akgl_draw_point()` and the UI layer. See `../../README.md` for the run
|
||||
instructions and the two tutorial chapters (docs/20, docs/21) for why only the
|
||||
things that move are artwork.
|
||||
BIN
examples/galaga/assets/art/enemyBlack3.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
examples/galaga/assets/art/enemyBlue1.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
examples/galaga/assets/art/enemyGreen3.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
examples/galaga/assets/art/enemyRed2.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
examples/galaga/assets/art/laserBlue01.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
examples/galaga/assets/art/laserBlue08.png
Normal file
|
After Width: | Height: | Size: 882 B |
BIN
examples/galaga/assets/art/laserRed01.png
Normal file
|
After Width: | Height: | Size: 735 B |
BIN
examples/galaga/assets/art/playerShip1_blue.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
16
examples/galaga/assets/character_galaga_bee.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_bee",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_bee"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/character_galaga_boom.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_boom",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_boom"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
examples/galaga/assets/character_galaga_boss.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "galaga_boss",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_boss"
|
||||
},
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_13"
|
||||
],
|
||||
"sprite": "galaga_boss_hurt"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/character_galaga_butterfly.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_butterfly",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_butterfly"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/character_galaga_enemyshot.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_enemyshot",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_enemyshot"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/character_galaga_player.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_player",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_player"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/character_galaga_playershot.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "galaga_playershot",
|
||||
"speedtime": 200,
|
||||
"speed_x": 0.0,
|
||||
"speed_y": 0.0,
|
||||
"acceleration_x": 0.0,
|
||||
"acceleration_y": 0.0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE"
|
||||
],
|
||||
"sprite": "galaga_playershot"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_bee.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/enemyBlue1.png",
|
||||
"frame_width": 93,
|
||||
"frame_height": 84
|
||||
},
|
||||
"name": "galaga_bee",
|
||||
"width": 93,
|
||||
"height": 84,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_boom.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/laserBlue08.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 46
|
||||
},
|
||||
"name": "galaga_boom",
|
||||
"width": 48,
|
||||
"height": 46,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_boss.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/enemyGreen3.png",
|
||||
"frame_width": 103,
|
||||
"frame_height": 84
|
||||
},
|
||||
"name": "galaga_boss",
|
||||
"width": 103,
|
||||
"height": 84,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_boss_hurt.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/enemyBlack3.png",
|
||||
"frame_width": 103,
|
||||
"frame_height": 84
|
||||
},
|
||||
"name": "galaga_boss_hurt",
|
||||
"width": 103,
|
||||
"height": 84,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_butterfly.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/enemyRed2.png",
|
||||
"frame_width": 104,
|
||||
"frame_height": 84
|
||||
},
|
||||
"name": "galaga_butterfly",
|
||||
"width": 104,
|
||||
"height": 84,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_enemyshot.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/laserRed01.png",
|
||||
"frame_width": 9,
|
||||
"frame_height": 54
|
||||
},
|
||||
"name": "galaga_enemyshot",
|
||||
"width": 9,
|
||||
"height": 54,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_player.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/playerShip1_blue.png",
|
||||
"frame_width": 99,
|
||||
"frame_height": 75
|
||||
},
|
||||
"name": "galaga_player",
|
||||
"width": 99,
|
||||
"height": 75,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
16
examples/galaga/assets/sprite_galaga_playershot.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "art/laserBlue01.png",
|
||||
"frame_width": 9,
|
||||
"frame_height": 54
|
||||
},
|
||||
"name": "galaga_playershot",
|
||||
"width": 9,
|
||||
"height": 54,
|
||||
"speed": 200,
|
||||
"loop": false,
|
||||
"loopReverse": false,
|
||||
"frames": [
|
||||
0
|
||||
]
|
||||
}
|
||||
314
examples/galaga/enemies.c
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @file enemies.c
|
||||
* @brief The formation, the wave, and the hook that hands each enemy to BASIC.
|
||||
*
|
||||
* C owns the grid, the wave table and the spawn timing; BASIC owns everything
|
||||
* an enemy does after it exists. The formation slot arrives in SELF@.HOMEX% /
|
||||
* HOMEY%, so even the idle breathing of the grid is the script's, computed
|
||||
* relative to home.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
#include "galaga.h"
|
||||
|
||||
galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES];
|
||||
akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES];
|
||||
|
||||
/* Explosion lifetimes, indexed by heap slot. An explosion is an actor with
|
||||
* nothing to decide, so its whole state is one countdown. */
|
||||
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
|
||||
|
||||
/* A spawn serial per shot so registry names never collide while two shots
|
||||
* with the same slot number are briefly both alive. */
|
||||
static uint32_t SHOT_SERIAL = 0;
|
||||
static uint32_t BOOM_SERIAL = 0;
|
||||
|
||||
/*
|
||||
* The wave, one row per formation row. Columns are 0..9 at GALAGA_COL_PITCH;
|
||||
* `first` and `count` say which columns the row fills. 40 enemies: 4 bosses,
|
||||
* 16 butterflies, 20 bees -- 59 of the 64 actor heap slots at peak, counting
|
||||
* the player, two player shots, eight enemy shots and eight explosions.
|
||||
*/
|
||||
static const struct
|
||||
{
|
||||
int32_t kind; /* GALAGA_ENEMY_* */
|
||||
int row; /* formation row */
|
||||
int first; /* first column filled */
|
||||
int count; /* columns filled */
|
||||
int32_t hp;
|
||||
}
|
||||
WAVE_ROWS[] = {
|
||||
/* kind row first count hp */
|
||||
{ GALAGA_ENEMY_BOSS, 0, 3, 4, 2 },
|
||||
{ GALAGA_ENEMY_BUTTERFLY, 1, 1, 8, 1 },
|
||||
{ GALAGA_ENEMY_BUTTERFLY, 2, 1, 8, 1 },
|
||||
{ GALAGA_ENEMY_BEE, 3, 0, 10, 1 },
|
||||
{ GALAGA_ENEMY_BEE, 4, 0, 10, 1 }
|
||||
};
|
||||
#define WAVE_ROW_COUNT ((int)(sizeof(WAVE_ROWS) / sizeof(WAVE_ROWS[0])))
|
||||
|
||||
/* Enemy kind -> character name, the render half of the dispatch table. */
|
||||
static char *ENEMY_CHARACTER[GALAGA_ENEMY_KINDS] = {
|
||||
"galaga_bee",
|
||||
"galaga_butterfly",
|
||||
"galaga_boss"
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------- random --- */
|
||||
|
||||
/*
|
||||
* No RND verb exists (issue #16), so the engine is the script's only source
|
||||
* of randomness: it refreshes GAME@.RND% each frame and SELF@.RND% each call
|
||||
* from this PRNG. A hand-rolled LCG rather than rand() so a headless run is
|
||||
* the same game on every libc.
|
||||
*/
|
||||
static uint32_t PRNG_STATE = 0x12345678u;
|
||||
|
||||
float galaga_random(void)
|
||||
{
|
||||
PRNG_STATE = PRNG_STATE * 1664525u + 1013904223u;
|
||||
return (float)(PRNG_STATE >> 8) / (float)0x01000000u;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- helpers --- */
|
||||
|
||||
static akerr_ErrorContext *release_actor(akgl_Actor *actor)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
PASS(errctx, akgl_heap_release_actor(actor));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- shots --- */
|
||||
|
||||
/**
|
||||
* @brief Move an enemy shot; release it once it has left the screen.
|
||||
*/
|
||||
static akerr_ErrorContext *enemy_shot_update(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
obj->y += 380.0f * galaga_game.dt;
|
||||
if ( obj->y > (float)GALAGA_VIEW_HEIGHT + 60.0f ) {
|
||||
galaga_game.enemy_shots_live -= 1;
|
||||
PASS(errctx, release_actor(obj));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Consume an enemy's fire flag: take an actor and aim it downward.
|
||||
*
|
||||
* The script only raises a flag. Spawning takes a slot from the actor heap,
|
||||
* and pool exhaustion must be a C-side refusal with the house error context --
|
||||
* so C consumes the flag and does the spawn. The engine also enforces the
|
||||
* eight-shot cap by simply not consuming the flag's wish.
|
||||
*/
|
||||
static akerr_ErrorContext *enemy_fire(galaga_Enemy *enemy, akgl_Actor *from)
|
||||
{
|
||||
akgl_Actor *shot = NULL;
|
||||
char name[32];
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy");
|
||||
FAIL_ZERO_RETURN(errctx, from, AKERR_NULLPOINTER, "from");
|
||||
|
||||
enemy->fire = 0;
|
||||
if ( galaga_game.enemy_shots_live >= GALAGA_MAX_ENEMY_SHOTS ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
SHOT_SERIAL += 1;
|
||||
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "eshot%u", SHOT_SERIAL));
|
||||
PASS(errctx, akgl_heap_next_actor(&shot));
|
||||
PASS(errctx, akgl_actor_initialize(shot, name));
|
||||
PASS(errctx, akgl_actor_set_character(shot, "galaga_enemyshot"));
|
||||
/* AFTER initialize: it resets all seven hooks. */
|
||||
shot->updatefunc = &enemy_shot_update;
|
||||
shot->movement_controls_face = false;
|
||||
shot->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
/* akgl_actor_initialize() does not raise `visible`; a hand-spawned actor
|
||||
* that skips this line exists, moves and collides -- invisibly. */
|
||||
shot->visible = true;
|
||||
/* Actor x/y is a sprite's top-left corner; the shot leaves the enemy's
|
||||
* midline. Enemy sprites run 93..104 wide, the shot is 9. */
|
||||
shot->x = from->x + 46.0f;
|
||||
shot->y = from->y + 60.0f;
|
||||
|
||||
galaga_game.enemy_shots_live += 1;
|
||||
galaga_game.shots[enemy->kind] += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- explosions --- */
|
||||
|
||||
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
|
||||
{
|
||||
ptrdiff_t slot = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
slot = obj - akgl_heap_actors;
|
||||
BOOM_TTL[slot] -= galaga_game.dt;
|
||||
if ( BOOM_TTL[slot] <= 0.0f ) {
|
||||
PASS(errctx, release_actor(obj));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
|
||||
{
|
||||
akgl_Actor *boom = NULL;
|
||||
char name[32];
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&boom));
|
||||
BOOM_SERIAL += 1;
|
||||
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
|
||||
CATCH(errctx, akgl_actor_initialize(boom, name));
|
||||
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
|
||||
boom->updatefunc = &boom_update;
|
||||
boom->movement_controls_face = false;
|
||||
boom->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
boom->visible = true;
|
||||
boom->x = x;
|
||||
boom->y = y;
|
||||
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKGL_ERR_HEAP) {
|
||||
/* Explosions are decoration. When the heap is momentarily full the
|
||||
* right outcome is no explosion, not a dead frame -- this is the one
|
||||
* spawn that absorbs exhaustion. */
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- enemies --- */
|
||||
|
||||
/**
|
||||
* @brief The custom update hook: one enemy, once per frame, thought in BASIC.
|
||||
*
|
||||
* The whole body is the protocol from docs/20: refresh the inbox, hand the
|
||||
* pair to the script, consume the outbox. akgl_game_update() calls this in
|
||||
* place of akgl_actor_update() because spawn replaced the hook.
|
||||
*/
|
||||
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
|
||||
{
|
||||
galaga_Enemy *enemy = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
enemy = (galaga_Enemy *)obj->actorData;
|
||||
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
|
||||
|
||||
enemy->rnd = galaga_random();
|
||||
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
|
||||
if ( enemy->fire != 0 ) {
|
||||
PASS(errctx, enemy_fire(enemy, obj));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *galaga_wave_spawn(void)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
galaga_Enemy *enemy = NULL;
|
||||
char name[32];
|
||||
int row = 0;
|
||||
int col = 0;
|
||||
int index = 0;
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
for ( row = 0; row < WAVE_ROW_COUNT; row++ ) {
|
||||
for ( col = 0; col < WAVE_ROWS[row].count; col++ ) {
|
||||
FAIL_NONZERO_RETURN(errctx, (index >= GALAGA_MAX_ENEMIES), AKERR_OUTOFBOUNDS,
|
||||
"The wave table places more than %d enemies", GALAGA_MAX_ENEMIES);
|
||||
enemy = &galaga_enemies[index];
|
||||
memset(enemy, 0, sizeof(*enemy));
|
||||
enemy->kind = WAVE_ROWS[row].kind;
|
||||
enemy->state = GALAGA_ES_ENTERING;
|
||||
enemy->homex = (float)(GALAGA_FORM_LEFT
|
||||
+ (WAVE_ROWS[row].first + col) * GALAGA_COL_PITCH);
|
||||
enemy->homey = (float)(GALAGA_FORM_TOP + WAVE_ROWS[row].row * GALAGA_ROW_PITCH);
|
||||
enemy->hp = WAVE_ROWS[row].hp;
|
||||
/* Stagger the entries: each enemy's clock starts in the past, and
|
||||
* the script holds still until its own t crosses zero. */
|
||||
enemy->t = -0.08f * (float)index;
|
||||
|
||||
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "enemy%02d", index));
|
||||
PASS(errctx, akgl_heap_next_actor(&actor));
|
||||
PASS(errctx, akgl_actor_initialize(actor, name));
|
||||
PASS(errctx, akgl_actor_set_character(actor, ENEMY_CHARACTER[enemy->kind]));
|
||||
/* AFTER initialize: it resets all seven hooks. */
|
||||
actor->updatefunc = &enemy_update;
|
||||
actor->actorData = enemy;
|
||||
/* Nothing here moves by state bits, and an actor whose state word
|
||||
* matches no character mapping is silently not drawn -- so facing
|
||||
* stays entirely out of the state word. */
|
||||
actor->movement_controls_face = false;
|
||||
actor->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
/* akgl_actor_initialize() does not raise `visible` -- the map
|
||||
* loader copies it from map data, and there is no map here. Skip
|
||||
* this and the whole wave exists, moves, fires and dies without
|
||||
* ever being drawn. */
|
||||
actor->visible = true;
|
||||
/* Off screen above, pouring in from whichever side is closer. */
|
||||
actor->x = (enemy->homex < (float)GALAGA_VIEW_WIDTH / 2.0f)
|
||||
? -80.0f : (float)GALAGA_VIEW_WIDTH + 80.0f;
|
||||
actor->y = -80.0f;
|
||||
|
||||
galaga_enemy_actors[index] = actor;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *galaga_wave_release(void)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
|
||||
if ( galaga_enemy_actors[i] != NULL ) {
|
||||
PASS(errctx, release_actor(galaga_enemy_actors[i]));
|
||||
galaga_enemy_actors[i] = NULL;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int galaga_enemies_alive(void)
|
||||
{
|
||||
int i = 0;
|
||||
int alive = 0;
|
||||
|
||||
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
|
||||
if ( galaga_enemy_actors[i] != NULL ) {
|
||||
alive += 1;
|
||||
}
|
||||
}
|
||||
return alive;
|
||||
}
|
||||
119
examples/galaga/galaga.bas
Normal file
@@ -0,0 +1,119 @@
|
||||
REM GALAGA enemy behavior. The C engine loads this file, runs it once so the
|
||||
REM definitions exist, and then calls one UPDATE function per enemy per frame.
|
||||
REM There is no top-level code: definitions, then END.
|
||||
REM
|
||||
REM Names the engine binds before every call:
|
||||
REM SELF@ - this enemy's record (ENEMY): the state machine's memory
|
||||
REM ACTOR@ - the engine's live actor (ACTOR): position is the real thing
|
||||
REM GAME@ - shared frame state (GAME): player position, wave, randomness
|
||||
REM
|
||||
REM SELF@.STATE# bits: 1 = entering 2 = in formation 4 = diving
|
||||
REM
|
||||
REM Two rules of this dialect that bite here, both from docs/03:
|
||||
REM - the LEFT operand decides integer or float arithmetic, so a float
|
||||
REM always goes first: SELF@.T% * 150 + 260, never 260 + 150 * SELF@.T%
|
||||
REM - RETURN at the start of a line ends the DEF body, so every early
|
||||
REM return rides an IF ... THEN, and only the last RETURN starts a line
|
||||
|
||||
REM Ease toward the formation slot, with a little entry swirl.
|
||||
REM Answers 1 once the slot is reached, else 0.
|
||||
DEF GLIDEHOME(DT%)
|
||||
DX% = SELF@.HOMEX% - ACTOR@.X%
|
||||
DY% = SELF@.HOMEY% - ACTOR@.Y%
|
||||
K% = DT% * 4.5
|
||||
IF K% > 1 THEN K% = 1
|
||||
ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT%
|
||||
ACTOR@.Y% = ACTOR@.Y% + DY% * K%
|
||||
IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1
|
||||
RETURN 0
|
||||
|
||||
REM One frame of a dive: accelerate downward, weave, lean toward the
|
||||
REM player's column, and glide back in from the top after falling out.
|
||||
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
|
||||
SPD% = SELF@.T% * 150 + 260
|
||||
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
|
||||
ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT%
|
||||
DX% = GAME@.PLAYERX% - ACTOR@.X%
|
||||
IF DX% > 220 THEN DX% = 220
|
||||
IF DX% < -220 THEN DX% = -220
|
||||
ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT%
|
||||
IF ACTOR@.Y% > 1040 THEN BEGIN
|
||||
ACTOR@.Y% = 0.0 - 90
|
||||
SELF@.STATE# = 1
|
||||
SELF@.T% = 0
|
||||
BEND
|
||||
RETURN 0
|
||||
|
||||
REM Raise the fire flag when diving roughly above the player. The engine
|
||||
REM consumes FIRE# and does the spawning; the script only wishes.
|
||||
DEF DECIDEFIRE(DT%)
|
||||
DX% = GAME@.PLAYERX% - ACTOR@.X%
|
||||
IF ABS(DX%) > 140 THEN RETURN 0
|
||||
IF ACTOR@.Y% > GAME@.PLAYERY% THEN RETURN 0
|
||||
IF SELF@.RND% < DT% * 1.5 THEN SELF@.FIRE# = 1
|
||||
RETURN 0
|
||||
|
||||
REM Bee: enter, breathe in formation, occasionally dive nearly straight.
|
||||
DEF UPDATEBEE(DT%)
|
||||
SELF@.T% = SELF@.T% + DT%
|
||||
IF SELF@.T% < 0 THEN RETURN 0
|
||||
S# = SELF@.STATE#
|
||||
IF (S# AND 1) > 0 THEN BEGIN
|
||||
R# = GLIDEHOME(DT%)
|
||||
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 2) > 0 THEN BEGIN
|
||||
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.7) * 16
|
||||
ACTOR@.Y% = SELF@.HOMEY%
|
||||
IF SELF@.RND% < DT% * 0.04 THEN SELF@.STATE# = 4 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 4) > 0 THEN BEGIN
|
||||
R# = DIVESTEP(DT%, 130, 0.2)
|
||||
R# = DECIDEFIRE(DT%)
|
||||
BEND
|
||||
RETURN 0
|
||||
|
||||
REM Butterfly: the same machine with a wide lateral weave on the dive.
|
||||
DEF UPDATEBFLY(DT%)
|
||||
SELF@.T% = SELF@.T% + DT%
|
||||
IF SELF@.T% < 0 THEN RETURN 0
|
||||
S# = SELF@.STATE#
|
||||
IF (S# AND 1) > 0 THEN BEGIN
|
||||
R# = GLIDEHOME(DT%)
|
||||
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 2) > 0 THEN BEGIN
|
||||
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 2.1) * 24
|
||||
ACTOR@.Y% = SELF@.HOMEY%
|
||||
IF SELF@.RND% < DT% * 0.05 THEN SELF@.STATE# = 4 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 4) > 0 THEN BEGIN
|
||||
R# = DIVESTEP(DT%, 260, 0.1)
|
||||
R# = DECIDEFIRE(DT%)
|
||||
BEND
|
||||
RETURN 0
|
||||
|
||||
REM Boss: two hit points, a slow sway, and a dive that leads the player.
|
||||
REM At one hit point it raises actor state bit 13 (8192), and the engine's
|
||||
REM character mapping swaps the sprite - the boundary crossed the other way.
|
||||
DEF UPDATEBOSS(DT%)
|
||||
SELF@.T% = SELF@.T% + DT%
|
||||
IF SELF@.T% < 0 THEN RETURN 0
|
||||
IF SELF@.HP# = 1 THEN ACTOR@.STATE# = ACTOR@.STATE# OR 8192
|
||||
S# = SELF@.STATE#
|
||||
IF (S# AND 1) > 0 THEN BEGIN
|
||||
R# = GLIDEHOME(DT%)
|
||||
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 2) > 0 THEN BEGIN
|
||||
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.1) * 10
|
||||
ACTOR@.Y% = SELF@.HOMEY%
|
||||
IF SELF@.RND% < DT% * 0.03 THEN SELF@.STATE# = 4 : SELF@.T% = 0
|
||||
BEND
|
||||
IF (S# AND 4) > 0 THEN BEGIN
|
||||
R# = DIVESTEP(DT%, 60, 0.9)
|
||||
R# = DECIDEFIRE(DT%)
|
||||
BEND
|
||||
RETURN 0
|
||||
|
||||
END
|
||||
155
examples/galaga/galaga.h
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file galaga.h
|
||||
* @brief Shared declarations for the GALAGA embedding example.
|
||||
*
|
||||
* The engine is C on libakgl; the enemies think in BASIC. Everything the two
|
||||
* sides share crosses in exactly one place: the three structures below, which
|
||||
* script.c registers as host types so a script reads and writes them directly.
|
||||
* docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md build this
|
||||
* program from an empty file; the split between files follows the split
|
||||
* between chapters.
|
||||
*/
|
||||
|
||||
#ifndef _GALAGA_H_
|
||||
#define _GALAGA_H_
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
|
||||
/* ------------------------------------------------------------- geometry --- */
|
||||
|
||||
/*
|
||||
* The view is sized to the artwork rather than the other way round: the Kenney
|
||||
* sprites are ~100 pixels wide, libakgl has no way to draw a sprite smaller
|
||||
* than it is (akgl_Actor.scale is overwritten every frame -- libakgl
|
||||
* docs/12-actors.md), and a ten-column formation of them needs 1120 pixels.
|
||||
*/
|
||||
#define GALAGA_VIEW_WIDTH 1280
|
||||
#define GALAGA_VIEW_HEIGHT 960
|
||||
|
||||
#define GALAGA_FORM_COLUMNS 10 /* formation width, in slots */
|
||||
#define GALAGA_FORM_LEFT 136 /* x of column 0, map pixels */
|
||||
#define GALAGA_FORM_TOP 120 /* y of row 0, map pixels */
|
||||
#define GALAGA_COL_PITCH 112
|
||||
#define GALAGA_ROW_PITCH 100
|
||||
|
||||
#define GALAGA_PLAYER_Y 860.0f
|
||||
#define GALAGA_PLAYER_SPEED 420.0f /* map pixels per second */
|
||||
#define GALAGA_PLAYER_MARGIN 60.0f /* how close to the edge it may go */
|
||||
|
||||
/* ------------------------------------------------------------- entities --- */
|
||||
|
||||
#define GALAGA_ENEMY_BEE 0
|
||||
#define GALAGA_ENEMY_BUTTERFLY 1
|
||||
#define GALAGA_ENEMY_BOSS 2
|
||||
#define GALAGA_ENEMY_KINDS 3
|
||||
|
||||
#define GALAGA_MAX_ENEMIES 40
|
||||
#define GALAGA_MAX_PLAYER_SHOTS 2 /* the classic two-on-screen rule */
|
||||
#define GALAGA_MAX_ENEMY_SHOTS 8
|
||||
|
||||
/*
|
||||
* galaga_Enemy.state bits. The script owns these transitions; the engine only
|
||||
* writes the word at spawn and when a script error forces an enemy dumb.
|
||||
* galaga.bas spells the same three values as literals, with a REM naming them.
|
||||
*
|
||||
* 8 0
|
||||
* 0 0 0 0 0 1 1 1
|
||||
* | | `-- ENTERING: flying its entry path toward the formation slot
|
||||
* | `---- FORMATION: holding (and breathing around) homex/homey
|
||||
* `------ DIVING: attacking, off the grid until it leaves the screen
|
||||
*/
|
||||
#define GALAGA_ES_ENTERING (1 << 0)
|
||||
#define GALAGA_ES_FORMATION (1 << 1)
|
||||
#define GALAGA_ES_DIVING (1 << 2)
|
||||
|
||||
/** @brief One enemy, as both sides see it. Hangs off akgl_Actor.actorData. */
|
||||
typedef struct galaga_Enemy
|
||||
{
|
||||
int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */
|
||||
int32_t state; /* GALAGA_ES_* bit flags */
|
||||
float homex; /* formation slot, in map pixels */
|
||||
float homey;
|
||||
float t; /* parametric clock for the current maneuver */
|
||||
int32_t hp;
|
||||
int32_t fire; /* outbox: script sets 1, engine consumes */
|
||||
float rnd; /* inbox: engine writes fresh 0..1 each call */
|
||||
} galaga_Enemy;
|
||||
|
||||
/** @brief Frame state every enemy may read. Bound once as GAME@. */
|
||||
typedef struct galaga_Shared
|
||||
{
|
||||
float playerx; /* the player actor's position, this frame */
|
||||
float playery;
|
||||
int32_t wave;
|
||||
float rnd; /* fresh 0..1 each frame; the issue #16 route */
|
||||
} galaga_Shared;
|
||||
|
||||
/* --------------------------------------------------------------- screens --- */
|
||||
|
||||
typedef enum
|
||||
{
|
||||
GALAGA_SCREEN_TITLE = 0,
|
||||
GALAGA_SCREEN_PLAY,
|
||||
GALAGA_SCREEN_GAMEOVER,
|
||||
GALAGA_SCREEN_VICTORY
|
||||
} galaga_Screen;
|
||||
|
||||
/* ------------------------------------------------------------ game state --- */
|
||||
|
||||
typedef struct galaga_Game
|
||||
{
|
||||
galaga_Screen screen;
|
||||
int frame;
|
||||
float dt; /* seconds, clamped; see main.c */
|
||||
bool autoplay;
|
||||
|
||||
int score;
|
||||
int lives;
|
||||
int kills[GALAGA_ENEMY_KINDS];
|
||||
int shots[GALAGA_ENEMY_KINDS]; /* shots each kind fired */
|
||||
int script_errors;
|
||||
|
||||
akgl_Actor *player;
|
||||
float fire_cooldown;
|
||||
float respawn_timer; /* > 0 while the player is invulnerable */
|
||||
bool firing;
|
||||
bool moveleft;
|
||||
bool moveright;
|
||||
|
||||
int player_shots_live;
|
||||
int enemy_shots_live;
|
||||
} galaga_Game;
|
||||
|
||||
extern galaga_Game galaga_game;
|
||||
extern galaga_Shared galaga_shared;
|
||||
|
||||
extern galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES];
|
||||
extern akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES];
|
||||
|
||||
/* ---------------------------------------------------------------- script --- */
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_script_boot(char *path);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt);
|
||||
|
||||
/* --------------------------------------------------------------- enemies --- */
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_spawn(void);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_wave_release(void);
|
||||
int galaga_enemies_alive(void);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_boom_spawn(float x, float y);
|
||||
|
||||
/* ---------------------------------------------------------------- player --- */
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_spawn(void);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_controls(void);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *galaga_player_autoplay(int frame);
|
||||
|
||||
/** @brief A 0..1 random draw from the engine's own PRNG (see enemies.c). */
|
||||
float galaga_random(void);
|
||||
|
||||
#endif // _GALAGA_H_
|
||||
132
examples/galaga/interop_test.c
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @file interop_test.c
|
||||
* @brief Round-trip test for the galaga boundary, hoststruct.c-style.
|
||||
*
|
||||
* Links the real script.c and the real galaga.bas -- not copies -- so this
|
||||
* fails the moment the boundary and the script disagree. The four claims it
|
||||
* pins:
|
||||
*
|
||||
* 1. The script writes the engine's actor memory: a formation enemy's sway
|
||||
* lands in akgl_Actor.x with no marshalling step.
|
||||
* 2. The outbox works: a diving enemy above the player raises FIRE# and the
|
||||
* C side reads it.
|
||||
* 3. The boss flips actor state bit 13 at one hit point -- the boundary
|
||||
* crossed engine-ward.
|
||||
* 4. Sustained calling holds: 24000 calls through the per-call
|
||||
* akbasic_environment_zero() regime, the load a 40-enemy wave puts on
|
||||
* the runtime in ten seconds.
|
||||
*
|
||||
* Exit status equals the number of failed claims.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
|
||||
#include "galaga.h"
|
||||
|
||||
#ifndef GALAGA_SCRIPT_PATH
|
||||
#define GALAGA_SCRIPT_PATH "galaga.bas"
|
||||
#endif
|
||||
|
||||
/* script.c reads these; main.c usually defines them. This test is the host. */
|
||||
galaga_Game galaga_game;
|
||||
galaga_Shared galaga_shared;
|
||||
|
||||
static int FAILURES = 0;
|
||||
|
||||
#define CLAIM(__cond, __text) \
|
||||
if ( !(__cond) ) { \
|
||||
fprintf(stderr, "FAILED: %s\n", __text); \
|
||||
FAILURES += 1; \
|
||||
} else { \
|
||||
printf("ok: %s\n", __text); \
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *run_claims(void)
|
||||
{
|
||||
galaga_Enemy enemy;
|
||||
akgl_Actor actor;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, galaga_script_boot((char *)GALAGA_SCRIPT_PATH));
|
||||
|
||||
/* --- 1: formation sway lands in the actor ---------------------------- */
|
||||
memset(&enemy, 0, sizeof(enemy));
|
||||
memset(&actor, 0, sizeof(actor));
|
||||
enemy.kind = GALAGA_ENEMY_BEE;
|
||||
enemy.state = GALAGA_ES_FORMATION;
|
||||
enemy.homex = 400.0f;
|
||||
enemy.homey = 300.0f;
|
||||
enemy.hp = 1;
|
||||
actor.x = 0.0f;
|
||||
actor.y = 0.0f;
|
||||
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
||||
CLAIM((fabsf(actor.x - enemy.homex) <= 16.5f) && (actor.y == enemy.homey),
|
||||
"a formation bee's sway is written into akgl_Actor.x/y by the script");
|
||||
|
||||
/* --- 2: the fire outbox ---------------------------------------------- */
|
||||
memset(&enemy, 0, sizeof(enemy));
|
||||
memset(&actor, 0, sizeof(actor));
|
||||
enemy.kind = GALAGA_ENEMY_BEE;
|
||||
enemy.state = GALAGA_ES_DIVING;
|
||||
enemy.rnd = 0.0f; /* 0.0 < DT% * 1.5: always willing */
|
||||
actor.x = 600.0f;
|
||||
actor.y = 200.0f;
|
||||
galaga_shared.playerx = 610.0f; /* just off the shot's column */
|
||||
galaga_shared.playery = 860.0f; /* well below */
|
||||
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
||||
CLAIM(enemy.fire == 1,
|
||||
"a diving bee above the player raises FIRE# for the engine to consume");
|
||||
|
||||
/* --- 3: the boss's hurt bit ------------------------------------------ */
|
||||
memset(&enemy, 0, sizeof(enemy));
|
||||
memset(&actor, 0, sizeof(actor));
|
||||
enemy.kind = GALAGA_ENEMY_BOSS;
|
||||
enemy.state = GALAGA_ES_FORMATION;
|
||||
enemy.homex = 500.0f;
|
||||
enemy.homey = 120.0f;
|
||||
enemy.hp = 1;
|
||||
actor.state = AKGL_ACTOR_STATE_ALIVE;
|
||||
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
||||
CLAIM((actor.state & AKGL_ACTOR_STATE_UNDEFINED_13) != 0,
|
||||
"a boss at one hit point raises actor state bit 13 from BASIC");
|
||||
|
||||
/* --- 4: sustained calling --------------------------------------------- */
|
||||
memset(&enemy, 0, sizeof(enemy));
|
||||
memset(&actor, 0, sizeof(actor));
|
||||
enemy.kind = GALAGA_ENEMY_BEE;
|
||||
enemy.state = GALAGA_ES_FORMATION;
|
||||
enemy.homex = 400.0f;
|
||||
enemy.homey = 300.0f;
|
||||
for ( i = 0; i < 24000; i++ ) {
|
||||
enemy.rnd = 0.9f; /* never dive: keep the state put */
|
||||
PASS(errctx, galaga_script_update_enemy(&enemy, &actor, 0.016f));
|
||||
}
|
||||
CLAIM(galaga_game.script_errors == 0,
|
||||
"24000 calls survive the per-call akbasic_environment_zero() regime");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, run_claims());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "the interop test could not run");
|
||||
FAILURES += 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
return FAILURES;
|
||||
}
|
||||
698
examples/galaga/main.c
Normal file
@@ -0,0 +1,698 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief Startup, the frame loop, the screens, and teardown.
|
||||
*
|
||||
* The startup order is libakgl's one sequence that works (deps/libakgl
|
||||
* include/akgl/game.h): metadata, akgl_game_init(), screen properties,
|
||||
* akgl_render_2d_init(), a physics backend -- and then, new in this example,
|
||||
* the interpreter. The scripts compute, the engine draws; the interpreter is
|
||||
* lent no devices at all, so a script that tries SPRITE is refused by name.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.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/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 "galaga.h"
|
||||
|
||||
/** @brief Where the example's assets live. CMake defines it; `--assets` overrides. */
|
||||
#ifndef GALAGA_ASSET_DIR
|
||||
#define GALAGA_ASSET_DIR "."
|
||||
#endif
|
||||
|
||||
/** @brief The enemy script. CMake defines it; `--script` overrides. */
|
||||
#ifndef GALAGA_SCRIPT_PATH
|
||||
#define GALAGA_SCRIPT_PATH "galaga.bas"
|
||||
#endif
|
||||
|
||||
/** @brief The HUD font. CMake defines it; headless runs still need it for the UI. */
|
||||
#ifndef GALAGA_FONT_PATH
|
||||
#define GALAGA_FONT_PATH "font.ttf"
|
||||
#endif
|
||||
|
||||
#define GALAGA_PATH_MAX 1024
|
||||
|
||||
galaga_Game galaga_game;
|
||||
galaga_Shared galaga_shared;
|
||||
|
||||
/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
|
||||
static char *SHOTPATH = NULL;
|
||||
static int SHOTFRAME = 0;
|
||||
|
||||
/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
|
||||
static int FAILED = 0;
|
||||
|
||||
/* ------------------------------------------------------------- starfield --- */
|
||||
|
||||
/*
|
||||
* No parallax facility exists in libakgl and none is needed: a fixed array of
|
||||
* stars advanced per frame and drawn with akgl_draw_point() between
|
||||
* frame_start and akgl_game_update(). Two speed bands give the depth for
|
||||
* free -- the slow band reads as far away.
|
||||
*/
|
||||
#define GALAGA_STARS 96
|
||||
|
||||
static struct
|
||||
{
|
||||
float x;
|
||||
float y;
|
||||
float speed;
|
||||
Uint8 bright;
|
||||
} STARS[GALAGA_STARS];
|
||||
|
||||
static void starfield_seed(void)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < GALAGA_STARS; i++ ) {
|
||||
STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
|
||||
STARS[i].y = galaga_random() * (float)GALAGA_VIEW_HEIGHT;
|
||||
if ( (i % 2) == 0 ) {
|
||||
STARS[i].speed = 40.0f; /* the far band */
|
||||
STARS[i].bright = 110;
|
||||
} else {
|
||||
STARS[i].speed = 110.0f; /* the near band */
|
||||
STARS[i].bright = 220;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *starfield_draw(void)
|
||||
{
|
||||
SDL_Color color = { 255, 255, 255, 255 };
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
for ( i = 0; i < GALAGA_STARS; i++ ) {
|
||||
STARS[i].y += STARS[i].speed * galaga_game.dt;
|
||||
if ( STARS[i].y > (float)GALAGA_VIEW_HEIGHT ) {
|
||||
STARS[i].y -= (float)GALAGA_VIEW_HEIGHT;
|
||||
STARS[i].x = galaga_random() * (float)GALAGA_VIEW_WIDTH;
|
||||
}
|
||||
color.r = STARS[i].bright;
|
||||
color.g = STARS[i].bright;
|
||||
color.b = STARS[i].bright;
|
||||
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ screenshots --- */
|
||||
|
||||
/**
|
||||
* @brief Read the render target back and write it out as a PNG.
|
||||
*
|
||||
* Called after everything has drawn and before the frame is presented,
|
||||
* because SDL_RenderPresent is where the target stops being readable. The
|
||||
* figures in docs/20 and docs/21 are output from this program rather than
|
||||
* pictures somebody took once, so they cannot show a game that no longer
|
||||
* exists.
|
||||
*/
|
||||
static akerr_ErrorContext *save_screenshot(char *path)
|
||||
{
|
||||
SDL_Surface *shot = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError());
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL,
|
||||
"IMG_SavePNG(%s): %s", path, SDL_GetError());
|
||||
} CLEANUP {
|
||||
SDL_DestroySurface(shot);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SDL_Log("Wrote %s", path);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- assets --- */
|
||||
|
||||
static char *SPRITE_FILES[] = {
|
||||
"sprite_galaga_player.json",
|
||||
"sprite_galaga_bee.json",
|
||||
"sprite_galaga_butterfly.json",
|
||||
"sprite_galaga_boss.json",
|
||||
"sprite_galaga_boss_hurt.json",
|
||||
"sprite_galaga_playershot.json",
|
||||
"sprite_galaga_enemyshot.json",
|
||||
"sprite_galaga_boom.json",
|
||||
NULL
|
||||
};
|
||||
|
||||
static char *CHARACTER_FILES[] = {
|
||||
"character_galaga_player.json",
|
||||
"character_galaga_bee.json",
|
||||
"character_galaga_butterfly.json",
|
||||
"character_galaga_boss.json",
|
||||
"character_galaga_playershot.json",
|
||||
"character_galaga_enemyshot.json",
|
||||
"character_galaga_boom.json",
|
||||
NULL
|
||||
};
|
||||
|
||||
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
|
||||
{
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sprites first, characters second. Not a preference: a character's
|
||||
* JSON names its sprites by registry name, so a character loaded first fails
|
||||
* on the first sprite it cannot find.
|
||||
*/
|
||||
static akerr_ErrorContext *load_assets(char *assetdir)
|
||||
{
|
||||
char path[GALAGA_PATH_MAX];
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||
for ( i = 0; SPRITE_FILES[i] != NULL; i++ ) {
|
||||
PASS(errctx, asset_path(assetdir, SPRITE_FILES[i], (char *)&path, sizeof(path)));
|
||||
PASS(errctx, akgl_sprite_load_json((char *)&path));
|
||||
}
|
||||
for ( i = 0; CHARACTER_FILES[i] != NULL; i++ ) {
|
||||
PASS(errctx, asset_path(assetdir, CHARACTER_FILES[i], (char *)&path, sizeof(path)));
|
||||
PASS(errctx, akgl_character_load_json((char *)&path));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- startup --- */
|
||||
|
||||
/** @brief Replacement for akgl_game.lowfpsfunc, which logs a line per frame. */
|
||||
static void galaga_lowfps(void)
|
||||
{
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *startup(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
|
||||
"akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
|
||||
PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
|
||||
"1.0.0", sizeof(akgl_game.version) - 1));
|
||||
PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
|
||||
"net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
PASS(errctx, akgl_game_init());
|
||||
akgl_game.lowfpsfunc = &galaga_lowfps;
|
||||
|
||||
/* Properties before the renderer: akgl_render_2d_init reads both, and an
|
||||
* unset one defaults to the string "0" -- a zero-sized window. */
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", "960"));
|
||||
PASS(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderLogicalPresentation(
|
||||
akgl_renderer->sdl_renderer,
|
||||
GALAGA_VIEW_WIDTH,
|
||||
GALAGA_VIEW_HEIGHT,
|
||||
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError()
|
||||
);
|
||||
|
||||
/* The view is what the camera looks through, so it says the same thing. */
|
||||
akgl_camera->x = 0.0f;
|
||||
akgl_camera->y = 0.0f;
|
||||
akgl_camera->w = (float)GALAGA_VIEW_WIDTH;
|
||||
akgl_camera->h = (float)GALAGA_VIEW_HEIGHT;
|
||||
|
||||
/*
|
||||
* akgl_game_init does NOT install a physics backend, whatever physics.h's
|
||||
* file comment says (libakgl docs/14-physics.md). Null physics accepts
|
||||
* every call and moves nothing: whatever writes x and y directly is the
|
||||
* mover, and in this game that is BASIC writing through ACTOR@.
|
||||
*/
|
||||
PASS(errctx, akgl_physics_init_null(akgl_physics));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- the UI --- */
|
||||
|
||||
static akgl_UiMenu TITLE_MENU = {
|
||||
"titlemenu", { "START", "QUIT" }, 2, 0, false, NULL
|
||||
};
|
||||
static akgl_UiMenu AGAIN_MENU = {
|
||||
"againmenu", { "PLAY AGAIN", "QUIT" }, 2, 0, false, NULL
|
||||
};
|
||||
|
||||
/* Clay borrows label text until frame_end, so these cannot be locals. */
|
||||
static char HUD_SCORE[64];
|
||||
static char HUD_LIVES[64];
|
||||
|
||||
/**
|
||||
* @brief Draw a headline centred above the menu, in the banner font.
|
||||
*
|
||||
* Direct text rather than a ui label: the menu owns AKGL_UI_ANCHOR_CENTER,
|
||||
* and a label anchored there disappears behind it -- there is no
|
||||
* top-centre anchor to reach for. Drawn before the UI bracket, so the menu
|
||||
* still paints over it if the two ever meet.
|
||||
*/
|
||||
static akerr_ErrorContext *draw_banner(char *text)
|
||||
{
|
||||
SDL_Color ink = { 235, 235, 235, 255 };
|
||||
TTF_Font *font = NULL;
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
|
||||
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
|
||||
PASS(errctx, akgl_text_measure(font, text, &w, &h));
|
||||
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0,
|
||||
(GALAGA_VIEW_WIDTH - w) / 2, 280));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *declare_title(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_ui_menu(&TITLE_MENU));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *declare_play(void)
|
||||
{
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, aksl_snprintf(&count, HUD_SCORE, sizeof(HUD_SCORE),
|
||||
"SCORE %06d", galaga_game.score));
|
||||
PASS(errctx, aksl_snprintf(&count, HUD_LIVES, sizeof(HUD_LIVES),
|
||||
"LIVES %d WAVE %d", galaga_game.lives, galaga_shared.wave));
|
||||
PASS(errctx, akgl_ui_label("score", HUD_SCORE, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
|
||||
PASS(errctx, akgl_ui_label("lives", HUD_LIVES, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *declare_end(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_ui_menu(&AGAIN_MENU));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ transitions --- */
|
||||
|
||||
static akerr_ErrorContext *start_game(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
galaga_game.score = 0;
|
||||
galaga_game.lives = 3;
|
||||
memset(galaga_game.kills, 0, sizeof(galaga_game.kills));
|
||||
memset(galaga_game.shots, 0, sizeof(galaga_game.shots));
|
||||
galaga_game.respawn_timer = 0.0f;
|
||||
galaga_shared.wave = 1;
|
||||
|
||||
galaga_game.player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
|
||||
PASS(errctx, galaga_wave_spawn());
|
||||
galaga_game.screen = GALAGA_SCREEN_PLAY;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief End-of-round bookkeeping: notice a cleared wave or a spent ship.
|
||||
*/
|
||||
static akerr_ErrorContext *check_transitions(bool *running)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
(void)running;
|
||||
if ( galaga_game.screen != GALAGA_SCREEN_PLAY ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( galaga_game.lives <= 0 ) {
|
||||
PASS(errctx, galaga_wave_release());
|
||||
galaga_game.screen = GALAGA_SCREEN_GAMEOVER;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( galaga_enemies_alive() == 0 ) {
|
||||
galaga_game.screen = GALAGA_SCREEN_VICTORY;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Consume a menu activation. The menu never clears `activated`; the
|
||||
* state machine that acts on it does.
|
||||
*/
|
||||
static akerr_ErrorContext *consume_menus(bool *running)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_TITLE && TITLE_MENU.activated ) {
|
||||
TITLE_MENU.activated = false;
|
||||
if ( TITLE_MENU.selected == 0 ) {
|
||||
PASS(errctx, start_game());
|
||||
} else {
|
||||
*running = false;
|
||||
}
|
||||
}
|
||||
if ( (galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|
||||
|| galaga_game.screen == GALAGA_SCREEN_VICTORY)
|
||||
&& AGAIN_MENU.activated ) {
|
||||
AGAIN_MENU.activated = false;
|
||||
if ( AGAIN_MENU.selected == 0 ) {
|
||||
PASS(errctx, galaga_wave_release());
|
||||
PASS(errctx, start_game());
|
||||
} else {
|
||||
*running = false;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the frame --- */
|
||||
|
||||
/**
|
||||
* @brief Route one event: the UI gets first refusal, then the menus, then
|
||||
* the controller. A consumed event goes no further.
|
||||
*/
|
||||
static akerr_ErrorContext *route_event(SDL_Event *event, bool *running)
|
||||
{
|
||||
bool consumed = false;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
|
||||
if ( event->type == SDL_EVENT_QUIT ) {
|
||||
*running = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
|
||||
if ( consumed ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
|
||||
PASS(errctx, akgl_ui_menu_handle_event(&TITLE_MENU, event, &consumed));
|
||||
} else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|
||||
|| galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
|
||||
PASS(errctx, akgl_ui_menu_handle_event(&AGAIN_MENU, event, &consumed));
|
||||
}
|
||||
if ( consumed ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
/* Every event, unconditionally: one that no control map binds is not an
|
||||
* error, it is a call that did nothing. */
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, event));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief The previous frame's timestamp, for dt. Stamped once at loop start. */
|
||||
static uint64_t LAST_NS = 0;
|
||||
|
||||
static akerr_ErrorContext *frame(bool *running)
|
||||
{
|
||||
SDL_Event event;
|
||||
uint64_t now = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
|
||||
|
||||
while ( SDL_PollEvent(&event) == true ) {
|
||||
PASS(errctx, route_event(&event, running));
|
||||
}
|
||||
|
||||
galaga_game.frame += 1;
|
||||
if ( galaga_game.autoplay ) {
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_TITLE && galaga_game.frame >= 8 ) {
|
||||
PASS(errctx, start_game());
|
||||
}
|
||||
/*
|
||||
* On an end screen the pilot presses Return, which drives the real
|
||||
* menu path -- declare, handle, activate, restart -- so a headless
|
||||
* run that dies keeps exercising the game instead of idling.
|
||||
*/
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER
|
||||
|| galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
|
||||
if ( (galaga_game.frame % 30) == 0 ) {
|
||||
SDL_Event press;
|
||||
memset(&press, 0, sizeof(press));
|
||||
press.type = SDL_EVENT_KEY_DOWN;
|
||||
press.key.key = SDLK_RETURN;
|
||||
PASS(errctx, route_event(&press, running));
|
||||
}
|
||||
}
|
||||
PASS(errctx, galaga_player_autoplay(galaga_game.frame));
|
||||
}
|
||||
|
||||
/*
|
||||
* dt from the wall clock, clamped: a debugger pause or a stalled runner
|
||||
* must not become one frame of teleporting enemies. The clamp is a 30 Hz
|
||||
* frame, the slowest game this is still worth playing at.
|
||||
*/
|
||||
now = SDL_GetTicksNS();
|
||||
galaga_game.dt = (float)(now - LAST_NS) / 1e9f;
|
||||
LAST_NS = now;
|
||||
if ( galaga_game.dt > (1.0f / 30.0f) ) {
|
||||
galaga_game.dt = 1.0f / 30.0f;
|
||||
}
|
||||
|
||||
/* The shared frame state, refreshed before any enemy thinks. The engine
|
||||
* filling GAME@.RND% is the issue #16 route: no RND verb exists. */
|
||||
galaga_shared.playerx = galaga_game.player->x + 50.0f;
|
||||
galaga_shared.playery = galaga_game.player->y;
|
||||
galaga_shared.rnd = galaga_random();
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, starfield_draw());
|
||||
|
||||
/*
|
||||
* akgl_game_update is update-every-actor, step-the-physics, draw-the-
|
||||
* world. Updating every actor is where the forty scripts run: each
|
||||
* enemy's updatefunc is the hook in enemies.c, and that hook is a BASIC
|
||||
* call. Held back on the menu screens so the world stands still there.
|
||||
*/
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_PLAY ) {
|
||||
PASS(errctx, akgl_game_update(NULL));
|
||||
PASS(errctx, check_transitions(running));
|
||||
}
|
||||
|
||||
/* The banner is direct text, drawn before the UI bracket. */
|
||||
if ( galaga_game.screen == GALAGA_SCREEN_TITLE ) {
|
||||
PASS(errctx, draw_banner("GALAGA"));
|
||||
} else if ( galaga_game.screen == GALAGA_SCREEN_GAMEOVER ) {
|
||||
PASS(errctx, draw_banner("GAME OVER"));
|
||||
} else if ( galaga_game.screen == GALAGA_SCREEN_VICTORY ) {
|
||||
PASS(errctx, draw_banner("VICTORY"));
|
||||
}
|
||||
|
||||
/* The UI bracket sits between akgl_game_update and frame_end, exactly as
|
||||
* libakgl docs/22-ui.md draws it. */
|
||||
PASS(errctx, akgl_ui_frame_begin());
|
||||
switch ( galaga_game.screen ) {
|
||||
case GALAGA_SCREEN_TITLE:
|
||||
PASS(errctx, declare_title());
|
||||
break;
|
||||
case GALAGA_SCREEN_PLAY:
|
||||
PASS(errctx, declare_play());
|
||||
break;
|
||||
case GALAGA_SCREEN_GAMEOVER:
|
||||
case GALAGA_SCREEN_VICTORY:
|
||||
PASS(errctx, declare_end());
|
||||
break;
|
||||
}
|
||||
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
|
||||
|
||||
PASS(errctx, consume_menus(running));
|
||||
|
||||
if ( (SHOTPATH != NULL) && (galaga_game.frame == SHOTFRAME) ) {
|
||||
PASS(errctx, save_screenshot(SHOTPATH));
|
||||
}
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *run(int frames)
|
||||
{
|
||||
bool running = true;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
LAST_NS = SDL_GetTicksNS();
|
||||
while ( running == true ) {
|
||||
PASS(errctx, frame(&running));
|
||||
if ( (frames > 0) && (galaga_game.frame >= frames) ) {
|
||||
running = false;
|
||||
}
|
||||
/* A crude frame limiter. A game on a real display should ask SDL for
|
||||
* vsync; this one has to work under the dummy video driver, where
|
||||
* there is nothing to sync to. */
|
||||
SDL_Delay(16);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- teardown --- */
|
||||
|
||||
/**
|
||||
* @brief Give back what the process is holding.
|
||||
*
|
||||
* There is no akgl_game_shutdown; teardown is the application's. Fonts have
|
||||
* to unload before TTF_Quit destroys them underneath the registry. IGNORE()
|
||||
* on every call: a teardown failure must not mask whatever error is already
|
||||
* being reported.
|
||||
*/
|
||||
static void shutdown_game(void)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount > 0 ) {
|
||||
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
|
||||
}
|
||||
}
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ args --- */
|
||||
|
||||
static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir,
|
||||
char **script, int *frames)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
|
||||
FAIL_ZERO_RETURN(errctx, script, AKERR_NULLPOINTER, "script");
|
||||
FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
|
||||
|
||||
for ( i = 1; i < argc; i++ ) {
|
||||
if ( strcmp(argv[i], "--autoplay") == 0 ) {
|
||||
galaga_game.autoplay = true;
|
||||
} else if ( strcmp(argv[i], "--frames") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
|
||||
PASS(errctx, aksl_atoi(argv[i], frames));
|
||||
} else if ( strcmp(argv[i], "--assets") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
|
||||
*assetdir = argv[i];
|
||||
} else if ( strcmp(argv[i], "--script") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--script needs a path");
|
||||
*script = argv[i];
|
||||
} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--screenshot needs a path");
|
||||
SHOTPATH = argv[i];
|
||||
} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
|
||||
i += 1;
|
||||
FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE,
|
||||
"--screenshot-frame needs a number");
|
||||
PASS(errctx, aksl_atoi(argv[i], &SHOTFRAME));
|
||||
} else {
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"usage: galaga [--assets DIR] [--script PATH] [--frames N]"
|
||||
" [--autoplay] [--screenshot PATH] [--screenshot-frame N]"
|
||||
);
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
char *assetdir = GALAGA_ASSET_DIR;
|
||||
char *script = GALAGA_SCRIPT_PATH;
|
||||
int frames = 0;
|
||||
uint16_t fontid = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, parse_args(argc, argv, &assetdir, &script, &frames));
|
||||
CATCH(errctx, startup());
|
||||
CATCH(errctx, load_assets(assetdir));
|
||||
/* The engine refuses to start when the script will not boot: a game
|
||||
* whose enemies cannot think is not a game missing a feature. */
|
||||
CATCH(errctx, galaga_script_boot(script));
|
||||
CATCH(errctx, akgl_ui_init(GALAGA_VIEW_WIDTH, GALAGA_VIEW_HEIGHT));
|
||||
CATCH(errctx, akgl_text_loadfont("hud", GALAGA_FONT_PATH, 28));
|
||||
CATCH(errctx, akgl_text_loadfont("banner", GALAGA_FONT_PATH, 84));
|
||||
CATCH(errctx, akgl_ui_font_register("hud", &fontid));
|
||||
CATCH(errctx, galaga_player_spawn());
|
||||
CATCH(errctx, galaga_player_controls());
|
||||
starfield_seed();
|
||||
galaga_game.screen = GALAGA_SCREEN_TITLE;
|
||||
galaga_game.lives = 3;
|
||||
CATCH(errctx, run(frames));
|
||||
} CLEANUP {
|
||||
shutdown_game();
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run");
|
||||
/* Set a flag rather than returning: leaving a HANDLE block early
|
||||
* skips FINISH's RELEASE_ERROR and leaks the context's pool slot. */
|
||||
FAILED = 1;
|
||||
/* FINISH_NORETURN rather than FINISH: FINISH expands a return that an
|
||||
* int-returning function cannot compile. */
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
/*
|
||||
* The readout is the evidence: exiting 0 is not proof the wave flew. A
|
||||
* headless CI log gets the same line a reader's terminal does, and the
|
||||
* script-error count is the line's whole reason to exist -- a wave of
|
||||
* dumb enemies still exits 0.
|
||||
*/
|
||||
SDL_Log(
|
||||
"galaga: %d frames, screen %d, score %d, alive %d, kills bee %d bfly %d boss %d,"
|
||||
" shots bee %d bfly %d boss %d, script errors %d",
|
||||
galaga_game.frame,
|
||||
(int)galaga_game.screen,
|
||||
galaga_game.score,
|
||||
galaga_enemies_alive(),
|
||||
galaga_game.kills[GALAGA_ENEMY_BEE],
|
||||
galaga_game.kills[GALAGA_ENEMY_BUTTERFLY],
|
||||
galaga_game.kills[GALAGA_ENEMY_BOSS],
|
||||
galaga_game.shots[GALAGA_ENEMY_BEE],
|
||||
galaga_game.shots[GALAGA_ENEMY_BUTTERFLY],
|
||||
galaga_game.shots[GALAGA_ENEMY_BOSS],
|
||||
galaga_game.script_errors);
|
||||
return FAILED;
|
||||
}
|
||||
407
examples/galaga/player.c
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @file player.c
|
||||
* @brief The player's ship, its shots, and every collision in the game.
|
||||
*
|
||||
* Bullets and collision are C forever -- they are engine, not behavior. The
|
||||
* per-frame budget for the script is spent on the forty things that think;
|
||||
* nothing here thinks, it just moves and intersects.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
#include "galaga.h"
|
||||
|
||||
/* Points per kill, indexed by enemy kind. */
|
||||
static const int KILL_SCORE[GALAGA_ENEMY_KINDS] = {
|
||||
/* bee butterfly boss */
|
||||
50, 80, 150
|
||||
};
|
||||
|
||||
static uint32_t PSHOT_SERIAL = 0;
|
||||
|
||||
/* -------------------------------------------------------------- hitboxes --- */
|
||||
|
||||
/*
|
||||
* Actor x/y is a sprite's top-left corner. Every box is inset from the
|
||||
* artwork's rectangle, because the PNGs carry transparent margin and wing
|
||||
* tips that should not kill anybody.
|
||||
*/
|
||||
static void player_box(akgl_Actor *actor, SDL_FRect *dest)
|
||||
{
|
||||
dest->x = actor->x + 12.0f;
|
||||
dest->y = actor->y + 8.0f;
|
||||
dest->w = 75.0f;
|
||||
dest->h = 60.0f;
|
||||
}
|
||||
|
||||
static void enemy_box(akgl_Actor *actor, SDL_FRect *dest)
|
||||
{
|
||||
dest->x = actor->x + 8.0f;
|
||||
dest->y = actor->y + 8.0f;
|
||||
dest->w = 78.0f;
|
||||
dest->h = 68.0f;
|
||||
}
|
||||
|
||||
static void shot_box(akgl_Actor *actor, SDL_FRect *dest)
|
||||
{
|
||||
dest->x = actor->x;
|
||||
dest->y = actor->y;
|
||||
dest->w = 9.0f;
|
||||
dest->h = 54.0f;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- player shots --- */
|
||||
|
||||
/**
|
||||
* @brief Kill one enemy: score it, blow it up, free its slot.
|
||||
*/
|
||||
static akerr_ErrorContext *kill_enemy(int index)
|
||||
{
|
||||
akgl_Actor *actor = NULL;
|
||||
galaga_Enemy *enemy = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_NONZERO_RETURN(errctx, (index < 0 || index >= GALAGA_MAX_ENEMIES),
|
||||
AKERR_OUTOFBOUNDS, "enemy index %d", index);
|
||||
actor = galaga_enemy_actors[index];
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "enemy %d is already gone", index);
|
||||
enemy = &galaga_enemies[index];
|
||||
|
||||
galaga_game.score += KILL_SCORE[enemy->kind];
|
||||
galaga_game.kills[enemy->kind] += 1;
|
||||
PASS(errctx, galaga_boom_spawn(actor->x + 20.0f, actor->y + 20.0f));
|
||||
PASS(errctx, akgl_heap_release_actor(actor));
|
||||
galaga_enemy_actors[index] = NULL;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Move a player shot and test it against every live enemy.
|
||||
*
|
||||
* The classic O(shots x enemies) sweep: at most 2 x 40 rectangle tests a
|
||||
* frame, which is noise. A hit costs the enemy a point of hp; the boss's
|
||||
* second point is the script's business to survive, not this file's.
|
||||
*/
|
||||
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
|
||||
{
|
||||
SDL_FRect mine;
|
||||
SDL_FRect theirs;
|
||||
bool hit = false;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
obj->y -= 900.0f * galaga_game.dt;
|
||||
if ( obj->y < -60.0f ) {
|
||||
galaga_game.player_shots_live -= 1;
|
||||
PASS(errctx, akgl_heap_release_actor(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
shot_box(obj, &mine);
|
||||
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
|
||||
if ( galaga_enemy_actors[i] == NULL ) {
|
||||
continue;
|
||||
}
|
||||
enemy_box(galaga_enemy_actors[i], &theirs);
|
||||
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
|
||||
if ( !hit ) {
|
||||
continue;
|
||||
}
|
||||
galaga_enemies[i].hp -= 1;
|
||||
if ( galaga_enemies[i].hp <= 0 ) {
|
||||
PASS(errctx, kill_enemy(i));
|
||||
}
|
||||
galaga_game.player_shots_live -= 1;
|
||||
PASS(errctx, akgl_heap_release_actor(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *player_fire(akgl_Actor *player)
|
||||
{
|
||||
akgl_Actor *shot = NULL;
|
||||
char name[32];
|
||||
int count = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
||||
|
||||
PSHOT_SERIAL += 1;
|
||||
PASS(errctx, aksl_snprintf(&count, name, sizeof(name), "pshot%u", PSHOT_SERIAL));
|
||||
PASS(errctx, akgl_heap_next_actor(&shot));
|
||||
PASS(errctx, akgl_actor_initialize(shot, name));
|
||||
PASS(errctx, akgl_actor_set_character(shot, "galaga_playershot"));
|
||||
/* AFTER initialize: it resets all seven hooks. */
|
||||
shot->updatefunc = &player_shot_update;
|
||||
shot->movement_controls_face = false;
|
||||
shot->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
shot->visible = true;
|
||||
shot->x = player->x + 45.0f;
|
||||
shot->y = player->y - 44.0f;
|
||||
|
||||
galaga_game.player_shots_live += 1;
|
||||
galaga_game.fire_cooldown = 0.22f;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- player hit --- */
|
||||
|
||||
static akerr_ErrorContext *player_hit(akgl_Actor *player)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
||||
galaga_game.lives -= 1;
|
||||
galaga_game.respawn_timer = 2.0f;
|
||||
PASS(errctx, galaga_boom_spawn(player->x + 25.0f, player->y + 10.0f));
|
||||
player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The player's own update hook: motion, fire, and what can kill it.
|
||||
*/
|
||||
static akerr_ErrorContext *player_update(akgl_Actor *obj)
|
||||
{
|
||||
SDL_FRect mine;
|
||||
SDL_FRect theirs;
|
||||
bool hit = false;
|
||||
float dx = 0.0f;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
dx = 0.0f;
|
||||
if ( galaga_game.moveleft ) {
|
||||
dx -= GALAGA_PLAYER_SPEED;
|
||||
}
|
||||
if ( galaga_game.moveright ) {
|
||||
dx += GALAGA_PLAYER_SPEED;
|
||||
}
|
||||
obj->x += dx * galaga_game.dt;
|
||||
if ( obj->x < GALAGA_PLAYER_MARGIN ) {
|
||||
obj->x = GALAGA_PLAYER_MARGIN;
|
||||
}
|
||||
if ( obj->x > (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f ) {
|
||||
obj->x = (float)GALAGA_VIEW_WIDTH - GALAGA_PLAYER_MARGIN - 99.0f;
|
||||
}
|
||||
|
||||
galaga_game.fire_cooldown -= galaga_game.dt;
|
||||
if ( galaga_game.firing
|
||||
&& galaga_game.fire_cooldown <= 0.0f
|
||||
&& galaga_game.player_shots_live < GALAGA_MAX_PLAYER_SHOTS
|
||||
&& galaga_game.screen == GALAGA_SCREEN_PLAY ) {
|
||||
PASS(errctx, player_fire(obj));
|
||||
}
|
||||
|
||||
/*
|
||||
* Respawn grace: two seconds of blinking invulnerability. The blink is
|
||||
* the `visible` flag, which is deliberate hiding -- the actor still
|
||||
* updates, it just is not drawn on the off frames.
|
||||
*/
|
||||
if ( galaga_game.respawn_timer > 0.0f ) {
|
||||
galaga_game.respawn_timer -= galaga_game.dt;
|
||||
obj->visible = ((galaga_game.frame / 6) % 2) == 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
obj->visible = true;
|
||||
|
||||
player_box(obj, &mine);
|
||||
|
||||
/* Enemy shots. */
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
if ( strncmp(akgl_heap_actors[i].name, "eshot", 5) != 0 ) {
|
||||
continue;
|
||||
}
|
||||
shot_box(&akgl_heap_actors[i], &theirs);
|
||||
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
|
||||
if ( hit ) {
|
||||
galaga_game.enemy_shots_live -= 1;
|
||||
PASS(errctx, akgl_heap_release_actor(&akgl_heap_actors[i]));
|
||||
PASS(errctx, player_hit(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Diving enemies. The formation never reaches this low, so testing all
|
||||
* forty is the same answer as testing the divers, without a state read. */
|
||||
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
|
||||
if ( galaga_enemy_actors[i] == NULL ) {
|
||||
continue;
|
||||
}
|
||||
enemy_box(galaga_enemy_actors[i], &theirs);
|
||||
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
|
||||
if ( hit ) {
|
||||
PASS(errctx, kill_enemy(i));
|
||||
PASS(errctx, player_hit(obj));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- controls --- */
|
||||
|
||||
static akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.moveleft = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.moveleft = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *right_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.moveright = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *right_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.moveright = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *fire_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.firing = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *fire_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)obj; (void)event;
|
||||
galaga_game.firing = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *galaga_player_controls(void)
|
||||
{
|
||||
akgl_Control control;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset(&control, 0, sizeof(control));
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
control.key = SDLK_LEFT;
|
||||
control.handler_on = &left_on;
|
||||
control.handler_off = &left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||
|
||||
control.key = SDLK_RIGHT;
|
||||
control.handler_on = &right_on;
|
||||
control.handler_off = &right_off;
|
||||
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||
|
||||
control.key = SDLK_SPACE;
|
||||
control.handler_on = &fire_on;
|
||||
control.handler_off = &fire_off;
|
||||
PASS(errctx, akgl_controller_pushmap(0, &control));
|
||||
|
||||
akgl_controlmaps[0].target = galaga_game.player;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- spawn --- */
|
||||
|
||||
akerr_ErrorContext *galaga_player_spawn(void)
|
||||
{
|
||||
akgl_Actor *player = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_heap_next_actor(&player));
|
||||
PASS(errctx, akgl_actor_initialize(player, "player"));
|
||||
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
|
||||
/* AFTER initialize: it resets all seven hooks. */
|
||||
player->updatefunc = &player_update;
|
||||
player->movement_controls_face = false;
|
||||
player->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
player->visible = true;
|
||||
player->x = (float)GALAGA_VIEW_WIDTH / 2.0f - 50.0f;
|
||||
player->y = GALAGA_PLAYER_Y;
|
||||
|
||||
galaga_game.player = player;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- autoplay --- */
|
||||
|
||||
/**
|
||||
* @brief Send one synthetic key event through the controller.
|
||||
*
|
||||
* Through akgl_controller_handle_event(), never the handlers directly: the
|
||||
* point of autoplay is to exercise the same path a keyboard does.
|
||||
*/
|
||||
static akerr_ErrorContext *synth_key(SDL_Keycode key, bool down)
|
||||
{
|
||||
SDL_Event event;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset(&event, 0, sizeof(event));
|
||||
event.type = (down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP);
|
||||
event.key.key = key;
|
||||
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The scripted pilot for headless runs: hold fire, sweep the floor.
|
||||
*/
|
||||
akerr_ErrorContext *galaga_player_autoplay(int frame)
|
||||
{
|
||||
int phase = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* Hold fire until the wave has mostly assembled: shooting the entry
|
||||
* stream point-blank empties the formation before it exists, which makes
|
||||
* both the game and its figure worse. */
|
||||
if ( frame == 300 ) {
|
||||
PASS(errctx, synth_key(SDLK_SPACE, true));
|
||||
}
|
||||
phase = frame % 240;
|
||||
if ( phase == 30 ) {
|
||||
PASS(errctx, synth_key(SDLK_LEFT, true));
|
||||
} else if ( phase == 90 ) {
|
||||
PASS(errctx, synth_key(SDLK_LEFT, false));
|
||||
PASS(errctx, synth_key(SDLK_RIGHT, true));
|
||||
} else if ( phase == 210 ) {
|
||||
PASS(errctx, synth_key(SDLK_RIGHT, false));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
281
examples/galaga/script.c
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @file script.c
|
||||
* @brief The boundary: everything that touches the interpreter lives here.
|
||||
*
|
||||
* One runtime, one script, three host types, three bindings. The engine calls
|
||||
* exactly one thing per enemy per frame -- galaga_script_update_enemy() -- and
|
||||
* that function is the whole protocol: rebind, call, recover, reset.
|
||||
*
|
||||
* The structure types are declared once, in C, right below. The script never
|
||||
* declares a TYPE of its own; akbasic_host_register_type() makes these structs
|
||||
* *be* the BASIC types, offsets taken from offsetof() so the two sides cannot
|
||||
* drift (include/akbasic/host.h).
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akbasic/environment.h>
|
||||
#include <akbasic/error.h>
|
||||
#include <akbasic/host.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
#include "galaga.h"
|
||||
|
||||
/* The interpreter. Static because an akbasic_Runtime is far too big for a
|
||||
* stack frame -- 2.40 MiB on this branch. */
|
||||
static akbasic_Runtime SCRIPT;
|
||||
static akbasic_TextSink SINK;
|
||||
static akbasic_StdioSink SINKSTATE;
|
||||
|
||||
/** @brief Longest galaga.bas this loader will accept. */
|
||||
#define GALAGA_MAX_SCRIPT_BYTES 16384
|
||||
|
||||
static char SOURCE[GALAGA_MAX_SCRIPT_BYTES];
|
||||
|
||||
/*
|
||||
* Enemy kind -> BASIC function name. Dispatch is a table, not a conditional:
|
||||
* adding a kind is one row here and one DEF in galaga.bas.
|
||||
*/
|
||||
static const char *UPDATE_FUNCTION[GALAGA_ENEMY_KINDS] = {
|
||||
"UPDATEBEE", /* GALAGA_ENEMY_BEE */
|
||||
"UPDATEBFLY", /* GALAGA_ENEMY_BUTTERFLY */
|
||||
"UPDATEBOSS" /* GALAGA_ENEMY_BOSS */
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- host types --- */
|
||||
|
||||
static const akbasic_HostField ENEMY_FIELDS[] = {
|
||||
/* struct member BASIC name C representation */
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
|
||||
};
|
||||
static const akbasic_HostType ENEMY_TYPE = {
|
||||
"ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8
|
||||
};
|
||||
|
||||
/*
|
||||
* The live actor itself. This is the demonstrative point of the whole
|
||||
* example: the script writes the engine's *real* actor memory -- the same x
|
||||
* the renderer reads -- with no copy in either direction.
|
||||
*/
|
||||
static const akbasic_HostField ACTOR_FIELDS[] = {
|
||||
AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL )
|
||||
};
|
||||
static const akbasic_HostType ACTOR_TYPE = {
|
||||
"ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4
|
||||
};
|
||||
|
||||
static const akbasic_HostField GAME_FIELDS[] = {
|
||||
AKBASIC_HOST_FIELD( galaga_Shared, playerx, "PLAYERX%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( galaga_Shared, playery, "PLAYERY%", AKBASIC_HOSTFIELD_FLOAT ),
|
||||
AKBASIC_HOST_FIELD( galaga_Shared, wave, "WAVE#", AKBASIC_HOSTFIELD_INT32 ),
|
||||
AKBASIC_HOST_FIELD( galaga_Shared, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
|
||||
};
|
||||
static const akbasic_HostType GAME_TYPE = {
|
||||
"GAME", sizeof(galaga_Shared), GAME_FIELDS, 4
|
||||
};
|
||||
|
||||
/* Placeholders the boot bindings point at until the first real rebind. A
|
||||
* binding is borrowed, never copied, so these must be static storage. */
|
||||
static galaga_Enemy SCRATCH_ENEMY;
|
||||
static akgl_Actor SCRATCH_ACTOR;
|
||||
|
||||
/* ---------------------------------------------------------------- boot --- */
|
||||
|
||||
static akerr_ErrorContext *read_script(char *path)
|
||||
{
|
||||
FILE *fp = NULL;
|
||||
size_t got = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
|
||||
fp = fopen(path, "rb");
|
||||
FAIL_ZERO_RETURN(errctx, fp, AKERR_IO, "Cannot open the enemy script %s", path);
|
||||
ATTEMPT {
|
||||
got = fread(SOURCE, 1, sizeof(SOURCE) - 1, fp);
|
||||
SOURCE[got] = '\0';
|
||||
FAIL_NONZERO_BREAK(errctx, (got >= sizeof(SOURCE) - 1), AKERR_OUTOFBOUNDS,
|
||||
"%s does not fit in the %d byte script buffer",
|
||||
path, GALAGA_MAX_SCRIPT_BYTES);
|
||||
} CLEANUP {
|
||||
fclose(fp);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief One dry call of every dispatch-table function, at boot.
|
||||
*
|
||||
* A missing or misspelled DEF fails here, at startup, with the function's name
|
||||
* in the message -- not on frame one of the first wave. The scratch enemy's
|
||||
* state word is zero, so no maneuver block runs and nothing moves.
|
||||
*/
|
||||
static akerr_ErrorContext *dry_run(void)
|
||||
{
|
||||
akbasic_Value dt;
|
||||
akbasic_Value *argp[1];
|
||||
akbasic_Value *result = NULL;
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset(&SCRATCH_ENEMY, 0, sizeof(SCRATCH_ENEMY));
|
||||
memset(&dt, 0, sizeof(dt));
|
||||
dt.valuetype = AKBASIC_TYPE_FLOAT;
|
||||
dt.floatval = 0.0;
|
||||
argp[0] = &dt;
|
||||
|
||||
for ( i = 0; i < GALAGA_ENEMY_KINDS; i++ ) {
|
||||
PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", &SCRATCH_ENEMY));
|
||||
PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", &SCRATCH_ACTOR));
|
||||
PASS(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[i],
|
||||
argp, 1, &result));
|
||||
/*
|
||||
* A body that died reports through the sink and answers zero; the
|
||||
* dropped mode is the only signal C gets. At boot that must be fatal
|
||||
* and must say which function -- not frame one of the first wave.
|
||||
*/
|
||||
FAIL_NONZERO_RETURN(errctx, (SCRIPT.mode != AKBASIC_MODE_RUN), AKERR_VALUE,
|
||||
"%s died during the boot dry run; the interpreter's report"
|
||||
" is above", UPDATE_FUNCTION[i]);
|
||||
PASS(errctx, akbasic_environment_zero(SCRIPT.environment));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *galaga_script_boot(char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
|
||||
|
||||
PASS(errctx, akbasic_error_register());
|
||||
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
|
||||
PASS(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
|
||||
|
||||
PASS(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE));
|
||||
PASS(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE));
|
||||
PASS(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE));
|
||||
|
||||
PASS(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY));
|
||||
PASS(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR));
|
||||
PASS(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared));
|
||||
|
||||
PASS(errctx, read_script(path));
|
||||
PASS(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
|
||||
|
||||
/*
|
||||
* A "no top level code" script still has to run once: executing the DEF
|
||||
* statements is what files the functions. The run is bounded because a
|
||||
* script that is all definitions has no business taking more than a step
|
||||
* per line, and an accidental loop at boot should be a diagnosis, not a
|
||||
* hang.
|
||||
*/
|
||||
PASS(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
PASS(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
|
||||
|
||||
/*
|
||||
* The program has now ended and the runtime sits in QUIT mode, where a
|
||||
* multi-line DEF called from the host returns a silent zero. Forcing the
|
||||
* mode back makes the bodies run, and it stays put because nothing here
|
||||
* ever steps the runtime again. Issue #8 tracks making this unnecessary.
|
||||
*/
|
||||
PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
|
||||
PASS(errctx, dry_run());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ per frame --- */
|
||||
|
||||
akerr_ErrorContext *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt)
|
||||
{
|
||||
akbasic_Value dtval;
|
||||
akbasic_Value *argp[1];
|
||||
akbasic_Value *result = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "enemy");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
FAIL_NONZERO_RETURN(errctx, (enemy->kind < 0 || enemy->kind >= GALAGA_ENEMY_KINDS),
|
||||
AKERR_VALUE, "Enemy kind %d has no update function", enemy->kind);
|
||||
|
||||
PASS(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
|
||||
PASS(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
|
||||
|
||||
memset(&dtval, 0, sizeof(dtval));
|
||||
dtval.valuetype = AKBASIC_TYPE_FLOAT;
|
||||
dtval.floatval = (double)dt;
|
||||
argp[0] = &dtval;
|
||||
|
||||
/*
|
||||
* An error in an enemy's function is that script's problem, not the
|
||||
* engine's: the enemy goes dumb -- cleared to a formation hold it will
|
||||
* never leave -- and the frame lives. HANDLE_DEFAULT absorbs whatever the
|
||||
* interpreter raised; the first failure is logged with the function's
|
||||
* name, the rest are counted, because sixty a second of the same message
|
||||
* is how a log stops being read.
|
||||
*/
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, (char *)UPDATE_FUNCTION[enemy->kind],
|
||||
argp, 1, &result));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
if ( galaga_game.script_errors == 0 ) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "first script error; this enemy is now dumb");
|
||||
}
|
||||
galaga_game.script_errors += 1;
|
||||
enemy->state = GALAGA_ES_FORMATION;
|
||||
enemy->fire = 0;
|
||||
} FINISH(errctx, true);
|
||||
|
||||
/*
|
||||
* A BASIC-level error in the body is quieter than an interpreter error:
|
||||
* it reports through the sink, the call answers a stale value, and the
|
||||
* runtime falls out of RUN mode -- after which every later call is a
|
||||
* silent no-op. The mode is the tell. Revival is two calls:
|
||||
* clear_error(), because a run's first error latches and every line is
|
||||
* skipped while it stands, and the same set_mode(RUN) the boot needed
|
||||
* (issue #8's mechanics). The enemy is treated exactly like the
|
||||
* interpreter-error case above.
|
||||
*/
|
||||
if ( SCRIPT.mode != AKBASIC_MODE_RUN ) {
|
||||
if ( galaga_game.script_errors == 0 ) {
|
||||
SDL_Log("first script error (reported by the interpreter above);"
|
||||
" enemy %s is now dumb", UPDATE_FUNCTION[enemy->kind]);
|
||||
}
|
||||
galaga_game.script_errors += 1;
|
||||
enemy->state = GALAGA_ES_FORMATION;
|
||||
enemy->fire = 0;
|
||||
PASS(errctx, akbasic_runtime_clear_error(&SCRIPT));
|
||||
PASS(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
|
||||
}
|
||||
|
||||
/*
|
||||
* Load-bearing: akbasic_runtime_call_function() parks every result in the
|
||||
* caller environment's per-line value scratch, and a host calling in a
|
||||
* loop never crosses the line boundary that would reset it. Without this
|
||||
* the pool drains in under two frames of a 40-enemy wave.
|
||||
*/
|
||||
PASS(errctx, akbasic_environment_zero(SCRIPT.environment));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||