// Space Taxi (JoeyLib port) -- front-end flow and frame pacing. // // Build: see make/{dos,amiga,atarist,iigs}.mk -- target is // `EXAMPLE=spacetaxi`. // // The game runs in the host-independent simulation (stSim.c and // friends); this file is the C64's outer flow around it: the title // with its intro, the attract-demo rotation, the two GAME VARIATIONS // menus, the level-name intro screen, the players' turns, game over // and the return to the title ($5EC4 / $48AD / $4741 / $5010 / $61FB). // // Pacing: the C64 runs one game tick per two video frames (its main // loop waits on the raster twice), 30 Hz on NTSC. Every port steps the // simulation from its own vertical blank count: exactly one tick per // two frames on 60 Hz and 50 Hz displays (the latter is the PAL C64's // own cadence), and three ticks per seven frames on the 70 Hz VGA // mode, so motion is a steady multiple of the display and never // keyed to a wall clock. #include #include "spacetaxi.h" #include "stDemoStreams.h" // Diagnostics. On the IIgs the logger drags in vsnprintf and an 8 KB // ring buffer that the bank-0 BSS budget cannot spare, and the game has // no need of them there, so logging compiles to nothing on the 65816 // and stays on the other ports. #if defined(__W65816__) #define ST_LOG_RESET() ((void)0) #define ST_LOG(...) ((void)0) #else #define ST_LOG_RESET() jlLogReset() #define ST_LOG(...) jlLogF(__VA_ARGS__) #endif #define ST_TICK_HZ 30u #define ST_MAX_CATCHUP_TICKS 4u #define ST_LEVEL_COUNT 24u // "GET READY" shows over the old screen while the C64 cycles the // leaving-passenger sprites (three busy waits, about half a second). #define ST_GET_READY_TICKS 15u // $5FBB: the GAME OVER pause is six full busy waits, about two seconds. #define ST_GAME_OVER_TICKS 60u // Menu joystick repeat delay ($4101 with X = $82). #define ST_MENU_REPEAT_TICKS 8u typedef enum { ST_STATE_TITLE = 0, ST_STATE_HIGHSCORES, ST_STATE_ABOUT, ST_STATE_CABBIES, ST_STATE_SHIFT, ST_STATE_GET_READY, ST_STATE_INTRO, ST_STATE_PLAYING, ST_STATE_GAME_OVER } StStateE; typedef struct { StStateE state; bool demo; // the attract demo owns PLAYING uint8_t demoRotation; // $5143 uint8_t variation; // $7221: 1..5 uint8_t usedLevels[ST_LEVEL_COUNT]; // $5254 (random shift) uint8_t pendingLevel; // $716B as an index uint16_t waitTicks; uint8_t menuSel; // cabbies 0..3 / shift 0..4 uint8_t menuRepeat; bool fireArmed; // $445D: FIRE must be released first uint8_t lastFrame; uint16_t paceAcc; } StGameT; // Menu texts ($5347..$545D) in screen codes. static const uint8_t kTextGameVariations[] = "GAME VARIATIONS"; static const uint8_t kTextCabbies[] = "SELECT NUMBER OF CABBIES:"; static const uint8_t kTextDigits[] = "1 2 3 4"; static const uint8_t kTextLeftRight[] = "USE JOYSTICK: LEFT OR RIGHT,"; static const uint8_t kTextFire[] = "FIRE TO SELECT"; static const uint8_t kTextSelectShift[] = "SELECT SHIFT:"; static const uint8_t *kTextShifts[5] = { (const uint8_t *)"1. MORNING SHIFT - BEGINNER", (const uint8_t *)"2. DAY SHIFT - INTERMEDIATE", (const uint8_t *)"3. NIGHT SHIFT - EXPERT", (const uint8_t *)"4. STANDARD 24 HOUR SHIFT", (const uint8_t *)"5. RANDOM 24 HOUR SHIFT", }; static const uint8_t kTextUpDown[] = "USE JOYSTICK: UP OR DOWN,"; static const uint8_t kTextGetReady[] = " GET READY FOR A RIDE "; static const uint8_t kTextOnTheTaxi[] = " ON THE SPACE TAXI! "; static const uint8_t kTextGameOver[] = " GAME OVER! "; static const uint8_t kTextBlank12[] = " "; // $4C1F / $556A screens. static const uint8_t kTextImmortal[] = "THE IMMORTAL CABBIES"; static const uint8_t *kHighScores[8] = { (const uint8_t *)"3877.56 MICHAEL PLATE", (const uint8_t *)"1809.51 MICHAEL PLATE", (const uint8_t *)"1179.87 ANDREAS PLATE", (const uint8_t *)" 892.65 MICHAEL PLATE", (const uint8_t *)" 685.37 ANDREAS PLATE", (const uint8_t *)" 629.45 MICHAEL PLATE", (const uint8_t *)" 569.50 MICHAEL PLATE", (const uint8_t *)" 504.97 ANDREAS PLATE", }; static const uint8_t *kAboutLines[7] = { (const uint8_t *)"THIS PROGRAM DEVELOPED AND WRITTEN BY", (const uint8_t *)"JOHN F. KUTCHER", (const uint8_t *)"11/21/65", (const uint8_t *)"CURRENTLY, AS OF JANUARY 1984,", (const uint8_t *)"HE IS ATTENDING JOHNS HOPKINS", (const uint8_t *)"UNIVERSITY IN BALTIMORE, MD", (const uint8_t *)"ALSO TRY RESCUE SQUAD BY JOHN KUTCHER", }; static const uint8_t kTextReturn[] = "PRESS FIRE TO RETURN"; static StGameT gGame; static StSimT gSim; // One level buffer serves both gameplay and the title screen: the title // is another scene, and gameplay never needs it at the same time, so it // is loaded into gLevel on each return to the title. (Keeping a second // StLevelT cost ~3.7 KB of static state the IIgs bank-0 BSS can't spare.) static StLevelT gLevel; static void blankScreen(StSimT *sim); static void enterTitle(void); static void gameOverEnter(void); static bool joyFire(void); static uint8_t joyMask(void); static bool loadLevel(uint8_t index); static uint8_t nextLevelForShift(void); static uint8_t paceTicks(void); static void runCabbiesMenu(uint8_t ticks); static void runDemoEnd(void); static void runIntro(uint8_t ticks); static void runPlaying(uint8_t ticks); static void runShiftMenu(uint8_t ticks); static void runTitle(uint8_t ticks); static void shiftMenuColor(uint8_t row, uint8_t color); static void showCabbiesMenu(void); static void showShiftMenu(void); static void startDemoScreen(void); static void startIntro(void); static void startNewScreen(void); // $40CA -- spaces everywhere, black. static void blankScreen(StSimT *sim) { memset(sim->screen, ST_CHAR_SPACE, ST_SCREEN_CELLS); memset(sim->color, 0, ST_SCREEN_CELLS); stSimDirtyAll(sim); sim->bgColor = 0u; sim->borderColor = 0u; sim->frame.enableMask = 0u; stRenderSceneChanged(sim); } // $477A -- back to the title, intro from the top. static void enterTitle(void) { gSim.demoMode = 0u; if (!stLevelLoad(&gLevel, "levels/title.dat")) { ST_LOG("spacetaxi: ! title load FAILED"); } stTitleEnter(&gSim, &gLevel); stRenderSceneChanged(&gSim); stAudioNoise(false); stAudioSilence(); gGame.state = ST_STATE_TITLE; gGame.fireArmed = false; } // $5FBB -- "GAME OVER!" over the play field, sprites hidden. static void gameOverEnter(void) { stSimDrawText(&gSim, 14u, 10u, kTextBlank12, 1u); stSimDrawText(&gSim, 14u, 11u, kTextGameOver, 1u); stSimDrawText(&gSim, 14u, 12u, kTextBlank12, 1u); gSim.frame.enableMask = 0u; stAudioNoise(false); stAudioSilence(); gGame.waitTicks = ST_GAME_OVER_TICKS; gGame.state = ST_STATE_GAME_OVER; } static bool joyFire(void) { return jlKeyDown(KEY_SPACE) || jlJoyDown(JOYSTICK_0, JOY_BUTTON_0); } // The C64's EOR'd $DC00: bit0 UP, 1 DOWN, 2 LEFT, 3 RIGHT, 4 FIRE, bit 7 set. static uint8_t joyMask(void) { int8_t jx = jlJoystickX(JOYSTICK_0); int8_t jy = jlJoystickY(JOYSTICK_0); uint8_t mask = 0x80u; if (jlKeyDown(KEY_UP) || jy < -32) { mask |= 0x01u; } if (jlKeyDown(KEY_DOWN) || jy > 32) { mask |= 0x02u; } if (jlKeyDown(KEY_LEFT) || jx < -32) { mask |= 0x04u; } if (jlKeyDown(KEY_RIGHT) || jx > 32) { mask |= 0x08u; } if (joyFire()) { mask |= 0x10u; } return mask; } static bool loadLevel(uint8_t index) { char path[32]; if (index >= ST_LEVEL_COUNT) { return false; } // Hand-rolled "levels/levelNN.dat" (NN = index+1, 01..24) so the // binary does not drag in the whole formatted-output machinery. { uint8_t n = (uint8_t)(index + 1u); memcpy(path, "levels/level", 12u); path[12] = (char)('0' + n / 10u); path[13] = (char)('0' + n % 10u); memcpy(path + 14, ".dat", 5u); } if (!stLevelLoad(&gLevel, path)) { ST_LOG("spacetaxi: ! cannot load %s", path); return false; } return true; } // $5010 -- which screen comes next for the chosen shift. Returns 0xFF // when the shift is over. static uint8_t nextLevelForShift(void) { StSimT *sim = &gSim; if (gGame.variation <= 3u) { // $517D: eight screens per shift, A..H / I..P / Q..X. if (sim->levelState == 8u) { return 0xFFu; } return (uint8_t)((gGame.variation - 1u) * 8u + sim->levelState); } if (sim->levelState >= ST_LEVEL_COUNT) { return 0xFFu; } if (gGame.variation == 4u) { return sim->levelState; } // $521A: random order without repeats. { uint8_t x = (uint8_t)(stSimRng(sim, ST_LEVEL_COUNT) - 1u); while (gGame.usedLevels[x] != 0u) { x = (x == 0u) ? (uint8_t)(ST_LEVEL_COUNT - 1u) : (uint8_t)(x - 1u); } gGame.usedLevels[x] = 1u; return x; } } // Game ticks due this rendered frame, from the vertical blank count: // one tick per two frames on 50/60 Hz displays, three per seven on // the 70 Hz VGA mode. jlFrameCount is 8-bit-wrapped so a stall of any // length just clamps to the catch-up cap. static uint8_t paceTicks(void) { uint16_t hz = jlFrameHz(); uint8_t now = (uint8_t)jlFrameCount(); uint8_t frames = (uint8_t)(now - gGame.lastFrame); uint8_t ticks = 0u; gGame.lastFrame = now; if (frames > 16u) { frames = 16u; } if (hz == 50u || hz == 60u) { gGame.paceAcc += (uint16_t)(frames * 35u); hz = 70u; } else { gGame.paceAcc += (uint16_t)(frames * ST_TICK_HZ); } // Ticks not run this frame stay owed (a slow frame catches up over // the next ones); a stall longer than the cap's worth is forgiven. while (gGame.paceAcc >= hz && ticks < ST_MAX_CATCHUP_TICKS) { gGame.paceAcc = (uint16_t)(gGame.paceAcc - hz); ticks++; } if (gGame.paceAcc > (uint16_t)(hz * ST_MAX_CATCHUP_TICKS)) { gGame.paceAcc = (uint16_t)(hz * ST_MAX_CATCHUP_TICKS); } return ticks; } // $52F7 -- LEFT/RIGHT cycle 1..4, FIRE confirms. static void runCabbiesMenu(uint8_t ticks) { uint8_t mask = joyMask(); if (!gGame.fireArmed) { if ((mask & 0x10u) == 0u) { gGame.fireArmed = true; } return; } if ((mask & 0x10u) != 0u) { stSimPutColor(&gSim, (uint8_t)(28u + gGame.menuSel * 2u), 2u, 1u); gSim.playerCount = (uint8_t)(gGame.menuSel + 1u); showShiftMenu(); return; } if (gGame.menuRepeat > 0u) { gGame.menuRepeat = (uint8_t)(gGame.menuRepeat - ((ticks < gGame.menuRepeat) ? ticks : gGame.menuRepeat)); return; } if ((mask & 0x0Cu) != 0u) { stSimPutColor(&gSim, (uint8_t)(28u + gGame.menuSel * 2u), 2u, 6u); if ((mask & 0x08u) != 0u) { gGame.menuSel = (uint8_t)((gGame.menuSel + 1u) & 3u); } else { gGame.menuSel = (uint8_t)((gGame.menuSel - 1u) & 3u); } stSimPutColor(&gSim, (uint8_t)(28u + gGame.menuSel * 2u), 2u, 3u); gGame.menuRepeat = ST_MENU_REPEAT_TICKS; } } // The demo crashed, flew out or was interrupted: $5EC4 -> title. static void runDemoEnd(void) { gGame.demo = false; enterTitle(); } // $4666 loop: two frames per game tick; joystick ends a demo. static void runIntro(uint8_t ticks) { uint8_t k; if (gGame.demo && (joyMask() & 0x1Fu) != 0u) { runDemoEnd(); return; } for (k = 0u; k < (uint8_t)(ticks * 2u); k++) { if (stIntroStep(&gSim)) { stSimEnterLevel(&gSim, &gLevel); stRenderSceneChanged(&gSim); if (gGame.demo) { const StDemoEntryT *e = &kDemoRotation[(uint8_t)(gGame.demoRotation - 1u) & 3u]; stSimSetDemoStream(&gSim, e->stream, e->length); } gGame.state = ST_STATE_PLAYING; return; } } } // The main loop proper, one $5F40 iteration per tick. static void runPlaying(uint8_t ticks) { uint8_t k; gSim.rawInput = joyMask(); for (k = 0u; k < ticks; k++) { StTickResultE r = stSimTick(&gSim); stAudioTick(); if (r == ST_TICK_CONTINUE) { continue; } if (gGame.demo) { runDemoEnd(); return; } switch (r) { case ST_TICK_LEVEL_EXIT: startNewScreen(); return; case ST_TICK_LIFE_LOST: stSimRespawn(&gSim); stRenderSceneChanged(&gSim); break; case ST_TICK_PLAYER_OUT: gameOverEnter(); return; default: break; } } } // $54F8 -- UP/DOWN pick the shift, FIRE confirms and starts the game. static void runShiftMenu(uint8_t ticks) { uint8_t mask = joyMask(); if (!gGame.fireArmed) { if ((mask & 0x10u) == 0u) { gGame.fireArmed = true; } return; } if ((mask & 0x10u) != 0u) { shiftMenuColor(gGame.menuSel, 1u); gGame.variation = (uint8_t)(gGame.menuSel + 1u); // $5EC4 + $5333: the game starts; the first advance wraps to // player 0 and loads the first screen. stSimNewGame(&gSim, gSim.playerCount, false); gSim.player = (uint8_t)(gSim.playerCount - 1u); memset(gGame.usedLevels, 0, sizeof(gGame.usedLevels)); gGame.demo = false; startNewScreen(); return; } if (gGame.menuRepeat > 0u) { gGame.menuRepeat = (uint8_t)(gGame.menuRepeat - ((ticks < gGame.menuRepeat) ? ticks : gGame.menuRepeat)); return; } if ((mask & 0x03u) != 0u) { shiftMenuColor(gGame.menuSel, 6u); if ((mask & 0x02u) != 0u) { gGame.menuSel = (uint8_t)((gGame.menuSel + 1u) % 5u); } else { gGame.menuSel = (uint8_t)((gGame.menuSel + 4u) % 5u); } shiftMenuColor(gGame.menuSel, 3u); gGame.menuRepeat = ST_MENU_REPEAT_TICKS; } } // $47E4 -- the title loop: intro, then FIRE / UP / DOWN, else the demo. static void runTitle(uint8_t ticks) { uint8_t mask = joyMask(); uint8_t k; if (!gGame.fireArmed) { if ((mask & 0x10u) == 0u) { gGame.fireArmed = true; } } for (k = 0u; k < ticks; k++) { if (stTitleTick(&gSim)) { // $4818: intro over -> the attract demo (demo mode on). startDemoScreen(); return; } } if ((mask & 0x10u) != 0u && gGame.fireArmed) { showCabbiesMenu(); return; } if ((mask & 0x01u) != 0u) { uint8_t i; blankScreen(&gSim); stSimDrawText(&gSim, 10u, 1u, kTextImmortal, 1u); for (i = 0u; i < 8u; i++) { stSimDrawText(&gSim, 9u, (uint8_t)(4u + i * 2u), kHighScores[i], 1u); } stSimDrawText(&gSim, 10u, 22u, kTextReturn, 1u); gGame.state = ST_STATE_HIGHSCORES; gGame.fireArmed = false; return; } if ((mask & 0x02u) != 0u) { static const uint8_t kAboutCols[7] = { 1u, 12u, 15u, 4u, 5u, 6u, 1u }; static const uint8_t kAboutRows[7] = { 3u, 5u, 7u, 10u, 12u, 14u, 18u }; uint8_t i; blankScreen(&gSim); for (i = 0u; i < 7u; i++) { stSimDrawText(&gSim, kAboutCols[i], kAboutRows[i], kAboutLines[i], 1u); } stSimDrawText(&gSim, 10u, 22u, kTextReturn, 1u); gGame.state = ST_STATE_ABOUT; gGame.fireArmed = false; } } // $554F -- colour one shift line (row 10 + 2*sel, 35 cells from col 2). static void shiftMenuColor(uint8_t sel, uint8_t color) { uint8_t col; for (col = 2u; col < 37u; col++) { stSimPutColor(&gSim, col, (uint8_t)(10u + sel * 2u), color); } } // $5295 -- GAME VARIATIONS page 1. static void showCabbiesMenu(void) { blankScreen(&gSim); stSimDrawText(&gSim, 13u, 0u, kTextGameVariations, 5u); stSimDrawText(&gSim, 1u, 2u, kTextCabbies, 7u); stSimDrawText(&gSim, 5u, 4u, kTextLeftRight, 11u); stSimDrawText(&gSim, 19u, 5u, kTextFire, 11u); stSimDrawText(&gSim, 28u, 2u, kTextDigits, 6u); gGame.menuSel = 0u; stSimPutColor(&gSim, 28u, 2u, 3u); gGame.state = ST_STATE_CABBIES; gGame.fireArmed = false; gGame.menuRepeat = 0u; } // $545E -- GAME VARIATIONS page 2. static void showShiftMenu(void) { uint8_t i; blankScreen(&gSim); stSimDrawText(&gSim, 1u, 8u, kTextSelectShift, 7u); for (i = 0u; i < 5u; i++) { stSimDrawText(&gSim, 2u, (uint8_t)(10u + i * 2u), kTextShifts[i], 6u); } stSimDrawText(&gSim, 5u, 21u, kTextUpDown, 11u); stSimDrawText(&gSim, 19u, 22u, kTextFire, 11u); gGame.menuSel = 0u; shiftMenuColor(0u, 3u); gGame.state = ST_STATE_SHIFT; gGame.fireArmed = false; gGame.menuRepeat = 0u; } // $48AD + $61FB + $50C6 -- the next demo screen: "GET READY" over the // title, then the level intro. static void startDemoScreen(void) { const StDemoEntryT *e = &kDemoRotation[gGame.demoRotation & 3u]; gGame.demoRotation++; gGame.demo = true; stSimNewGame(&gSim, 1u, true); gSim.player = 0u; (void)stSimAdvancePlayer(&gSim); gSim.levelState++; if (!loadLevel(e->levelIndex)) { enterTitle(); return; } stSimDrawText(&gSim, 10u, 8u, kTextGetReady, 5u); stSimDrawText(&gSim, 10u, 10u, kTextOnTheTaxi, 5u); gSim.frame.enableMask &= 0x07u; gGame.waitTicks = ST_GET_READY_TICKS; gGame.state = ST_STATE_GET_READY; } // $62F0 -> $4523 -- the level-name screen for gLevel. static void startIntro(void) { stIntroEnter(&gSim, &gLevel); stRenderSceneChanged(&gSim); gGame.state = ST_STATE_INTRO; } // $61FB -- the next player's turn on this screen, or the next screen // once everybody has had it; players out of cabs are skipped. static void startNewScreen(void) { uint8_t guard = 0u; for (;;) { if (stSimAdvancePlayer(&gSim)) { uint8_t next = nextLevelForShift(); if (next == 0xFFu) { // $602F: the shift is over. enterTitle(); return; } gGame.pendingLevel = next; if (!loadLevel(next)) { enterTitle(); return; } gSim.levelState++; } if (gSim.cabs[gSim.player] != 0u) { break; } guard++; if (guard > (uint8_t)(ST_MAX_PLAYERS * 2u)) { enterTitle(); return; } } startIntro(); } int main(void) { jlConfigT config; jlSurfaceT *stage; #if defined(JOEYLIB_PLATFORM_IIGS) // One 64 KB bank: enough for the cab, exhaust and passenger cels. config.codegenBytes = 60UL * 1024; #else config.codegenBytes = 160UL * 1024; #endif config.audioBytes = 32UL * 1024; if (!jlInit(&config)) { return 1; } ST_LOG_RESET(); ST_LOG("spacetaxi: build=%s %s", __DATE__, __TIME__); stage = jlStageGet(); if (stage == NULL) { jlShutdown(); return 1; } memset(&gGame, 0, sizeof(gGame)); memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); stAudioInit(); // Boot: the playback buffer as the C64 leaves it, and the state a // fresh machine has when the first title intro starts ($4092 + the // title's own sparkle/walk history from the dump). stSimNewGame(&gSim, 1u, false); stSimSeedDemoBuffer(&gSim, kDemoBufferInit, ST_DEMO_BUFFER_BYTES); gSim.waveIdx = 3u; stRenderPrewarm(stage, &gSim); enterTitle(); gGame.lastFrame = (uint8_t)jlFrameCount(); for (;;) { uint8_t ticks; jlInputPoll(); if (jlKeyPressed(KEY_ESCAPE)) { break; } ticks = paceTicks(); switch (gGame.state) { case ST_STATE_TITLE: runTitle(ticks); break; case ST_STATE_HIGHSCORES: case ST_STATE_ABOUT: if (!gGame.fireArmed) { gGame.fireArmed = !joyFire(); } else if (joyFire()) { enterTitle(); } break; case ST_STATE_CABBIES: runCabbiesMenu(ticks); break; case ST_STATE_SHIFT: runShiftMenu(ticks); break; case ST_STATE_GET_READY: if (gGame.demo && (joyMask() & 0x1Fu) != 0u) { runDemoEnd(); break; } if (gGame.waitTicks > ticks) { gGame.waitTicks = (uint16_t)(gGame.waitTicks - ticks); } else { gGame.waitTicks = 0u; startIntro(); } break; case ST_STATE_INTRO: runIntro(ticks); break; case ST_STATE_PLAYING: runPlaying(ticks); break; case ST_STATE_GAME_OVER: if (gGame.waitTicks > ticks) { gGame.waitTicks = (uint16_t)(gGame.waitTicks - ticks); } else { // $6BD0: one more player done; everybody done -> title. gSim.playersDone++; if (gSim.playersDone == gSim.playerCount) { enterTitle(); } else { startNewScreen(); } } break; default: enterTitle(); break; } stRenderFrame(stage, &gSim); jlAudioFrameTick(); } stAudioShutdown(); stRenderShutdown(); jlShutdown(); return 0; }