// 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 #include "spacetaxi.h" #include "stSongData.h" #include "stDemoStreams.h" #define ST_TICK_HZ 30u #define ST_MAX_CATCHUP_TICKS 4u // Screens A..X, then screen Y -- the 25th, " MYSTERY SCREEN !!! " -- which // $5167 loads once the 24-hour shifts have cleared all of A..X. Files are // levels/level01.dat .. level25.dat, so Y is index 24. #define ST_LEVEL_COUNT 24u // A..X, the screens a shift sweeps #define ST_SCREEN_Y 24u // the 25th screen ($5171 LDA #$59) #define ST_LEVEL_FILES 25u // level01.dat .. level25.dat // "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 // The immortal cabbies ($4A89 image, $4E35 screen). #define ST_HS_ENTRIES 10u #define ST_HS_NAME_CHARS 15u #define ST_HS_ENTRY_BYTES 24u // score[7] 0 name[15] shift #define ST_HS_POINT_AT 4u // "dddd.dd" #define ST_HS_GREET_DIGIT_AT 23u // "CONGRATULATIONS CABBIE n," #define ST_HS_NAME_COL 12u // $0664: row 15, column 12 #define ST_HS_NAME_ROW 15u #define ST_HS_CURSOR '%' // $4F4D flips it against blank #define ST_HS_BLINK_TICKS 3u // $4101 x $1E between flips, ~55 ms #define ST_HS_SAVE_NAME "IMMORTAL" 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, ST_STATE_JINGLE, // a $44CF tune sounding; sim frozen until it ends ST_STATE_NAME_ENTRY // $4E35: a top-ten cabbie types a name } 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; StStateE afterJingle; // where ST_STATE_JINGLE returns to bool gameOverTune; // $5C24: song 5 started after the wait uint8_t hsSlot; // $4D51 table slot being named uint8_t hsLen; // $4D2C characters typed uint8_t hsCursor; // $4F4D the blinking cursor's current glyph uint8_t hsBlinkTicks; // ticks toward the next cursor flip bool paused; // $4FCB: SHIFT is holding the game } StGameT; // The immortal cabbies ($4A89): ten entries of a right-aligned "dddd.dd" // score, a name of up to fifteen letters and the shift it was earned on. // The C64 keeps this table on its own disk (the $9948 IEC routines load // it at boot and save it after every entry); the port keeps the same // 240-byte image in a save file. Shipped contents as the dump has them. typedef struct { uint8_t score[ST_NUMBER_CHARS + 1u]; // ASCII, NUL-terminated uint8_t name[ST_HS_NAME_CHARS + 1u]; // ASCII, space-padded, NUL-terminated uint8_t shift; // 1..5 } StHighScoreT; static StHighScoreT gHighScores[ST_HS_ENTRIES] = { { "3877.56", "MICHAEL PLATE ", 4u }, { "1809.51", "MICHAEL PLATE ", 2u }, { "1179.87", "ANDREAS PLATE ", 4u }, { " 892.65", "MICHAEL PLATE ", 1u }, { " 685.37", "ANDREAS PLATE ", 1u }, { " 629.45", "MICHAEL PLATE ", 4u }, { " 569.50", "MICHAEL PLATE ", 1u }, { " 504.97", "ANDREAS PLATE ", 2u }, { " 498.77", "ANDREAS PLATE ", 5u }, { " 490.85", "ANDREAS PLATE ", 2u }, }; // 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 (the table) and $4E35 (the name entry) screens. static const uint8_t kTextImmortal[] = "THE IMMORTAL CABBIES"; static const uint8_t kTextShift[] = "SHIFT"; static const uint8_t kTextReturnTitle[] = "PRESS FIRE TO RETURN TO TITLE PAGE"; static const uint8_t kTextCongrats[] = "CONGRATULATIONS CABBIE ,"; static const uint8_t kTextRanks[] = "YOUR SCORE RANKS YOU AS ONE OF"; static const uint8_t kTextTopTen[] = "THE TOP TEN CABBIES OF ALL TIME"; static const uint8_t kTextEnterName[] = "ENTER YOUR NAME BELOW"; 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; static jlSurfaceT *gStage; // 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, uint8_t border, uint8_t bg); static void enterTitle(void); static void gameOverEnter(void); static void gameOverFinish(void); static void highScoreDraw(void); static uint8_t highScoreInsert(const uint8_t *score); static void highScoreLoad(void); static void highScoreSave(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 bool serviceJingle(void); static void runCabbiesMenu(uint8_t ticks); static void runDemoEnd(void); static void runIntro(uint8_t ticks); static void runNameEntry(uint8_t ticks); static bool nameEntryStart(void); 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, uint8_t border, uint8_t bg) { memset(sim->screen, ST_CHAR_SPACE, ST_SCREEN_CELLS); memset(sim->color, 0, ST_SCREEN_CELLS); stSimDirtyAll(sim); sim->bgColor = bg; sim->borderColor = border; 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"); } stRenderLevelCels(gStage, 0); // the last level's cels go stTitleEnter(&gSim, &gLevel); stRenderSceneChanged(&gSim); stRenderTitleLogo(&gSim); stAudioNoise(false); stAudioSilence(); jlMusicStop(); gGame.state = ST_STATE_TITLE; gGame.fireArmed = false; } // $6BD0 -- one more player done; everybody done -> title. static void gameOverFinish(void) { gSim.playersDone++; if (gSim.playersDone == gSim.playerCount) { enterTitle(); } else { startNewScreen(); } } // $4C1F -- the immortal cabbies: ten rows of shift, "$", score and name, // each entry drawn only while its last score digit is a digit (a blank // row is an unused slot). Song 7 plays under it until FIRE ($4D04). static void highScoreDraw(void) { uint8_t i; blankScreen(&gSim, 3u, 14u); stSimDrawText(&gSim, 9u, 1u, kTextImmortal, 6u); stSimDrawText(&gSim, 32u, 3u, kTextShift, 15u); for (i = 0u; i < ST_HS_ENTRIES; i++) { const StHighScoreT *e = &gHighScores[i]; uint8_t row = (uint8_t)(4u + i * 2u); if (e->score[ST_NUMBER_CHARS - 1u] < '0') { continue; } stSimPutChar(&gSim, 34u, row, (uint8_t)('0' + e->shift)); stSimPutColor(&gSim, 34u, row, 15u); stSimPutChar(&gSim, 4u, row, '$'); stSimPutColor(&gSim, 4u, row, 1u); stSimDrawText(&gSim, 5u, row, e->score, 1u); stSimDrawText(&gSim, 15u, row, e->name, 1u); } stSimDrawText(&gSim, 3u, 24u, kTextReturnTitle, 0u); stAudioMusic(ST_SONG_SCORES, false); // $4C29 } // $4D92 -- where a score belongs in the table, or ST_HS_ENTRIES when it // does not make it. The table is only opened to scores of $50.00 and up // (second character a digit, or third character 5 or more). The scores // are right-aligned text, so a character compare orders them; a tie goes // above the older entry. The new entry is opened with a blank name. static uint8_t highScoreInsert(const uint8_t *score) { uint8_t slot; uint8_t k; if (score[1] < '0' && score[2] < '5') { return ST_HS_ENTRIES; } for (slot = 0u; slot < ST_HS_ENTRIES; slot++) { const uint8_t *old = gHighScores[slot].score; for (k = 0u; k < ST_NUMBER_CHARS; k++) { if (score[k] != old[k]) { break; } } if (k == ST_NUMBER_CHARS || score[k] > old[k]) { break; } } if (slot == ST_HS_ENTRIES) { return ST_HS_ENTRIES; } for (k = (uint8_t)(ST_HS_ENTRIES - 1u); k > slot; k--) { gHighScores[k] = gHighScores[k - 1u]; } memcpy(gHighScores[slot].score, score, ST_NUMBER_CHARS + 1u); memset(gHighScores[slot].name, ' ', ST_HS_NAME_CHARS); gHighScores[slot].name[ST_HS_NAME_CHARS] = 0u; gHighScores[slot].shift = 0u; return slot; } // $23E5 -> $9948 at boot: the table from disk, when there is one. The // image is the C64's own: per entry score[7], 0, name[15], shift. static void highScoreLoad(void) { uint8_t image[ST_HS_ENTRIES * ST_HS_ENTRY_BYTES]; uint8_t i; if (jlSaveRead(ST_HS_SAVE_NAME, image, sizeof(image)) != sizeof(image)) { return; } for (i = 0u; i < ST_HS_ENTRIES; i++) { const uint8_t *e = &image[i * ST_HS_ENTRY_BYTES]; if (e[ST_NUMBER_CHARS] != 0u) { ST_LOG("spacetaxi: ! %s is not a score table", ST_HS_SAVE_NAME); return; } } for (i = 0u; i < ST_HS_ENTRIES; i++) { const uint8_t *e = &image[i * ST_HS_ENTRY_BYTES]; memcpy(gHighScores[i].score, e, ST_NUMBER_CHARS); gHighScores[i].score[ST_NUMBER_CHARS] = 0u; memcpy(gHighScores[i].name, &e[ST_NUMBER_CHARS + 1u], ST_HS_NAME_CHARS); gHighScores[i].name[ST_HS_NAME_CHARS] = 0u; gHighScores[i].shift = e[ST_HS_ENTRY_BYTES - 1u]; } } // $4E11 -> $9948 after an entry: the table back to disk. static void highScoreSave(void) { uint8_t image[ST_HS_ENTRIES * ST_HS_ENTRY_BYTES]; uint8_t i; for (i = 0u; i < ST_HS_ENTRIES; i++) { uint8_t *e = &image[i * ST_HS_ENTRY_BYTES]; memcpy(e, gHighScores[i].score, ST_NUMBER_CHARS); e[ST_NUMBER_CHARS] = 0u; memcpy(&e[ST_NUMBER_CHARS + 1u], gHighScores[i].name, ST_HS_NAME_CHARS); e[ST_HS_ENTRY_BYTES - 1u] = gHighScores[i].shift; } if (!jlSaveWrite(ST_HS_SAVE_NAME, image, sizeof(image))) { ST_LOG("spacetaxi: ! could not save %s", ST_HS_SAVE_NAME); } } // $4D52 -- after a player's GAME OVER: their score as "dddd.dd" text, and // if it ranks, the $4E35 screen: red border, grey field, the cabbie's // number in the greeting, the score at the top, song 6, and a blinking // cursor where the name goes. Returns false when the score did not rank // and the game moves straight on. static bool nameEntryStart(void) { uint8_t score[ST_NUMBER_CHARS + 1u]; uint8_t greet[sizeof(kTextCongrats)]; uint8_t k; for (k = 0u; k < ST_NUMBER_CHARS; k++) { uint8_t ch = gSim.scoreBackup[gSim.player][k]; if (ch == ST_CHAR_BLANK) { score[k] = ' '; } else { uint8_t d = (uint8_t)(ch - ST_CHAR_DIGIT_BASE); // $4345 score[k] = (uint8_t)('0' + ((d == 10u) ? 0u : d)); } } score[ST_HS_POINT_AT] = '.'; score[ST_NUMBER_CHARS] = 0u; gGame.hsSlot = highScoreInsert(score); if (gGame.hsSlot == ST_HS_ENTRIES) { return false; } blankScreen(&gSim, 2u, 15u); while (jlInputGetChar() >= 0) { // $0277 = 0: nothing typed earlier counts } memcpy(greet, kTextCongrats, sizeof(greet)); greet[ST_HS_GREET_DIGIT_AT] = (uint8_t)('1' + gSim.player); stSimDrawText(&gSim, 7u, 3u, greet, 0u); stSimDrawText(&gSim, 5u, 5u, kTextRanks, 0u); stSimDrawText(&gSim, 4u, 7u, kTextTopTen, 0u); stSimDrawText(&gSim, 9u, 9u, kTextEnterName, 0u); stSimDrawText(&gSim, 16u, 0u, score, 6u); stSimPutChar(&gSim, 15u, 0u, '$'); stSimPutColor(&gSim, 15u, 0u, 6u); stAudioMusic(ST_SONG_NAME_ENTRY, false); // $4EB6 gGame.hsLen = 0u; gGame.hsCursor = ST_HS_CURSOR; gGame.hsBlinkTicks = 0u; gGame.state = ST_STATE_NAME_ENTRY; return true; } // $4ED8 -- the entry loop: the cursor flips between "%" and blank, then // the keyboard: letters, space, ".", "!" and "," go in (fifteen at most), // DEL takes one back, RETURN keeps the name (a "*" when none was typed), // stamps the shift, stops the tune and saves the table. static void runNameEntry(uint8_t ticks) { StHighScoreT *e = &gHighScores[gGame.hsSlot]; uint8_t col = (uint8_t)(ST_HS_NAME_COL + gGame.hsLen); int c; gGame.hsBlinkTicks = (uint8_t)(gGame.hsBlinkTicks + ticks); if (gGame.hsBlinkTicks >= ST_HS_BLINK_TICKS) { gGame.hsBlinkTicks = 0u; gGame.hsCursor ^= ST_HS_CURSOR ^ ' '; stSimPutChar(&gSim, col, ST_HS_NAME_ROW, gGame.hsCursor); stSimPutColor(&gSim, col, ST_HS_NAME_ROW, 6u); } while ((c = jlInputGetChar()) >= 0) { if (c >= 'a' && c <= 'z') { c = c - 'a' + 'A'; } if (c == JL_CHAR_RETURN) { stSimPutChar(&gSim, col, ST_HS_NAME_ROW, ' '); if (gGame.hsLen == 0u) { e->name[0] = '*'; } e->shift = gGame.variation; jlMusicStop(); // $44E1 highScoreSave(); // $4E11 gameOverFinish(); return; } if (c == JL_CHAR_BACKSPACE || c == JL_CHAR_DELETE) { if (gGame.hsLen != 0u) { stSimPutChar(&gSim, col, ST_HS_NAME_ROW, ' '); gGame.hsLen--; col--; e->name[gGame.hsLen] = ' '; } continue; } if (c != ' ' && c != '.' && c != '!' && c != ',' && (c < 'A' || c > 'Z')) { continue; } if (gGame.hsLen == ST_HS_NAME_CHARS) { continue; } e->name[gGame.hsLen] = (uint8_t)c; stSimPutChar(&gSim, col, ST_HS_NAME_ROW, (uint8_t)c); stSimPutColor(&gSim, col, ST_HS_NAME_ROW, 6u); gGame.hsLen++; col++; } } // $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.gameOverTune = false; 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_FILES) { 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; } stRenderLevelCels(gStage, &gLevel); 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); } // $5022/$5167: the 24-hour shifts play screen Y after A..X, and only // then is the shift over -- which is also what lights the "finished" // marker in the screens-completed field (levelState reaches 25). if (sim->levelState == ST_LEVEL_COUNT) { return ST_SCREEN_Y; } 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)) { jlMusicStop(); // $4709 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; serviceJingle(); 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) { if (serviceJingle()) { return; // $70D4 bonus cab: the tune blocks } continue; } if (gGame.demo) { runDemoEnd(); return; } switch (r) { case ST_TICK_LEVEL_EXIT: startNewScreen(); return; case ST_TICK_LIFE_LOST: stSimRespawn(&gSim); stRenderSceneChanged(&gSim); if (serviceJingle()) { return; // $6906 on the new cab } break; case ST_TICK_PLAYER_OUT: gameOverEnter(); return; default: break; } } } // Start a tune the sim asked for and park the loop in ST_STATE_JINGLE // until it ends. Returns true when a tune was started, so callers can // stop ticking the sim for this frame -- the C64's $44CF blocks there. static bool serviceJingle(void) { uint8_t song; if (gSim.jingleRequest == 0u) { return false; } song = (uint8_t)(gSim.jingleRequest - 1u); gSim.jingleRequest = 0u; stAudioJingle(song); if (!stAudioJingleActive()) { return false; // no audio: nothing to wait for } gGame.afterJingle = gGame.state; gGame.state = ST_STATE_JINGLE; return true; } // $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) { highScoreDraw(); 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, 12u, 15u); // $558E: grey border, light-grey field 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, 0u, 0u); 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, 0u, 0u); 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); // After the scene change, which turns it back off. stRenderIntroStars(); // $459C: the intro's tune plays under the fly-in, not blocking; the // C64 leaves it to the IRQ player and stops it at $4709. stAudioMusic(ST_SONG_INTRO, true); 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; // The codegen arena holds every compiled cel, the baked ones too: the // 37 cab/exhaust/passenger cels plus a level's hook cels (level I has // 30). On the IIgs that is ~230 KB of 65816 code in 64 KB banks, and // the arena grants as many banks as the machine has (a 1 MB machine // gets one; whatever does not fit draws interpreted). The 68k ports' // cels are far larger (9-13 KB each) and their JIT is fast, so they // keep a modest arena and compile at level entry. #if defined(JOEYLIB_PLATFORM_IIGS) || defined(JOEYLIB_PLATFORM_DOS) config.codegenBytes = 256UL * 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; } gStage = stage; memset(&gGame, 0, sizeof(gGame)); memset(&gSim, 0, sizeof(gSim)); stRenderInit(stage); stRenderLoading(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); highScoreLoad(); // $23E5 enterTitle(); gGame.lastFrame = (uint8_t)jlFrameCount(); for (;;) { uint8_t ticks; jlInputPoll(); // $4FCB runStopWatcher, on the game tick, the intro and the menus: // SHIFT held freezes everything with the sound gated off and any // tune stopped (the intro's restarts on release); RUN/STOP -- ESC // here -- abandons the game for the title, and on the title it // leaves the program (the C64 had nowhere else to go). if (gGame.state != ST_STATE_JINGLE && gGame.state != ST_STATE_GAME_OVER && gGame.state != ST_STATE_NAME_ENTRY) { if (jlKeyDown(KEY_LSHIFT) || jlKeyDown(KEY_RSHIFT)) { if (!gGame.paused) { gGame.paused = true; stAudioSilence(); stAudioNoise(false); jlMusicStop(); } stRenderFrame(stage, &gSim); jlAudioFrameTick(); continue; } if (gGame.paused) { gGame.paused = false; gGame.lastFrame = (uint8_t)jlFrameCount(); // the held time is not owed gGame.paceAcc = 0u; if (gGame.state == ST_STATE_INTRO) { stAudioMusic(ST_SONG_INTRO, true); // $46B5 } } if (jlKeyPressed(KEY_ESCAPE)) { if (gGame.state == ST_STATE_TITLE) { break; } jlMusicStop(); // $5000 gGame.demo = false; enterTitle(); } } 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_JINGLE: // The C64 spins in $44D5 until the tune ends. A joystick // press ends the attract demo here too ($4FE2 stops the // music on input). if (gGame.demo && (joyMask() & 0x1Fu) != 0u) { jlMusicStop(); runDemoEnd(); } else if (!stAudioJingleActive()) { gGame.state = gGame.afterJingle; } break; case ST_STATE_GAME_OVER: if (gGame.waitTicks > ticks) { gGame.waitTicks = (uint16_t)(gGame.waitTicks - ticks); } else if (!gGame.gameOverTune) { // $5C24: after the GAME OVER! wait, song 5 plays out. gGame.gameOverTune = true; stAudioJingle(ST_SONG_GAME_OVER); } else if (stAudioJingleActive()) { // still sounding } else if (!nameEntryStart()) { gameOverFinish(); } break; case ST_STATE_NAME_ENTRY: runNameEntry(ticks); break; default: enterTitle(); break; } stRenderFrame(stage, &gSim); jlAudioFrameTick(); } stAudioShutdown(); stRenderShutdown(); jlShutdown(); return 0; }