/* * Portions of this file are copyright Rebirth contributors and licensed as * described in COPYING.txt. * Portions of this file are copyright Parallax Software and licensed * according to the Parallax license below. * See COPYING.txt for license details. THE COMPUTER CODE CONTAINED HEREIN IS THE SOLE PROPERTY OF PARALLAX SOFTWARE CORPORATION ("PARALLAX"). PARALLAX, IN DISTRIBUTING THE CODE TO END-USERS, AND SUBJECT TO ALL OF THE TERMS AND CONDITIONS HEREIN, GRANTS A ROYALTY-FREE, PERPETUAL LICENSE TO SUCH END-USERS FOR USE BY SUCH END-USERS IN USING, DISPLAYING, AND CREATING DERIVATIVE WORKS THEREOF, SO LONG AS SUCH USE, DISPLAY OR CREATION IS FOR NON-COMMERCIAL, ROYALTY OR REVENUE FREE PURPOSES. IN NO EVENT SHALL THE END-USER USE THE COMPUTER CODE CONTAINED HEREIN FOR REVENUE-BEARING PURPOSES. THE END-USER UNDERSTANDS AND AGREES TO THE TERMS HEREIN AND ACCEPTS THE SAME BY USE OF THIS FILE. COPYRIGHT 1993-1999 PARALLAX SOFTWARE CORPORATION. ALL RIGHTS RESERVED. */ /* * * Inferno High Scores and Statistics System * */ #include #include #include #include #include #include "scores.h" #include "dxxerror.h" #include "pstypes.h" #include "window.h" #include "gr.h" #include "key.h" #include "mouse.h" #include "palette.h" #include "game.h" #include "gamefont.h" #include "u_mem.h" #include "newmenu.h" #include "menu.h" #include "player.h" #include "object.h" #include "screens.h" #include "gamefont.h" #include "mouse.h" #include "joy.h" #include "timer.h" #include "text.h" #include "strutil.h" #include "physfsx.h" #include "compiler-range_for.h" #include "d_enumerate.h" #include "d_levelstate.h" #include "d_range.h" #include "d_zip.h" #define VERSION_NUMBER 1 #define SCORES_FILENAME "descent.hi" #define COOL_MESSAGE_LEN 50 namespace dcx { constexpr std::integral_constant MAX_HIGH_SCORES{}; struct score_items_context { const font_x_scaled_float name, score, difficulty, levels, time_played; score_items_context(const font_x_scale_float fspacx, const unsigned border_x) : name(fspacx(51) + border_x), score(fspacx(134) + border_x), difficulty(fspacx(151) + border_x), levels(fspacx(217) + border_x), time_played(fspacx(261) + border_x) { } }; } namespace dsx { namespace { #if defined(DXX_BUILD_DESCENT_I) #define DXX_SCORE_STRUCT_PACK __pack__ #elif defined(DXX_BUILD_DESCENT_II) #define DXX_SCORE_STRUCT_PACK #endif struct stats_info { callsign_t name; int score; sbyte starting_level; sbyte ending_level; sbyte diff_level; short kill_ratio; // 0-100 short hostage_ratio; // int seconds; // How long it took in seconds... } DXX_SCORE_STRUCT_PACK; struct all_scores { char signature[3]; // DHS sbyte version; // version char cool_saying[COOL_MESSAGE_LEN]; stats_info stats[MAX_HIGH_SCORES]; } DXX_SCORE_STRUCT_PACK; #if defined(DXX_BUILD_DESCENT_I) static_assert(sizeof(all_scores) == 294, "high score size wrong"); #elif defined(DXX_BUILD_DESCENT_II) static_assert(sizeof(all_scores) == 336, "high score size wrong"); #endif void scores_view(grs_canvas &canvas, const stats_info *last_game, int citem); static void assign_builtin_placeholder_scores(all_scores &scores) { strcpy(scores.cool_saying, TXT_REGISTER_DESCENT); scores.stats[0].name = "Parallax"; scores.stats[1].name = "Matt"; scores.stats[2].name = "Mike"; scores.stats[3].name = "Adam"; scores.stats[4].name = "Mark"; scores.stats[5].name = "Jasen"; scores.stats[6].name = "Samir"; scores.stats[7].name = "Doug"; scores.stats[8].name = "Dan"; scores.stats[9].name = "Jason"; for (auto &&[idx, stat] : enumerate(scores.stats)) stat.score = (10 - idx) * 1000; } static void scores_read(all_scores *scores) { int fsize; // clear score array... *scores = {}; RAIIPHYSFS_File fp{PHYSFS_openRead(SCORES_FILENAME)}; if (!fp) { // No error message needed, code will work without a scores file assign_builtin_placeholder_scores(*scores); return; } fsize = PHYSFS_fileLength(fp); if ( fsize != sizeof(all_scores) ) { return; } // Read 'em in... PHYSFS_read(fp, scores, sizeof(all_scores), 1); if ( (scores->version!=VERSION_NUMBER)||(scores->signature[0]!='D')||(scores->signature[1]!='H')||(scores->signature[2]!='S') ) { *scores = {}; return; } } static void scores_write(all_scores *scores) { RAIIPHYSFS_File fp{PHYSFS_openWrite(SCORES_FILENAME)}; if (!fp) { nm_messagebox(menu_title{TXT_WARNING}, 1, TXT_OK, "%s\n'%s'", TXT_UNABLE_TO_OPEN, SCORES_FILENAME); return; } scores->signature[0]='D'; scores->signature[1]='H'; scores->signature[2]='S'; scores->version = VERSION_NUMBER; PHYSFS_write(fp, scores,sizeof(all_scores), 1); } static void scores_fill_struct(stats_info * stats) { auto &Objects = LevelUniqueObjectState.Objects; auto &vmobjptr = Objects.vmptr; auto &plr = get_local_player(); stats->name = plr.callsign; auto &player_info = get_local_plrobj().ctype.player_info; stats->score = player_info.mission.score; stats->ending_level = plr.level; if (const auto robots_total = GameUniqueState.accumulated_robots) stats->kill_ratio = (plr.num_kills_total * 100) / robots_total; else stats->kill_ratio = 0; if (const auto hostages_total = GameUniqueState.total_hostages) stats->hostage_ratio = (player_info.mission.hostages_rescued_total * 100) / hostages_total; else stats->hostage_ratio = 0; stats->seconds = f2i(plr.time_total) + (plr.hours_total * 3600); stats->diff_level = GameUniqueState.Difficulty_level; stats->starting_level = plr.starting_level; } } } namespace dcx { namespace { static inline const char *get_placement_slot_string(const unsigned position) { switch(position) { default: Int3(); [[fallthrough]]; case 0: return TXT_1ST; case 1: return TXT_2ND; case 2: return TXT_3RD; case 3: return TXT_4TH; case 4: return TXT_5TH; case 5: return TXT_6TH; case 6: return TXT_7TH; case 7: return TXT_8TH; case 8: return TXT_9TH; case 9: return TXT_10TH; } } struct request_user_high_score_comment : std::array, std::array, newmenu { all_scores &scores; request_user_high_score_comment(all_scores &scores, grs_canvas &canvas) : std::array{{ newmenu_item::nm_item_text{TXT_COOL_SAYING}, newmenu_item::nm_item_input(prepare_input_saying(*this)), }}, newmenu(menu_title{TXT_HIGH_SCORE}, menu_subtitle{TXT_YOU_PLACED_1ST}, menu_filename{nullptr}, tiny_mode_flag::normal, tab_processing_flag::ignore, adjusted_citem::create(*static_cast *>(this), 0), canvas), scores(scores) { } virtual window_event_result event_handler(const d_event &) override; static std::array &prepare_input_saying(std::array &buf) { buf.front() = 0; return buf; } }; window_event_result request_user_high_score_comment::event_handler(const d_event &event) { switch (event.type) { case EVENT_WINDOW_CLOSE: { std::array &text1 = *this; strcpy(scores.cool_saying, text1[0] ? text1.data() : "No comment"); } break; default: break; } return newmenu::event_handler(event); } } } namespace dsx { void scores_maybe_add_player() { auto &Objects = LevelUniqueObjectState.Objects; auto &vmobjptr = Objects.vmptr; all_scores scores; stats_info last_game; if ((Game_mode & GM_MULTI) && !(Game_mode & GM_MULTI_COOP)) return; scores_read(&scores); auto &player_info = get_local_plrobj().ctype.player_info; const auto predicate = [player_mission_score = player_info.mission.score](const stats_info &stats) { return player_mission_score > stats.score; }; const auto begin_score_stats = std::begin(scores.stats); const auto end_score_stats = std::end(scores.stats); /* Find the position at which the player's score should be placed. */ const auto iter_position = std::find_if(begin_score_stats, end_score_stats, predicate); const auto position = std::distance(begin_score_stats, iter_position); /* If iter_position == end_score_stats, then the player's score does * not beat any of the existing high scores. Include a special case * so that the player's statistics can be shown for the duration of * this menu, despite not being a new record. */ stats_info *const ptr_last_game = (iter_position == end_score_stats) ? &last_game : nullptr; if (ptr_last_game) { /* Not a new record */ scores_fill_struct(ptr_last_game); } else { /* New record - check whether it is the best score. If so, * allow the player to leave a comment. */ if (iter_position == begin_score_stats) { run_blocking_newmenu(scores, grd_curscreen->sc_canvas); } else { /* New record, but not a new best score. Tell the player * what slot the new record earned. */ nm_messagebox(menu_title{TXT_HIGH_SCORE}, 1, TXT_OK, "%s %s!", TXT_YOU_PLACED, get_placement_slot_string(position)); } // move everyone down... std::move_backward(iter_position, std::prev(end_score_stats), end_score_stats); scores_fill_struct(iter_position); scores_write(&scores); } scores_view(grd_curscreen->sc_canvas, ptr_last_game, position); } } namespace dcx { namespace { __attribute_nonnull() static void scores_rputs(grs_canvas &canvas, const grs_font &cv_font, const font_x_scaled_float x, const font_y_scaled_float y, const char *const buffer) { const auto &&[w, h] = gr_get_string_size(cv_font, buffer); gr_string(canvas, cv_font, x - w, y, buffer, w, h); } static unsigned compute_score_y_coordinate(const unsigned i) { const unsigned y = 59 + i * 9; return i ? y : y - 8; } } } namespace dsx { namespace { struct scores_menu_items { struct row { callsign_t name; uint8_t diff_level; std::array score; std::array levels; std::array time_played; }; struct numbered_row : row { std::array line_number; }; const int citem; fix64 time_last_color_change = timer_query(); uint8_t looper = 0; std::array scores; row last_game; std::array cool_saying; static void prepare_row(row &r, const stats_info &stats, std::ostringstream &oss); static void prepare_row(numbered_row &r, const stats_info &stats, std::ostringstream &oss, unsigned idx); static std::ostringstream build_locale_stringstream(); void prepare_scores(const all_scores &all_scores, std::ostringstream &oss); scores_menu_items(const int citem, const all_scores &all_scores, const stats_info *last_game_stats); }; void scores_menu_items::prepare_row(row &r, const stats_info &stats, std::ostringstream &oss) { r.name = stats.name; r.diff_level = stats.diff_level; { /* This std::ostringstream is shared among multiple rows, to * avoid reinitializing the std::locale each time. Clear the * text before inserting the score for this row. */ oss.str(""); oss << stats.score; auto &&buffer = oss.str(); //replace the digit '1' with special wider 1 const auto bb = buffer.begin(); const auto be = buffer.end(); auto ri = r.score.begin(); if (std::distance(bb, be) < r.score.size() - 1) ri = std::replace_copy(bb, be, ri, '1', '\x84'); *ri = 0; } { r.levels.front() = 0; auto starting_level = stats.starting_level; auto ending_level = stats.ending_level; if (starting_level || ending_level) { const auto secret_start = (starting_level < 0) ? (starting_level = -starting_level, "S") : ""; const auto secret_end = (ending_level < 0) ? (ending_level = -ending_level, "S") : ""; auto levels_length = std::snprintf(r.levels.data(), r.levels.size(), "%s%d-%s%d", secret_start, starting_level, secret_end, ending_level); auto lb = r.levels.begin(); std::replace(lb, std::next(lb, levels_length), '1', '\x84'); } } { const auto &&d1 = std::div(stats.seconds, 60); const auto &&d2 = std::div(d1.rem, 60); auto time_length = std::snprintf(r.time_played.data(), r.time_played.size(), "%d:%02d:%02d", d1.quot, d2.quot, d2.rem); auto tb = r.time_played.begin(); std::replace(tb, std::next(tb, time_length), '1', '\x84'); } } void scores_menu_items::prepare_row(numbered_row &r, const stats_info &stats, std::ostringstream &oss, const unsigned idx) { std::snprintf(r.line_number.data(), r.line_number.size(), "%u.", idx); auto b = r.line_number.begin(); std::replace(b, std::next(b, 2), '1', '\x84'); prepare_row(r, stats, oss); } std::ostringstream scores_menu_items::build_locale_stringstream() { const auto user_preferred_locale = []() { try { /* Use the user's locale if possible. */ return std::locale(""); } catch (std::runtime_error &) { /* Fall back to the default locale if the user's locale * fails to parse. */ return std::locale(); } }(); std::ostringstream oss; oss.imbue(user_preferred_locale); return oss; } void scores_menu_items::prepare_scores(const all_scores &all_scores, std::ostringstream &oss) { for (auto &&[idx, sr, si] : enumerate(zip(scores, all_scores.stats), 1u)) prepare_row(sr, si, oss, idx); std::copy(std::begin(all_scores.cool_saying), std::prev(std::end(all_scores.cool_saying)), cool_saying.begin()); cool_saying.back() = 0; } scores_menu_items::scores_menu_items(const int citem, const all_scores &all_scores, const stats_info *const last_game_stats) : citem(citem) { auto oss = build_locale_stringstream(); prepare_scores(all_scores, oss); if (last_game_stats) prepare_row(last_game, *last_game_stats, oss); else last_game = {}; } struct scores_menu : scores_menu_items, window { scores_menu(grs_canvas &src, int x, int y, int w, int h, int citem, const all_scores &scores, const stats_info *last_game) : scores_menu_items(citem, scores, last_game), window(src, x, y, w, h) { } virtual window_event_result event_handler(const d_event &) override; int get_update_looper(); }; static void scores_draw_item(grs_canvas &canvas, const grs_font &cv_font, const score_items_context &shared_item_context, const unsigned shade, const font_y_scaled_float fspacy_y, const scores_menu_items::row &stats) { gr_set_fontcolor(canvas, BM_XRGB(shade, shade, shade), -1); if (!stats.name[0u]) { gr_string(canvas, cv_font, shared_item_context.name, fspacy_y, TXT_EMPTY); return; } gr_string(canvas, cv_font, shared_item_context.name, fspacy_y, stats.name); scores_rputs(canvas, cv_font, shared_item_context.score, fspacy_y, stats.score.data()); gr_string(canvas, cv_font, shared_item_context.difficulty, fspacy_y, MENU_DIFFICULTY_TEXT(stats.diff_level)); scores_rputs(canvas, cv_font, shared_item_context.levels, fspacy_y, stats.levels.data()); scores_rputs(canvas, cv_font, shared_item_context.time_played, fspacy_y, stats.time_played.data()); } window_event_result scores_menu::event_handler(const d_event &event) { int k; const auto &&fspacx = FSPACX(); const auto &&fspacy = FSPACY(); switch (event.type) { case EVENT_WINDOW_ACTIVATED: game_flush_inputs(Controls); break; case EVENT_KEY_COMMAND: k = event_key_get(event); switch( k ) { case KEY_CTRLED+KEY_R: if (citem < 0) { // Reset scores... if (nm_messagebox_str(menu_title{nullptr}, nm_messagebox_tie(TXT_NO, TXT_YES), menu_subtitle{TXT_RESET_HIGH_SCORES}) == 1) { PHYSFS_delete(SCORES_FILENAME); all_scores scores{}; assign_builtin_placeholder_scores(scores); auto oss = build_locale_stringstream(); prepare_scores(scores, oss); return window_event_result::handled; } } return window_event_result::handled; case KEY_ENTER: case KEY_SPACEBAR: case KEY_ESC: return window_event_result::close; } break; case EVENT_MOUSE_BUTTON_DOWN: case EVENT_MOUSE_BUTTON_UP: if (event_mouse_get_button(event) == MBTN_LEFT || event_mouse_get_button(event) == MBTN_RIGHT) { return window_event_result::close; } break; #if DXX_MAX_BUTTONS_PER_JOYSTICK case EVENT_JOYSTICK_BUTTON_DOWN: return window_event_result::close; #endif case EVENT_IDLE: timer_delay2(50); break; case EVENT_WINDOW_DRAW: { auto &canvas = w_canv; nm_draw_background(w_canv, 0, 0, w_canv.cv_bitmap.bm_w, w_canv.cv_bitmap.bm_h); auto &medium3_font = *MEDIUM3_FONT; const auto border_x = BORDERX; const auto border_y = BORDERY; gr_string(canvas, medium3_font, 0x8000, border_y, TXT_HIGH_SCORES); auto &game_font = *GAME_FONT; gr_set_fontcolor(canvas, BM_XRGB(28, 28, 28), -1); gr_printf(canvas, game_font, 0x8000, fspacy(16) + border_y, "\"%s\" - %s", cool_saying.data(), static_cast(scores[0].name)); const font_x_scaled_float fspacx_line_number(fspacx(42) + border_x); const score_items_context shared_item_context(fspacx, border_x); gr_set_fontcolor(canvas, BM_XRGB(31, 26, 5), -1); const auto x_header = fspacx(56) + border_x; const auto fspacy_column_labels = fspacy(35) + border_y; gr_string(canvas, game_font, x_header, fspacy_column_labels, TXT_NAME); gr_string(canvas, game_font, x_header + fspacx(51), fspacy_column_labels, TXT_SCORE); gr_string(canvas, game_font, x_header + fspacx(96), fspacy_column_labels, TXT_SKILL); gr_string(canvas, game_font, x_header + fspacx(139), fspacy_column_labels, TXT_LEVELS); gr_string(canvas, game_font, x_header + fspacx(182), fspacy_column_labels, TXT_TIME); if (citem < 0) gr_string(canvas, game_font, 0x8000, fspacy(125) + fspacy_column_labels, TXT_PRESS_CTRL_R); for (const auto &&[idx, stat] : enumerate(scores)) { const auto shade = (idx == citem) ? get_update_looper() : 28 - idx * 2; const unsigned y = compute_score_y_coordinate(idx); const font_y_scaled_float fspacy_y(fspacy(y) + border_y); scores_draw_item(canvas, game_font, shared_item_context, shade, fspacy_y, stat); scores_rputs(canvas, game_font, fspacx_line_number, fspacy_y, stat.line_number.data()); } if (citem == MAX_HIGH_SCORES) { const auto shade = get_update_looper(); scores_draw_item(canvas, game_font, shared_item_context, shade, fspacy(compute_score_y_coordinate(citem) + 8), last_game); } } break; case EVENT_WINDOW_CLOSE: break; default: break; } return window_event_result::ignored; } int scores_menu::get_update_looper() { if (const auto t2 = timer_query(); t2 >= time_last_color_change + F1_0 / 128) { time_last_color_change = t2; if (++ looper >= fades.size()) looper = 0; } const auto shade = 7 + fades[looper]; return shade; } void scores_view(grs_canvas &canvas, const stats_info *const last_game, int citem) { const auto &&fspacx290 = FSPACX(290); const auto &&fspacy170 = FSPACY(170); all_scores scores; scores_read(&scores); const auto border_x = get_border_x(canvas); const auto border_y = get_border_y(canvas); auto menu = window_create(canvas, ((canvas.cv_bitmap.bm_w - fspacx290) / 2) - border_x, ((canvas.cv_bitmap.bm_h - fspacy170) / 2) - border_y, fspacx290 + (border_x * 2), fspacy170 + (border_y * 2), citem, scores, last_game); (void)menu; newmenu_free_background(); set_screen_mode(SCREEN_MENU); show_menus(); } } void scores_view_menu(grs_canvas &canvas) { scores_view(canvas, nullptr, -1); } }