joeylib2/examples/spacetaxi/spacetaxi.c

571 lines
22 KiB
C

// Space Taxi (JoeyLib port) -- main loop and game state machine.
//
// Build: see make/{dos,amiga,atarist,iigs}.mk -- target is
// `EXAMPLE=spacetaxi`.
//
// Runtime layout:
// 1. jlInit + jlStageGet
// 2. stRenderInit loads tile and sprite asset banks from disk
// 3. State machine: title -> level-intro -> playing -> done
// 4. Each frame:
// - jlInputPoll (read joystick + keyboard)
// - stEngineTick (taxi physics)
// - stPassengerTick (passenger AI)
// - stRenderFrame (sprite save/restore + draw + HUD)
// - jlAudioFrameTick
// - jlWaitVBL
#include <stdio.h>
#include <string.h>
#include "spacetaxi.h"
#include "stDemoStreams.h"
// The C64 runs its physics + game logic once per GAME TICK, and a game
// tick is 2 video frames (the main loop waits on the raster twice per
// iteration, $5F94 + $5FA3) = ~30 Hz NTSC. The port renders at each
// platform's own frame rate (70 Hz DOS, 50 Hz ST/Amiga, ~15 fps IIgs),
// so gameplay is stepped on a fixed 33 ms accumulator rather than once
// per rendered frame. Without this the cab accelerates ~2.3x too fast
// on DOS and half-speed on the IIgs -- every per-tick constant (accel
// 14, gravity 1, the $7D8F templates, the demo RLE counts) is authored
// for this 30 Hz cadence, so only stepping at 30 Hz reproduces the
// original feel, identically on every port.
#define ST_GAME_TICK_MS 33u
// Cap catch-up ticks per rendered frame so a hitch (disk stall, cold
// cache) drops backlog instead of spiraling; ~165 ms simulated max.
#define ST_MAX_CATCHUP_TICKS 5u
static void applyInput(StGameT *game);
static void demoEnter(StGameT *game);
static void demoExitToTitle(StGameT *game);
static void demoTick(StGameT *game);
static void demoTickInput(StGameT *game);
static bool loadLevelByIndex(StGameT *game, uint8_t idx);
static void playingTick(StGameT *game);
// All 24 canonical Space Taxi levels (A..X), extracted from the C64
// ROM via stuff/spacetaxi/romToLevel.py. level01 = scene 0 = "A",
// level24 = scene 23 = "X".
static const char *gLevelPaths[] = {
"levels/level01.dat", "levels/level02.dat",
"levels/level03.dat", "levels/level04.dat",
"levels/level05.dat", "levels/level06.dat",
"levels/level07.dat", "levels/level08.dat",
"levels/level09.dat", "levels/level10.dat",
"levels/level11.dat", "levels/level12.dat",
"levels/level13.dat", "levels/level14.dat",
"levels/level15.dat", "levels/level16.dat",
"levels/level17.dat", "levels/level18.dat",
"levels/level19.dat", "levels/level20.dat",
"levels/level21.dat", "levels/level22.dat",
"levels/level23.dat", "levels/level24.dat",
};
#define ST_LEVEL_COUNT (sizeof(gLevelPaths) / sizeof(gLevelPaths[0]))
// Attract-demo rotation cursor (mirrors $5143). Playback is stepped by
// the main loop's fixed game-tick accumulator, so no wall-clock here.
static uint8_t gDemoRotation;
// Enter (or advance to the next screen of) the rolling attract demo:
// pick the rotation slot, load its level, seed the recorded stream
// ($6262 seeds mask/timer from the head), zero the demo score.
static void demoEnter(StGameT *game) {
const StDemoEntryT *e = &kDemoRotation[gDemoRotation & 3u];
gDemoRotation++;
if (!loadLevelByIndex(game, e->levelIndex)) {
demoExitToTitle(game);
return;
}
stEngineReset(game);
game->score = 0u;
game->demoStream = e->stream;
game->demoLen = e->length;
game->demoMask = e->stream[0];
game->demoTimer = e->stream[1];
game->demoOff = 2u;
game->demoEnded = false;
game->state = ST_STATE_DEMO;
}
static void demoExitToTitle(StGameT *game) {
if (stLevelLoad(&game->level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset(); // replay the intro -> attract cycle
stAudioSfxThrust(false);
game->state = ST_STATE_TITLE;
}
// Recorded-input playback, substituting for applyInput ($48F2): DEC
// the pair timer each game tick; at zero take the next [mask][count]
// pair. Mask bits 0-3 = UP/DOWN/LEFT/RIGHT (bit 7 is always set in
// the recordings, no fire bit -- the demo cab never lowers its gear,
// which is why every recorded ride ends in a crash).
static void demoTickInput(StGameT *game) {
StTaxiT *t = &game->taxi;
uint8_t mask;
bool up;
bool down;
bool left;
bool right;
// Advance the RLE stream by exactly one game tick (the caller runs
// this once per fixed 33 ms step, in lockstep with stEngineTick).
if (game->demoTimer > 0u) {
game->demoTimer--;
}
if (game->demoTimer == 0u) {
if ((uint16_t)(game->demoOff + 1u) < game->demoLen) {
game->demoMask = game->demoStream[game->demoOff];
game->demoTimer = game->demoStream[game->demoOff + 1u];
game->demoOff = (uint16_t)(game->demoOff + 2u);
} else {
// The C64 has no end-of-stream check (every recorded ride
// crashes first); hold idle until then.
game->demoMask = 0x80u;
game->demoTimer = 0xFFu;
}
}
mask = game->demoMask;
up = (mask & 0x01u) != 0u;
down = (mask & 0x02u) != 0u;
left = (mask & 0x04u) != 0u;
right = (mask & 0x08u) != 0u;
t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0));
t->thrusting = (up || down || left || right);
t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
(left ? 4u : 0u) | (right ? 8u : 0u));
if (left) {
t->facing = ST_DIR_LEFT;
} else if (right) {
t->facing = ST_DIR_RIGHT;
}
}
// One fixed game-tick of the attract demo: recorded input -> physics ->
// passenger. The main loop runs this in lockstep with the 30 Hz clock.
static void demoTick(StGameT *game) {
demoTickInput(game);
stEngineTick(game);
stPassengerTick(game);
}
// One fixed game-tick of PLAYING: warp recede, or input -> physics ->
// passenger, with the land-SFX edge and the transporter-exit trigger.
// The thrust SFX is left to the caller (once per rendered frame).
static void playingTick(StGameT *game) {
bool wasLanded;
if (game->taxi.warpFrame > 0u) {
if (stEngineWarpTick(game)) {
stPassengerTransporterExit(game);
game->state = ST_STATE_LEVEL_DONE;
}
return;
}
wasLanded = game->taxi.landed;
applyInput(game);
stEngineTick(game);
stPassengerTick(game);
if (!wasLanded && game->taxi.landed) {
stAudioSfxLand();
}
// Flying up through the top-wall transporter exits the screen:
// begin the shrink-warp (delivers a carried "UP PLEASE" fare and
// advances the screen when it ends).
if (game->taxi.crashTicks == 0u && stEngineInTransporter(game)) {
stEngineStartWarp(game);
}
}
static void applyInput(StGameT *game) {
StTaxiT *t = &game->taxi;
int8_t jx;
int8_t jy;
bool up;
bool down;
bool left;
bool right;
// Joystick port 2 is the canonical Space Taxi control. Keyboard
// is the fallback so the same binary is testable on hosts without
// a stick. jlJoystickX/Y return -127..127; treat anything past
// 1/4 deflection as "held in that direction".
jx = jlJoystickX(JOYSTICK_0);
jy = jlJoystickY(JOYSTICK_0);
up = jlKeyDown(KEY_UP) || jy < -32;
down = jlKeyDown(KEY_DOWN) || jy > 32;
left = jlKeyDown(KEY_LEFT) || jx < -32;
right = jlKeyDown(KEY_RIGHT) || jx > 32;
// FIRE is the LANDING GEAR ($63DD): a press EDGE while airborne
// toggles the gear with an SFX; pressing UP while parked on a pad
// is the takeoff, which retracts the gear ($65AD AND #$FE). (The
// old comment here claiming "fire is never tested in this path"
// was wrong -- $63F1-$6401 tests it every tick.)
{
bool fire = jlKeyDown(KEY_SPACE) || jlJoyDown(JOYSTICK_0, JOY_BUTTON_0);
if (fire && !t->fireHeld && !t->landed) {
t->gearDown = !t->gearDown;
stAudioSfxGear();
}
t->fireHeld = fire;
}
if (up && t->landed) {
t->gearDown = false; // takeoff retracts the gear
}
// Gear down strips lateral thrust BEFORE the direction mask is
// built ($6190 AND #$13 runs upstream of the $60D6 mask store),
// so the flame table never sees L/R with the gear down either.
if (t->gearDown) {
left = false;
right = false;
}
t->thrustDx = (int8_t)((right ? 1 : 0) - (left ? 1 : 0));
t->thrustDy = (int8_t)((down ? 1 : 0) - (up ? 1 : 0));
t->thrusting = (up || down || left || right);
// $716A-style mask for the flame cel table (1=UP 2=DOWN 4=LEFT
// 8=RIGHT, same encoding kFlameCelByDirMask indexes).
t->dirMask = (uint8_t)((up ? 1u : 0u) | (down ? 2u : 0u) |
(left ? 4u : 0u) | (right ? 8u : 0u));
// Facing is purely cosmetic (sprite cel selection). Update when
// horizontal thrust is commanded; keep last facing otherwise so
// a stopped cab stays facing where it was.
if (left) {
t->facing = ST_DIR_LEFT;
} else if (right) {
t->facing = ST_DIR_RIGHT;
}
}
static bool loadLevelByIndex(StGameT *game, uint8_t idx) {
if (idx >= ST_LEVEL_COUNT) {
return false;
}
if (!stLevelLoad(&game->level, gLevelPaths[idx])) {
return false;
}
game->levelIndex = idx;
stRenderLevelChanged();
return true;
}
int main(void) {
jlConfigT config;
jlSurfaceT *stage;
// game embeds a full StLevelT (tilemap + colormap = ~2 KB). As a
// stack local it held ~2 KB across the whole program, overflowing the
// IIgs (llvm-mos) soft stack once the deep asset-load call chain ran
// (stRenderInit -> loadSpriteSheet -> jlSpriteBankLoad -> fopen) and
// silently crashing back to GS/OS. It is a single program-lifetime
// instance, so static (BSS) is both correct and stack-friendly.
static StGameT game;
// Sprite codegen arena: after Phase 11 (shared-walker rewrite of
// the planar sprite path), sprites no longer consume arena bytes
// -- the per-cel work happens inside halSpriteDrawPlanes /
// halSpriteSavePlanes / halSpriteRestorePlanes as static lib code.
// 32 KB is plenty for whatever other codegen needs are around.
// Sprite codegen: the 26 drawn cels (taxi/warp/passenger/flame,
// 24x24 = 3x3-tile) each compile to per-shift save/draw/restore asm.
// Compiling them is what makes sprite blitting fast (the interpreter
// costs ~200 ms/frame on the IIgs 65816). The arena is capped at one
// 64 KB bank (codegenArena attrNoCross); 60 KB fits all 26 cels with
// headroom. The old 32 KB was sized for procedural sprites only.
config.codegenBytes = 60UL * 1024;
config.audioBytes = 32UL * 1024; // music + SFX
if (!jlInit(&config)) {
fprintf(stderr, "jlInit: %s\n", jlLastError());
return 1;
}
jlLogReset();
jlLogF("spacetaxi: build=%s %s", __DATE__, __TIME__);
stage = jlStageGet();
if (stage == NULL) {
jlLogF("spacetaxi: ! jlStageGet returned NULL");
jlShutdown();
return 1;
}
memset(&game, 0, sizeof(game));
game.state = ST_STATE_TITLE;
game.lives = 5;
game.levelIndex = 0;
game.fareTarget = 1; // C64 default: 1 fare per game (see $48AF).
jlLogF("spacetaxi: stRenderInit ...");
stRenderInit(stage);
jlLogF("spacetaxi: stAudioInit ...");
stAudioInit();
// Load the title-screen tilemap. raw.bin captured the C64 game
// at the title screen, so its screen RAM at $0400 IS the title
// (the big SPACE TAXI letters, JOHN F. BUTCHER credit, joystick
// instructions, etc.). romToLevel.py emits title.dat from that
// capture. Falls through to a black field if the asset is
// missing.
jlLogF("spacetaxi: stLevelLoad title.dat ...");
if (stLevelLoad(&game.level, "levels/title.dat")) {
jlLogF("spacetaxi: title loaded, tilebank=%u",
(unsigned)game.level.tileBankId);
stRenderLevelChanged();
} else {
jlLogF("spacetaxi: title load FAILED");
}
jlLogFlush();
// Fixed-timestep gameplay clock. gTickAccumMs banks real elapsed
// time; each frame consumes it in whole 30 Hz game ticks so physics
// advance at the C64's rate regardless of the render frame rate.
uint32_t gPrevTickMs = jlMillisElapsed();
uint32_t gTickAccumMs = 0u;
for (;;) {
uint8_t pendingTicks;
uint8_t tick;
jlInputPoll();
if (jlKeyPressed(KEY_ESCAPE)) {
break;
}
{
uint32_t nowMs = jlMillisElapsed();
uint32_t want;
gTickAccumMs += (uint32_t)(nowMs - gPrevTickMs);
gPrevTickMs = nowMs;
want = gTickAccumMs / ST_GAME_TICK_MS; // wide: no cast wrap
if (want > ST_MAX_CATCHUP_TICKS) {
// A long stall (level load, disk seek): resync to now
// and skip the gap instead of spiraling. Zero the whole
// accumulator so no backlog survives the truncated cast.
want = ST_MAX_CATCHUP_TICKS;
gTickAccumMs = 0u;
} else {
gTickAccumMs -= want * ST_GAME_TICK_MS; // keep sub-tick remainder
}
pendingTicks = (uint8_t)want; // guaranteed <= 5
}
switch (game.state) {
case ST_STATE_TITLE:
{
// C64 title listens for UP (high scores), DOWN
// (instructions), and FIRE. FIRE on the title does NOT
// start gameplay -- it advances to the "GAME VARIATIONS"
// options menu ($5295, reached via $4750 -> JSR $5295),
// where the player selects the cabbie count before the
// game begins. (UP/DOWN high-scores/instructions screens
// are still TODO.)
{
int8_t jy = jlJoystickY(JOYSTICK_0);
if (jlKeyDown(KEY_UP) || jy < -32) {
game.state = ST_STATE_HIGHSCORES;
break;
}
if (jlKeyDown(KEY_DOWN) || jy > 32) {
game.state = ST_STATE_INSTRUCTIONS;
break;
}
}
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.fareTarget = 1u; // C64 default ($48AF sets 1)
game.state = ST_STATE_OPTIONS;
break;
}
// Advance the sparkle -> walk -> takeoff intro (and the
// logo flip) on the fixed 30 Hz clock, so pre-game
// timing is identical on every port.
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_TITLE;
tick++) {
stRenderTitleAdvance();
}
// Intro finished (cab took off): roll the attract
// demo, exactly like the C64's post-takeoff flow.
if (stRenderTitleIntroDone()) {
demoEnter(&game);
}
break;
}
case ST_STATE_DEMO:
{
// Any real input exits the attract loop ($5006 tests
// $DC00 != $7F).
int8_t jx = jlJoystickX(JOYSTICK_0);
int8_t jy = jlJoystickY(JOYSTICK_0);
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0) ||
jlKeyDown(KEY_UP) || jlKeyDown(KEY_DOWN) ||
jlKeyDown(KEY_LEFT) || jlKeyDown(KEY_RIGHT) ||
jx < -32 || jx > 32 || jy < -32 || jy > 32) {
demoExitToTitle(&game);
break;
}
if (game.demoEnded) {
// The recorded ride crashed out; $6B57 skips the
// life bookkeeping in demo mode -- back to title.
demoExitToTitle(&game);
break;
}
if (game.score != 0u) {
// Fare delivered: advance the rotation ($501D ->
// $50C6) without visiting the title.
demoEnter(&game);
break;
}
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_DEMO &&
!game.demoEnded && game.score == 0u;
tick++) {
demoTick(&game);
}
stAudioSfxThrust(game.taxi.thrusting);
break;
}
case ST_STATE_HIGHSCORES:
case ST_STATE_INSTRUCTIONS:
// Both are title sub-screens; FIRE returns to the title.
// The screens cleared the stage, so force a full title
// tilemap repaint on the way back.
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
stRenderLevelChanged();
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
case ST_STATE_OPTIONS:
{
// "GAME VARIATIONS" menu ($5295). LEFT/RIGHT cycle the
// cabbie count 1..4 (C64 stores 0..3 in $7213 and wraps
// with AND #$03 -- so it rolls 4->1 and 1->4); FIRE
// confirms and starts the game. LEFT/RIGHT are edge-
// triggered so one press = one step.
static bool prevLeft = false;
static bool prevRight = false;
int8_t jx = jlJoystickX(JOYSTICK_0);
bool left = jlKeyDown(KEY_LEFT) || jx < -32;
bool right = jlKeyDown(KEY_RIGHT) || jx > 32;
uint8_t sel = (uint8_t)((game.fareTarget - 1u) & 3u);
if (right && !prevRight) {
sel = (uint8_t)((sel + 1u) & 3u);
}
if (left && !prevLeft) {
sel = (uint8_t)((sel - 1u) & 3u);
}
game.fareTarget = (uint8_t)(sel + 1u);
prevLeft = left;
prevRight = right;
if (jlKeyPressed(KEY_SPACE) ||
jlJoyPressed(JOYSTICK_0, JOY_BUTTON_0)) {
game.score = 0;
// "SELECT NUMBER OF CABBIES" ($7213) sets the number of
// taxi LIVES, not a fare quota: gFareCounter increments
// only on the crash path ($6BD6), and the game ends when
// crashes reach the cabbie count. Deliveries never end
// the game; you advance screens by flying up.
game.lives = game.fareTarget;
game.levelIndex = 0;
if (!loadLevelByIndex(&game, 0)) {
jlLogF("spacetaxi: ! cannot load level 0 (DATA/levels/level01.dat)");
jlLogFlush();
goto shutdown;
}
stEngineReset(&game);
game.state = ST_STATE_PLAYING;
}
break;
}
case ST_STATE_PLAYING:
// Advance physics at the fixed 30 Hz cadence -- each
// playingTick handles the warp recede, physics, passenger,
// land SFX, and transporter-exit trigger. Stop early if a
// tick changed state (crash game-over, level done).
for (tick = 0u;
tick < pendingTicks && game.state == ST_STATE_PLAYING;
tick++) {
playingTick(&game);
}
// Continuous thrust SFX reflects the final tick's state.
stAudioSfxThrust(game.taxi.thrusting);
break;
case ST_STATE_LEVEL_DONE:
// No music to stop -- gameplay is silent. The C64 plays
// a score-screen jingle (song 7 / song 6) between levels;
// not yet wired up here.
if (loadLevelByIndex(&game, (uint8_t)(game.levelIndex + 1))) {
stEngineReset(&game);
game.state = ST_STATE_PLAYING;
} else {
// ran out of levels -- back to title (player wins)
if (stLevelLoad(&game.level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
case ST_STATE_GAME_OVER:
if (jlKeyPressed(KEY_SPACE)) {
// Restore the title tilemap so the press-start
// overlay isn't sitting on top of the last-played
// level's art.
if (stLevelLoad(&game.level, "levels/title.dat")) {
stRenderLevelChanged();
}
stRenderTitleReset();
game.state = ST_STATE_TITLE;
}
break;
default:
game.state = ST_STATE_TITLE;
break;
}
// Silence continuous thrust SFX whenever no cab is flying
// under thrust (title, menus, game-over, level-done). The
// attract DEMO is a recorded gameplay session and thrusts
// audibly, so it keeps its own per-tick thrust SFX -- without
// this exclusion the blanket silence overrode it and the demo
// cab flamed in total silence.
if (game.state != ST_STATE_PLAYING && game.state != ST_STATE_DEMO) {
stAudioSfxThrust(false);
}
stRenderFrame(stage, &game);
stAudioFrameTick();
jlAudioFrameTick();
// stRenderFrame already does jlWaitVBL right before its
// jlStagePresent (sync-on-present), so an extra wait here
// would just slow the loop to half framerate.
}
shutdown:
stAudioShutdown();
stRenderShutdown();
jlShutdown();
return 0;
}