// 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 #include #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; }