diff --git a/changes/game-stats.md b/changes/game-stats.md new file mode 100644 index 00000000..7378c2ba --- /dev/null +++ b/changes/game-stats.md @@ -0,0 +1 @@ +Players can now view summary statistics of games played by selecting the "Game Stats" option from the "View" menu. Statistics are collected only for games played since the menu option became available. \ No newline at end of file diff --git a/src/brogue/MainMenu.c b/src/brogue/MainMenu.c index ca58a544..71856d6d 100644 --- a/src/brogue/MainMenu.c +++ b/src/brogue/MainMenu.c @@ -373,9 +373,10 @@ static void initializeFlyoutMenu(buttonState *menu, screenDisplayBuffer *shadowB } else if (rogue.nextGame == NG_FLYOUT_VIEW) { - buttonCount = 2; + buttonCount = 3; initializeMainMenuButton(&(buttons[0]), " View %sR%secording ", 'r', 'R', NG_VIEW_RECORDING); initializeMainMenuButton(&(buttons[1]), " %sH%sigh Scores ", 'h', 'H', NG_HIGH_SCORES); + initializeMainMenuButton(&(buttons[2]), " %sG%same Stats ", 'g', 'G', NG_GAME_STATS); } else { return; @@ -858,6 +859,217 @@ boolean dialogChooseFile(char *path, const char *suffix, const char *prompt) { } } +typedef struct gameStats { + int games; + int escaped; + int mastered; + int won; + float winRate; + int deepestLevel; + int cumulativeLevels; + int highestScore; + unsigned long cumulativeScore; + int mostGold; + unsigned long cumulativeGold; + int mostLumenstones; + int cumulativeLumenstones; + int fewestTurnsWin; // zero means never won + unsigned long cumulativeTurns; + int longestWinStreak; + int longestMasteryStreak; + int currentWinStreak; + int currentMasteryStreak; +} gameStats; + +/// @brief Updates the given stats to include a run +/// @param run The run to add +/// @param stats The stats to update +static void addRuntoGameStats(rogueRun *run, gameStats *stats) { + + stats->games++; + stats->cumulativeScore += run->score; + stats->cumulativeGold += run->gold; + stats->cumulativeLumenstones += run->lumenstones; + stats->cumulativeLevels += run->deepestLevel; + stats->cumulativeTurns += run->turns; + + stats->highestScore = (run->score > stats->highestScore) ? run->score : stats->highestScore; + stats->mostGold = (run->gold > stats->mostGold) ? run->gold : stats->mostGold; + stats->mostLumenstones = (run->lumenstones > stats->mostLumenstones) ? run->lumenstones : stats->mostLumenstones; + stats->deepestLevel = (run->deepestLevel > stats->deepestLevel) ? run->deepestLevel : stats->deepestLevel; + + if (strcmp(run->result, "Escaped") == 0 || strcmp(run->result, "Mastered") == 0) { + if (stats->fewestTurnsWin == 0 || run->turns < stats->fewestTurnsWin) { + stats->fewestTurnsWin = run->turns; + } + stats->won++; + stats->currentWinStreak++; + if (strcmp(run->result, "Mastered") == 0) { + stats->currentMasteryStreak++; + stats->mastered++; + } else { + stats->currentMasteryStreak = 0; + stats->escaped++; + } + } else { + stats->currentWinStreak = stats->currentMasteryStreak = 0; + } + + if (stats->currentWinStreak > stats->longestWinStreak) { + stats->longestWinStreak = stats->currentWinStreak; + } + if (stats->currentMasteryStreak > stats->longestMasteryStreak) { + stats->longestMasteryStreak = stats->currentMasteryStreak; + } + + if (stats->games == 0) { + stats->winRate = 0.0; + } else { + stats->winRate = ((float)stats->won / stats->games) * 100.0; + } +} + +/// @brief Display the game stats screen +/// Includes "All Time" stats and "Recent" stats. The player can reset their recent stats at any time. +static void viewGameStats(void) { + + gameStats allTimeStats = {0}; + gameStats recentStats = {0}; + + rogueRun *runHistory = loadRunHistory(); + rogueRun *run = runHistory; + + // calculate stats + while (run != NULL) { + if (run->seed != 0) { + addRuntoGameStats(run, &allTimeStats); + addRuntoGameStats(run, &recentStats); + } else { // when seed == 0 the run entry means the player reset their recent stats at this point + memset(&recentStats, 0, sizeof(gameStats)); + } + run = run->nextRun; + } + + // free run history + run = runHistory; + rogueRun *next; + while (run != NULL) { + next = run->nextRun; + free(run); + run = next; + } + + const SavedDisplayBuffer rbuf = saveDisplayBuffer(); + blackOutScreen(); + + screenDisplayBuffer dbuf; + clearDisplayBuffer(&dbuf); + + char buf[COLS*3]; + int i = 4; + int offset = 21; + char whiteColorEscape[5] = "", yellowColorEscape[5] = ""; + encodeMessageColor(whiteColorEscape, 0, &white); + encodeMessageColor(yellowColorEscape, 0, &itemMessageColor); + + color titleColor = black; + applyColorAverage(&titleColor, &itemMessageColor, 100); + printString("-- GAME STATS --", (COLS - 17 + 1) / 2, 0, &titleColor, &black, &dbuf); + + sprintf(buf,"%-30s%16s%16s","", "All Time", "Recent"); + printString(buf, offset, i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Games Played", whiteColorEscape, allTimeStats.games, recentStats.games); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + i++; + sprintf(buf,"%-30s%s%16i%16i","Won", whiteColorEscape, allTimeStats.won, recentStats.won); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16.1f%16.1f","Win Rate (%)", whiteColorEscape, allTimeStats.winRate, recentStats.winRate); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Escaped", whiteColorEscape, allTimeStats.escaped, recentStats.escaped); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Mastered", whiteColorEscape, allTimeStats.mastered, recentStats.mastered); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + i++; + sprintf(buf,"%-30s%s%16i%16i","High Score", whiteColorEscape, allTimeStats.highestScore, recentStats.highestScore); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Most Gold", whiteColorEscape, allTimeStats.mostGold, recentStats.mostGold); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Most Lumenstones", whiteColorEscape, allTimeStats.mostLumenstones, recentStats.mostLumenstones); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + i++; + sprintf(buf,"%-30s%s%16i%16i","Deepest Level", whiteColorEscape, allTimeStats.deepestLevel, recentStats.deepestLevel); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + char allTimeFewestTurns[20] = "-"; + char recentFewestTurns[20] = "-"; + if (allTimeStats.fewestTurnsWin > 0) { + sprintf(allTimeFewestTurns, "%i", allTimeStats.fewestTurnsWin); + } + if (recentStats.fewestTurnsWin > 0) { + sprintf(recentFewestTurns, "%i", recentStats.fewestTurnsWin); + } + sprintf(buf,"%-30s%s%16s%16s","Shortest Win (Turns)", whiteColorEscape, allTimeFewestTurns, recentFewestTurns); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + i++; + sprintf(buf,"%-30s%s%16i%16i","Longest Win Streak", whiteColorEscape, allTimeStats.longestWinStreak, recentStats.longestWinStreak); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%16i%16i","Longest Mastery Streak", whiteColorEscape, allTimeStats.longestMasteryStreak, recentStats.longestMasteryStreak); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + i++; + sprintf(buf,"%-30s%s%32i","Current Win Streak", whiteColorEscape, recentStats.currentWinStreak); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + sprintf(buf,"%-30s%s%32i","Current Mastery Streak", whiteColorEscape, recentStats.currentMasteryStreak); + printString(buf, offset, ++i, &itemMessageColor, &black, &dbuf); + + // Set the dbuf opacity. + for (int i=0; i 0) { + brogueButton buttons[1]; + initializeButton(&(buttons[0])); + if (KEYBOARD_LABELS) { + sprintf(buttons[0].text, " %sR%seset ", yellowColorEscape, whiteColorEscape); + } else { + strcpy(buttons[0].text, " Reset "); + } + buttons[0].hotkey[0] = 'R'; + buttons[0].hotkey[1] = 'r'; + buttons[0].x = 74; + buttons[0].y = 25; + + if (buttonInputLoop(buttons, 1, 74, 25, 10, 3, NULL) == 0 && confirm("Reset recent stats?",false)) { + saveResetRun(); + } + } else { + waitForKeystrokeOrMouseClick(); + } + + restoreDisplayBuffer(&rbuf); +} + // This is the basic program loop. // When the program launches, or when a game ends, you end up here. // If the player has already said what he wants to do next @@ -1041,6 +1253,10 @@ void mainBrogueJunction() { rogue.nextGame = NG_NOTHING; printHighScores(false); break; + case NG_GAME_STATS: + rogue.nextGame = NG_NOTHING; + viewGameStats(); + break; case NG_QUIT: // No need to do anything. break; diff --git a/src/brogue/Rogue.h b/src/brogue/Rogue.h index 3fa64b0c..0be0d08d 100644 --- a/src/brogue/Rogue.h +++ b/src/brogue/Rogue.h @@ -379,6 +379,19 @@ typedef struct rogueHighScoresEntry { char description[DCOLS]; } rogueHighScoresEntry; +typedef struct rogueRun { + uint64_t seed; + long dateNumber; + char result[DCOLS]; + char killedBy[DCOLS]; + int gold; + int lumenstones; + int score; + int turns; + int deepestLevel; + struct rogueRun *nextRun; +} rogueRun; + typedef struct fileEntry { char *path; struct tm date; @@ -2319,6 +2332,7 @@ enum NGCommands { NG_OPEN_GAME, NG_VIEW_RECORDING, NG_HIGH_SCORES, + NG_GAME_STATS, NG_QUIT, }; @@ -2929,6 +2943,9 @@ extern "C" { boolean shiftKeyIsDown(void); short getHighScoresList(rogueHighScoresEntry returnList[HIGH_SCORES_COUNT]); boolean saveHighScore(rogueHighScoresEntry theEntry); + void saveRunHistory(char *result, char *killedBy, int score, int lumenstones); + void saveResetRun(void); + rogueRun *loadRunHistory(void); fileEntry *listFiles(short *fileCount, char **dynamicMemoryBuffer); void initializeLaunchArguments(enum NGCommands *command, char *path, uint64_t *seed); diff --git a/src/brogue/RogueMain.c b/src/brogue/RogueMain.c index e4daf90e..5cd6604d 100644 --- a/src/brogue/RogueMain.c +++ b/src/brogue/RogueMain.c @@ -1186,6 +1186,10 @@ void gameOver(char *killedBy, boolean useCustomPhrasing) { notifyEvent(GAMEOVER_RECORDING, 0, 0, "recording ended", "none"); } + if (!rogue.playbackMode && !rogue.easyMode && !rogue.wizard) { + saveRunHistory(rogue.quit ? "Quit" : "Died", rogue.quit ? "-" : killedBy, (int) theEntry.score, numGems); + } + rogue.gameHasEnded = true; rogue.gameExitStatusCode = EXIT_STATUS_SUCCESS; } @@ -1348,6 +1352,10 @@ void victory(boolean superVictory) { notifyEvent(GAMEOVER_RECORDING, 0, 0, "recording ended", "none"); } + if (!rogue.playbackMode && !rogue.easyMode && !rogue.wizard) { + saveRunHistory(victoryVerb, "-", (int) theEntry.score, gemCount); + } + rogue.gameHasEnded = true; rogue.gameExitStatusCode = EXIT_STATUS_SUCCESS; } diff --git a/src/platform/platformdependent.c b/src/platform/platformdependent.c index c7de4287..8d4c9219 100644 --- a/src/platform/platformdependent.c +++ b/src/platform/platformdependent.c @@ -471,6 +471,88 @@ boolean saveHighScore(rogueHighScoresEntry theEntry) { return true; } +/// @brief Sets the name of the run history file based on the variant +/// @param buffer The filename +/// @param bufferMaxLength The maximum filename length +static void setRunHistoryFilename(char *buffer, int bufferMaxLength) { + strncpy(buffer, gameConst->variantName, bufferMaxLength); + strncat(buffer, "RunHistory.txt", bufferMaxLength); + buffer[0] = toupper(buffer[0]); +} + +/// @brief Saves the run to the history file at the end of a game +/// @param result The game result (Escaped, Mastered, Died, Quit) +/// @param killedBy How the player died (monster name, etc.) +/// @param score The total score +/// @param lumenstones The number of lumenstones collected +void saveRunHistory(char *result, char *killedBy, int score, int lumenstones) { + FILE *runHistoryFile; + char runHistoryFilename[BROGUE_FILENAME_MAX]; + + setRunHistoryFilename(runHistoryFilename, BROGUE_FILENAME_MAX); + runHistoryFile = fopen(runHistoryFilename, "a"); // append. create if not found. + + fprintf(runHistoryFile, "%llu\t%li\t%s\t%s\t%i\t%i\t%i\t%i\t%i\n", rogue.seed, (long) time(NULL), result, killedBy, + score, (int) rogue.gold, lumenstones, (int) rogue.deepestLevel, (int) rogue.playerTurnNumber); + fclose(runHistoryFile); +} +/// @brief Saves a "reset" run to the history file. This serves to reset the player's recent stats to zero. +void saveResetRun(void) { + FILE *runHistoryFile; + char runHistoryFilename[BROGUE_FILENAME_MAX]; + + setRunHistoryFilename(runHistoryFilename, BROGUE_FILENAME_MAX); + runHistoryFile = fopen(runHistoryFilename, "a"); // append. create if not found. + + fprintf(runHistoryFile, "%i\t%li\t%s\t%s\t%i\t%i\t%i\t%i\t%i\n", 0, (long) time(NULL), "Reset", "-", 0, 0, 0, 0, 0); + fclose(runHistoryFile); +} + +/// @brief Loads the run history file +/// @return Linked list of runs +rogueRun* loadRunHistory(void) { + FILE *runHistoryFile; + char runHistoryFilename[BROGUE_FILENAME_MAX]; + + setRunHistoryFilename(runHistoryFilename, BROGUE_FILENAME_MAX); + runHistoryFile = fopen(runHistoryFilename, "r"); // read + + if (runHistoryFile == NULL) { + runHistoryFile = fopen(runHistoryFilename, "w"); // create if not found + fclose(runHistoryFile); + runHistoryFile = fopen(runHistoryFilename, "r"); + } + + rogueRun *runHistory = NULL; + rogueRun *current = NULL; + char line[1024]; // maximum line length + while (fgets(line, sizeof(line), runHistoryFile) != NULL) { + rogueRun *run = (rogueRun *)malloc(sizeof(rogueRun)); + memset(run, '\0', sizeof(rogueRun)); + run->nextRun = NULL; + + int vals = sscanf(line, "%llu\t%li\t%s\t%[^\t]\t%i\t%i\t%i\t%i\t%i\n", &run->seed, &run->dateNumber, + run->result, run->killedBy, &run->score, &run->gold, &run->lumenstones, + &run->deepestLevel, &run->turns); + + if ( vals == 9) { + if (runHistory == NULL) { + runHistory = run; + current = run; + } else { + current->nextRun = run; + current = run; + } + } else { + fprintf(stderr, "Error parsing line: %s\n", line); + free(run); + } + } + fclose(runHistoryFile); + + return runHistory; +} + // start of file listing struct filelist {