joeylib2/examples/spacetaxi/spacetaxi.c

1060 lines
36 KiB
C

// 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 <string.h>
#include <joey/file.h>
#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;
}