diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9103d9471..5ef0493e3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,9 @@ Version 1.4.0 (Lupin): Not yet released - Bagman (`BAG`) - Team Bagman (`TBAG`) - Last Miner Standing (`LMS`) +- Add sprays with bindable `Spray` control + - `cl_sprays` console command to toggle local display and dedicated server `[sprays]` config section + - `spray` console command to select spray, and in-game spray picker in advanced options ### Minor features, changes, and enhancements [@GooberRF](https://github.com/GooberRF) @@ -76,6 +79,7 @@ Version 1.4.0 (Lupin): Not yet released - Fix unbounded read when a request to play a sound above `g_num_sounds` is made - Fix camera angle snapping when switching between free look and third person camera modes - Fix a server crash that could be triggered by a zero-length UDP packet in the packet receive pump +- Fix overlapping decals rendering with the oldest on top instead of the newest [@is-this-c](https://github.com/is-this-c) - Clear cached server config output after a shuffle of a server's rotation diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index cda3822e5..13afff62e 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -150,6 +150,8 @@ set(SRCS multi/rounds.cpp multi/lms.h multi/lms.cpp + multi/sprays.h + multi/sprays.cpp multi/kill.cpp multi/network.cpp multi/network.h @@ -275,6 +277,8 @@ set(SRCS misc/destruction.h misc/camera.cpp misc/ui.cpp + misc/spray_picker.cpp + misc/spray_picker.h misc/game.cpp misc/level.cpp misc/waypoints.cpp diff --git a/game_patch/input/key.cpp b/game_patch/input/key.cpp index 37751ddc0..f501ff0e3 100644 --- a/game_patch/input/key.cpp +++ b/game_patch/input/key.cpp @@ -21,6 +21,7 @@ #include "../rf/os/os.h" #include "../rf/ui.h" #include "../multi/alpine_packets.h" +#include "../multi/sprays.h" #include "../os/console.h" #include "input.h" @@ -278,49 +279,79 @@ CodeInjection control_config_init_patch{ rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK); alpine_control_config_add_item(ccp, "Toggle Spectate", false, rf::KEY_DIVIDE, -1, -1, rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE); + alpine_control_config_add_item(ccp, "Spray", 0, rf::KEY_Z, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_SPRAY); }, }; -// alpine controls that activate only when local player is alive (multi or single) +// Handles alpine controls that activate only when the local player is alive (multi or single). +static void execute_alive_alpine_control(int action_index) +{ + // only intercept alpine controls + if (action_index < starting_alpine_control_index) { + return; + } + + if (action_index == static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_FLASHLIGHT)) + && !rf::is_multi) { + if (g_headlamp_toggle_enabled) { + (rf::entity_headlamp_is_on(rf::local_player_entity)) + ? rf::entity_headlamp_turn_off(rf::local_player_entity) + : rf::entity_headlamp_turn_on(rf::local_player_entity); + grant_achievement_sp(AchievementName::UseFlashlight); + } + } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_SELF_KILL) && + rf::is_multi) { + rf::player_kill_self(rf::local_player); + if (gt_is_run()) { + multi_hud_reset_run_gt_timer(true); + } + } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_DROP_FLAG) && + rf::is_multi && !rf::is_server) { + send_chat_line_packet("/dropflag", nullptr); + } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_PING_LOCATION)) { + ping_looked_at_location(); + } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_SPRAY)) { + sprays_handle_spray_action(); + } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_INSPECT_WEAPON)) { + fpgun_play_random_idle_anim(); + } +} + CodeInjection player_execute_action_patch{ 0x004A6283, [](auto& regs) { - rf::ControlConfigAction action = regs.ebp; - int action_index = static_cast(action); - //xlog::warn("executing action {}", action_index); + int action_index = static_cast(regs.ebp); + if (starting_alpine_control_index != -1 && + action_index >= starting_alpine_control_index) { + execute_alive_alpine_control(action_index); + regs.eip = 0x004A681B; + } + }, +}; - // only intercept alpine controls - if (action_index >= starting_alpine_control_index) { - if (action_index == static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_FLASHLIGHT)) - && !rf::is_multi) { - if (g_headlamp_toggle_enabled) { - (rf::entity_headlamp_is_on(rf::local_player_entity)) - ? rf::entity_headlamp_turn_off(rf::local_player_entity) - : rf::entity_headlamp_turn_on(rf::local_player_entity); - grant_achievement_sp(AchievementName::UseFlashlight); - } - } - else if (action_index == starting_alpine_control_index + - static_cast(rf::AlpineControlConfigAction::AF_ACTION_SELF_KILL) && - rf::is_multi) { - rf::player_kill_self(rf::local_player); - if (gt_is_run()) { - multi_hud_reset_run_gt_timer(true); - } - } - else if (action_index == starting_alpine_control_index + - static_cast(rf::AlpineControlConfigAction::AF_ACTION_DROP_FLAG) && - rf::is_multi && !rf::is_server) { - send_chat_line_packet("/dropflag", nullptr); - } - else if (action_index == starting_alpine_control_index + - static_cast(rf::AlpineControlConfigAction::AF_ACTION_PING_LOCATION)) { - ping_looked_at_location(); - } - else if (action_index == starting_alpine_control_index + - static_cast(rf::AlpineControlConfigAction::AF_ACTION_INSPECT_WEAPON)) { - fpgun_play_random_idle_anim(); - } +// Stock player_execute_action rejects action indices above 0x3c (60). +// Alpine controls at or past index 0x3d (61) never reach that dispatch, +// so handle them here and return before the stock guard runs. +CodeInjection player_execute_action_high_index_patch{ + 0x004A6272, + [](auto& regs) { + int action_index = static_cast(regs.ebp); + if (starting_alpine_control_index != -1 && + action_index > 0x3c && + action_index >= starting_alpine_control_index) { + execute_alive_alpine_control(action_index); + regs.eip = 0x004A681B; } }, }; @@ -423,8 +454,10 @@ CodeInjection controls_process_patch{ 0x00430E4C, [](auto& regs) { int index = regs.edi; - if (index >= starting_alpine_control_index && - index <= static_cast(rf::AlpineControlConfigAction::_AF_ACTION_LAST_VARIANT)) { + if (starting_alpine_control_index != -1 && + index >= starting_alpine_control_index && + index <= starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::_AF_ACTION_LAST_VARIANT)) { //xlog::warn("passing control {}", index); regs.eip = 0x00430E24; } @@ -543,6 +576,7 @@ void key_apply_patch() // Handle Alpine controls control_config_init_patch.install(); player_execute_action_patch.install(); + player_execute_action_high_index_patch.install(); player_execute_action_patch2.install(); player_execute_action_patch3.install(); controls_process_patch.install(); diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index 9a7558798..352925fa0 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -37,6 +37,7 @@ #include "../fflink/fflink.h" #include "../misc/misc.h" #include "../misc/achievements.h" +#include "../misc/spray_picker.h" #include "../misc/alpine_options.h" #include "../misc/alpine_settings.h" #include "../misc/vpackfile.h" @@ -200,6 +201,7 @@ CodeInjection after_frame_render_hook{ achievement_system_do_frame(); fullscreen_overlay_do_frame(); gas_region_transition_do_frame(); + spray_picker_render(); #if !defined(NDEBUG) && defined(HAS_EXPERIMENTAL) experimental_render(); #endif diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index fd1f7cfda..2dbb9bf10 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -949,6 +949,14 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.play_hit_sounds = std::stoi(settings["PlayHitsounds"]); processed_keys.insert("PlayHitsounds"); } + if (settings.count("SprayDisplay")) { + g_alpine_game_config.spray_display = std::stoi(settings["SprayDisplay"]); + processed_keys.insert("SprayDisplay"); + } + if (settings.count("SpraySelection")) { + g_alpine_game_config.set_selected_spray_index(std::stoi(settings["SpraySelection"])); + processed_keys.insert("SpraySelection"); + } if (settings.count("KillfeedEnabled")) { g_alpine_game_config.killfeed_enabled = std::stoi(settings["KillfeedEnabled"]); processed_keys.insert("KillfeedEnabled"); @@ -1474,6 +1482,8 @@ void alpine_player_settings_save(rf::Player* player) file << "WorldHUDTeamLabels=" << g_alpine_game_config.world_hud_team_player_labels << "\n"; file << "ShowLocationPings=" << g_alpine_game_config.show_location_pings << "\n"; file << "PlayHitsounds=" << g_alpine_game_config.play_hit_sounds << "\n"; + file << "SprayDisplay=" << g_alpine_game_config.spray_display << "\n"; + file << "SpraySelection=" << g_alpine_game_config.selected_spray_index << "\n"; file << "KillfeedEnabled=" << g_alpine_game_config.killfeed_enabled << "\n"; file << "HitSoundIntervalMs=" << g_alpine_game_config.hit_sound_min_interval_ms << "\n"; file << "PlayTaunts=" << g_alpine_game_config.play_taunt_sounds << "\n"; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 5f70ae026..13672be5d 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -8,6 +8,9 @@ extern bool g_loaded_alpine_settings_file; +// forward declaration (in sprays.cpp) +int spray_count(); + struct AlpineGameSettings { // fov @@ -96,6 +99,14 @@ struct AlpineGameSettings bool show_location_pings = true; bool play_hit_sounds = true; + bool spray_display = true; + int selected_spray_index = 0; + void set_selected_spray_index(int index) + { + const int count = spray_count(); + selected_spray_index = (count > 0) ? std::clamp(index, 0, count - 1) : 0; + } + static constexpr int min_hit_sound_interval_ms = 0; static constexpr int max_hit_sound_interval_ms = 1000; int hit_sound_min_interval_ms = 20; diff --git a/game_patch/misc/player.cpp b/game_patch/misc/player.cpp index a5faf8614..e733943cf 100644 --- a/game_patch/misc/player.cpp +++ b/game_patch/misc/player.cpp @@ -23,6 +23,7 @@ #include "../multi/gametype.h" #include "../multi/server_internal.h" #include "../multi/bagman.h" +#include "../multi/sprays.h" #include "../multi/lms.h" #include "../hud/multi_spectate.h" #include "../hud/hud_internal.h" @@ -249,6 +250,7 @@ FunHook player_destroy_hook{ [](rf::Player* player) { multi_spectate_on_destroy_player(player); bagman_on_player_disconnect(player); + sprays_on_player_destroyed(player); lms_on_player_disconnect(player); if (rf::is_server) { remove_ready_player_silent(player); diff --git a/game_patch/misc/spray_picker.cpp b/game_patch/misc/spray_picker.cpp new file mode 100644 index 000000000..adad5b480 --- /dev/null +++ b/game_patch/misc/spray_picker.cpp @@ -0,0 +1,305 @@ +#include +#include +#include +#include "spray_picker.h" +#include "alpine_settings.h" +#include "../multi/sprays.h" +#include "../rf/gr/gr.h" +#include "../rf/gr/gr_font.h" +#include "../rf/bmpman.h" +#include "../rf/input.h" +#include "../rf/ui.h" +#include "../hud/hud.h" +#include "../hud/hud_internal.h" + +namespace +{ + bool g_open = false; + float g_scroll = 0.0f; + int g_cursor_x = 0; + int g_cursor_y = 0; + + constexpr float REF_WIDTH = 1280.0f; + constexpr float REF_HEIGHT = 800.0f; + constexpr float TARGET_THUMB = 160.0f; + constexpr int MIN_COLS = 2; + constexpr int MAX_COLS = 6; + + struct Layout + { + float scale; + int px, py, pw, ph; // panel rect + int content_x, content_y, content_w, content_h; // scrollable grid region (clip window) + int gap; // spacing between cells / edges + int thumb; // square thumbnail size + int cols; + int count; + int total_grid_h; // full (unclipped) height of all rows + int cancel_x, cancel_y, cancel_w, cancel_h; // Cancel button rect + }; + + Layout compute_layout() + { + Layout lo{}; + const int clip_w = rf::gr::clip_width(); + const int clip_h = rf::gr::clip_height(); + lo.scale = std::min(clip_w / REF_WIDTH, clip_h / REF_HEIGHT); + + const auto s = [&](float v) { return static_cast(v * lo.scale); }; + + // Compact panel that leaves a clear margin at every resolution. + lo.pw = std::min(s(880.0f), clip_w - 80); + lo.ph = std::min(s(600.0f), clip_h - 80); + lo.px = (clip_w - lo.pw) / 2; + lo.py = (clip_h - lo.ph) / 2; + + const int pad = s(16.0f); + const int title_h = s(46.0f); + const int footer_h = s(58.0f); + + lo.gap = std::max(4, s(14.0f)); + + lo.content_x = lo.px + pad; + lo.content_y = lo.py + title_h; + lo.content_w = lo.pw - 2 * pad; + lo.content_h = lo.ph - title_h - footer_h; + + // Derive column count from width and the target thumbnail size, then clamp. + const int target = std::max(1, s(TARGET_THUMB)); + lo.cols = std::clamp((lo.content_w - lo.gap) / (target + lo.gap), MIN_COLS, MAX_COLS); + lo.thumb = (lo.content_w - (lo.cols + 1) * lo.gap) / lo.cols; + if (lo.thumb < 1) { + lo.thumb = 1; + } + + lo.count = spray_count(); + const int rows = (lo.count + lo.cols - 1) / lo.cols; + lo.total_grid_h = rows * (lo.thumb + lo.gap) + lo.gap; + + lo.cancel_w = std::min(s(240.0f), lo.content_w); + lo.cancel_h = s(40.0f); + lo.cancel_x = lo.px + (lo.pw - lo.cancel_w) / 2; + lo.cancel_y = lo.py + lo.ph - footer_h + (footer_h - lo.cancel_h) / 2; + + return lo; + } + + // Screen rect of cell `i`, accounting for the current scroll offset. + void cell_rect(const Layout& lo, int i, int& out_x, int& out_y) + { + const int col = i % lo.cols; + const int row = i / lo.cols; + out_x = lo.content_x + lo.gap + col * (lo.thumb + lo.gap); + out_y = lo.content_y + lo.gap + row * (lo.thumb + lo.gap) - static_cast(g_scroll); + } + + float max_scroll(const Layout& lo) + { + return std::max(0.0f, static_cast(lo.total_grid_h - lo.content_h)); + } + + bool point_in(int px, int py, int x, int y, int w, int h) + { + return px >= x && px < x + w && py >= y && py < y + h; + } +} + +void spray_picker_open() +{ + g_open = true; + g_scroll = 0.0f; +} + +bool spray_picker_is_open() +{ + return g_open; +} + +void spray_picker_render() +{ + if (!g_open) { + return; + } + + const Layout lo = compute_layout(); + g_scroll = std::clamp(g_scroll, 0.0f, max_scroll(lo)); + + const int font = rf::ui::medium_font_0; + + // Use the stored menu cursor (screen/clip space) for hover feedback so it matches hit-testing. + const int mx = g_cursor_x; + const int my = g_cursor_y; + const bool cursor_in_content = point_in(mx, my, lo.content_x, lo.content_y, lo.content_w, lo.content_h); + + // Full-screen dim. + rf::gr::set_color(0, 0, 0, 192); + rf::gr::rect(0, 0, rf::gr::clip_width(), rf::gr::clip_height()); + + // Panel background + border. + rf::gr::set_color(20, 20, 20, 235); + rf::gr::rect(lo.px, lo.py, lo.pw, lo.ph); + rf::gr::set_color(120, 120, 120, 255); + hud_rect_border(lo.px, lo.py, lo.pw, lo.ph, std::max(1, static_cast(2 * lo.scale))); + + // Title. + rf::gr::set_color(255, 255, 255, 255); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, lo.px + lo.pw / 2, + lo.py + static_cast(14 * lo.scale), "Select Spray", font); + + // Grid (clipped to the content region, drawn with scroll offset). + int save_cx = 0, save_cy = 0, save_cw = 0, save_ch = 0; + rf::gr::get_clip(&save_cx, &save_cy, &save_cw, &save_ch); + rf::gr::set_clip(lo.content_x, lo.content_y, lo.content_w, lo.content_h); + + // Which cell is the cursor over (only if it is inside the visible content region)? + int hovered = -1; + + for (int i = 0; i < lo.count; ++i) { + int cx = 0, cy = 0; + cell_rect(lo, i, cx, cy); // ABSOLUTE screen coords (used for hit-test + cull) + + // Skip cells fully outside the content region (cheap vertical cull, absolute coords). + if (cy + lo.thumb < lo.content_y || cy > lo.content_y + lo.content_h) { + continue; + } + + if (cursor_in_content && point_in(mx, my, cx, cy, lo.thumb, lo.thumb)) { + hovered = i; + } + + // set_clip moved the drawing origin to (content_x, content_y), so everything drawn while + // the clip is active must use coords RELATIVE to that origin. The absolute cx/cy above are + // still what hit-testing (and the cursor) use, so draw at cx-content_x / cy-content_y. + const int dx = cx - lo.content_x; + const int dy = cy - lo.content_y; + + const int bm = spray_get_bitmap(static_cast(i)); + if (bm >= 0) { + int bw = 0, bh = 0; + rf::bm::get_dimensions(bm, &bw, &bh); + rf::gr::set_color(255, 255, 255, 255); + rf::gr::bitmap_scaled(bm, dx, dy, lo.thumb, lo.thumb, 0, 0, bw, bh, false, false, + rf::gr::bitmap_clamp_mode); + } + else { + // Placeholder tile + (truncated) filename for a texture that failed to load. + rf::gr::set_color(50, 50, 50, 255); + rf::gr::rect(dx, dy, lo.thumb, lo.thumb); + rf::gr::set_color(90, 90, 90, 255); + hud_rect_border(dx, dy, lo.thumb, lo.thumb, 1); + const char* name = spray_texture_name(static_cast(i)); + if (name) { + const std::string fit = hud_fit_string(name, lo.thumb - static_cast(8 * lo.scale), + nullptr, font); + rf::gr::set_color(200, 200, 200, 255); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, dx + lo.thumb / 2, dy + lo.thumb / 2, + fit.c_str(), font); + } + } + + // Selected: bright green border. Hovered: subtle white border. + if (i == g_alpine_game_config.selected_spray_index) { + rf::gr::set_color(120, 230, 120, 255); + hud_rect_border(dx - 2, dy - 2, lo.thumb + 4, lo.thumb + 4, + std::max(2, static_cast(3 * lo.scale))); + } + else if (i == hovered) { + rf::gr::set_color(255, 255, 255, 200); + hud_rect_border(dx - 1, dy - 1, lo.thumb + 2, lo.thumb + 2, + std::max(1, static_cast(2 * lo.scale))); + } + + // Index badge: a small darkened box in the bottom-right corner showing this spray's number. + { + const std::string num = std::to_string(i); + const int fh = rf::gr::get_font_height(font); + const auto num_size = rf::gr::get_string_size(num, font); + const int bpad = std::max(2, static_cast(3 * lo.scale)); + const int box_w = num_size.first + bpad * 2; + const int box_h = fh + bpad; + const int box_x = dx + lo.thumb - box_w; + const int box_y = dy + lo.thumb - box_h; + rf::gr::set_color(0, 0, 0, 180); + rf::gr::rect(box_x, box_y, box_w, box_h); + rf::gr::set_color(255, 255, 255, 255); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, box_x + box_w / 2, + box_y + (box_h - fh) / 2, num.c_str(), font); + } + } + + // Restore the caller's clip window before drawing chrome outside the content region. + rf::gr::set_clip(save_cx, save_cy, save_cw, save_ch); + + // Scrollbar if the grid overflows the content region. + const float ms = max_scroll(lo); + if (ms > 0.0f) { + const float ratio = g_scroll / ms; + const float bar_h = static_cast(lo.content_h) * lo.content_h / lo.total_grid_h; + const float bar_y = lo.content_y + ratio * (lo.content_h - bar_h); + const int bar_w = std::max(4, static_cast(6 * lo.scale)); + const int bar_x = lo.content_x + lo.content_w - bar_w; + rf::gr::set_color(140, 200, 160, 255); + rf::gr::rect(bar_x, std::lround(bar_y), bar_w, std::lround(bar_h)); + } + + // Cancel button, brighten on hover. + const bool cancel_hover = point_in(mx, my, lo.cancel_x, lo.cancel_y, lo.cancel_w, lo.cancel_h); + rf::gr::set_color(cancel_hover ? 70 : 45, cancel_hover ? 70 : 45, cancel_hover ? 70 : 45, 255); + rf::gr::rect(lo.cancel_x, lo.cancel_y, lo.cancel_w, lo.cancel_h); + rf::gr::set_color(150, 150, 150, 255); + hud_rect_border(lo.cancel_x, lo.cancel_y, lo.cancel_w, lo.cancel_h, 1); + rf::gr::set_color(255, 255, 255, 255); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, lo.cancel_x + lo.cancel_w / 2, + lo.cancel_y + (lo.cancel_h - rf::gr::get_font_height(font)) / 2, "Cancel (Esc)", font); +} + +void spray_picker_handle_mouse(int x, int y) +{ + if (!g_open) { + return; + } + + g_cursor_x = x; + g_cursor_y = y; + + const Layout lo = compute_layout(); + + // Mouse wheel scrolls the grid (one row per notch). + if (rf::mouse_dz != 0) { + const float step = static_cast(lo.thumb + lo.gap); + g_scroll += (rf::mouse_dz > 0 ? -step : step); + g_scroll = std::clamp(g_scroll, 0.0f, max_scroll(lo)); + } + + if (rf::mouse_was_button_pressed(0)) { + // Cancel button closes without changing the selection. + if (point_in(x, y, lo.cancel_x, lo.cancel_y, lo.cancel_w, lo.cancel_h)) { + g_open = false; + return; + } + // Clicks only count inside the visible content region (so scrolled-off cells never hit). + if (point_in(x, y, lo.content_x, lo.content_y, lo.content_w, lo.content_h)) { + for (int i = 0; i < lo.count; ++i) { + int cx = 0, cy = 0; + cell_rect(lo, i, cx, cy); + if (point_in(x, y, cx, cy, lo.thumb, lo.thumb)) { + g_alpine_game_config.set_selected_spray_index(i); + g_open = false; + return; + } + } + } + } +} + +void spray_picker_handle_key(rf::Key* key) +{ + if (!g_open) { + return; + } + if (*key == rf::Key::KEY_ESC) { + g_open = false; + } + // Swallow every key while the modal is open so nothing leaks to the options menu. + *key = rf::Key::KEY_NONE; +} diff --git a/game_patch/misc/spray_picker.h b/game_patch/misc/spray_picker.h new file mode 100644 index 000000000..84de27fa0 --- /dev/null +++ b/game_patch/misc/spray_picker.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../rf/input.h" + +void spray_picker_open(); +bool spray_picker_is_open(); +void spray_picker_render(); +void spray_picker_handle_mouse(int x, int y); +void spray_picker_handle_key(rf::Key* key); diff --git a/game_patch/misc/ui.cpp b/game_patch/misc/ui.cpp index c1f86a099..fca1e2e7b 100644 --- a/game_patch/misc/ui.cpp +++ b/game_patch/misc/ui.cpp @@ -8,6 +8,8 @@ #include #include "alpine_settings.h" #include "misc.h" +#include "spray_picker.h" +#include "../multi/sprays.h" #include "../main/main.h" #include "../graphics/gr.h" #include "../os/os.h" @@ -192,6 +194,10 @@ static rf::ui::Checkbox ao_exposuredamage_cbox; static rf::ui::Label ao_exposuredamage_label; static rf::ui::Checkbox ao_painsounds_cbox; static rf::ui::Label ao_painsounds_label; +static rf::ui::Checkbox ao_spray_btn; +static rf::ui::Label ao_spray_label; +static rf::ui::Label ao_spray_but_label; +static char ao_spray_butlabel_text[8]; // levelsounds audio options slider std::vector alpine_audio_panel_settings; @@ -1033,7 +1039,17 @@ void ao_enemybullets_cbox_on_click(int x, int y) { ao_play_button_snd(g_alpine_game_config.show_enemy_bullets); } +void ao_spray_btn_on_click(int x, int y) { + spray_picker_open(); + rf::snd_play(stock_sound_id::menu_select, 0, 0.0f, 1.0f); +} + void alpine_options_panel_handle_key(rf::Key* key){ + // spray picker modal captures all input while open + if (spray_picker_is_open()) { + spray_picker_handle_key(key); + return; + } // todo: more key support (tab, etc.) // close panel on escape if (*key == rf::Key::KEY_ESC) { @@ -1044,6 +1060,12 @@ void alpine_options_panel_handle_key(rf::Key* key){ } void alpine_options_panel_handle_mouse(int x, int y) { + // spray picker modal captures all input while open + if (spray_picker_is_open()) { + spray_picker_handle_mouse(x, y); + return; + } + int hovered_index = -1; //xlog::warn("handling mouse {}, {}", x, y); @@ -1208,8 +1230,8 @@ void alpine_options_panel_init() { &ao_simdist_cbox, &ao_simdist_label, &ao_simdist_butlabel, &alpine_options_panel0, ao_simdist_cbox_on_click, 112, 262, "Simulation dist"); alpine_options_panel_checkbox_init( &ao_unclamplights_cbox, &ao_unclamplights_label, &alpine_options_panel0, ao_unclamplights_cbox_on_click, g_alpine_game_config.full_range_lighting, 112, 292, "Full light range"); - alpine_options_panel_checkbox_init( - &ao_nearest_cbox, &ao_nearest_label, &alpine_options_panel0, ao_nearest_cbox_on_click, g_alpine_game_config.nearest_texture_filtering, 112, 322, "Nearest filtering"); + alpine_options_panel_inputbox_init( + &ao_spray_btn, &ao_spray_label, &ao_spray_but_label, &alpine_options_panel0, ao_spray_btn_on_click, 112, 322, "Select spray"); alpine_options_panel_checkbox_init( &ao_camshake_cbox, &ao_camshake_label, &alpine_options_panel0, ao_camshake_cbox_on_click, !g_alpine_game_config.screen_shake_force_off, 280, 54, "View shake (SP)"); @@ -1232,6 +1254,8 @@ void alpine_options_panel_init() { &ao_firelights_cbox, &ao_firelights_label, &alpine_options_panel0, ao_firelights_cbox_on_click, !g_alpine_game_config.try_disable_muzzle_flash_lights, 280, 262, "Muzzle lights"); alpine_options_panel_checkbox_init( &ao_mpcharlod_cbox, &ao_mpcharlod_label, &alpine_options_panel0, ao_mpcharlod_cbox_on_click, !g_alpine_game_config.multi_no_character_lod, 280, 292, "Entity LOD (MP)"); + alpine_options_panel_checkbox_init( + &ao_nearest_cbox, &ao_nearest_label, &alpine_options_panel0, ao_nearest_cbox_on_click, g_alpine_game_config.nearest_texture_filtering, 280, 322, "Nearest filtering"); // panel 1 @@ -1442,6 +1466,10 @@ void alpine_options_panel_do_frame(int x) snprintf(ao_simdist_butlabel_text, sizeof(ao_simdist_butlabel_text), "%6.2f", g_alpine_game_config.entity_sim_distance); ao_simdist_butlabel.text = ao_simdist_butlabel_text; + // selected spray index (refreshes automatically when the picker changes the selection) + snprintf(ao_spray_butlabel_text, sizeof(ao_spray_butlabel_text), "%d", g_alpine_game_config.selected_spray_index); + ao_spray_but_label.text = ao_spray_butlabel_text; + // mesh lighting snprintf(ao_meshlight_butlabel_text, sizeof(ao_meshlight_butlabel_text), "%s", meshlight_mode_names[std::clamp(g_alpine_game_config.mesh_lighting_mode, 0, 2)]); @@ -1651,6 +1679,19 @@ CodeInjection options_handle_mouse_patch{ }, }; +FunHook options_mouse_handler_hook{ + 0x0044F530, + []() { + if (spray_picker_is_open()) { + int x = 0, y = 0, z = 0; + rf::mouse_get_pos(x, y, z); + spray_picker_handle_mouse(x, y); + return; // do not run stock options mouse handling + } + options_mouse_handler_hook.call_target(); + }, +}; + // unhighlight buttons when not active CodeInjection options_do_frame_unhighlight_buttons_patch{ 0x0044F1E1, @@ -1872,6 +1913,7 @@ void ui_apply_patch() options_do_frame_unhighlight_buttons_patch.install(); options_handle_key_patch.install(); options_handle_mouse_patch.install(); + options_mouse_handler_hook.install(); AsmWriter{0x0044F550}.push(6); // num buttons in options menu AsmWriter{0x0044F552}.push(&new_gadgets); // support mouseover for alpine options button AsmWriter{0x0044F285}.push(5); // back button index, used when hitting esc in options menu diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 172bd0c2b..2e8ae264c 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -20,6 +20,7 @@ #include "server.h" #include "../hud/hud_world.h" #include "alpine_packets.h" +#include "sprays.h" #include "bagman.h" #include "../misc/player.h" #include "../hud/hud.h" @@ -578,6 +579,17 @@ void serialize_payload(const HandicapPayload& payload, std::byte* buf, size_t& o buf[offset++] = static_cast(payload.amount); } +// af_req_spray +void serialize_payload(const SprayReqPayload& payload, std::byte* buf, size_t& offset) +{ + std::memcpy(buf + offset, &payload.texture_id, sizeof(payload.texture_id)); + offset += sizeof(payload.texture_id); + std::memcpy(buf + offset, &payload.pos, sizeof(payload.pos)); + offset += sizeof(payload.pos); + std::memcpy(buf + offset, &payload.normal, sizeof(payload.normal)); + offset += sizeof(payload.normal); +} + // af_req_server_cfg void serialize_payload(const std::monostate& payload, const std::byte* const buf, const size_t& offset) { @@ -604,6 +616,21 @@ void serialize_payload(const TeleportEntityPayload& payload, std::byte* buf, siz offset += sizeof(payload.vel); } +// af_sreq_spray +void serialize_payload(const SprayPayload& payload, std::byte* buf, size_t& offset) +{ + std::memcpy(buf + offset, &payload.player_id, sizeof(payload.player_id)); + offset += sizeof(payload.player_id); + std::memcpy(buf + offset, &payload.texture_id, sizeof(payload.texture_id)); + offset += sizeof(payload.texture_id); + std::memcpy(buf + offset, &payload.pos, sizeof(payload.pos)); + offset += sizeof(payload.pos); + std::memcpy(buf + offset, &payload.normal, sizeof(payload.normal)); + offset += sizeof(payload.normal); + std::memcpy(buf + offset, &payload.flags, sizeof(payload.flags)); + offset += sizeof(payload.flags); +} + void af_send_server_cfg_request() { if (!rf::is_multi || rf::is_server) { return; @@ -618,6 +645,29 @@ void af_send_server_cfg_request() { af_send_client_req_packet(client_req_packet); } +void af_send_spray_request(uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal) +{ + // Send: client -> server + if (!rf::is_multi || rf::is_server) { + return; + } + + SprayReqPayload payload{}; + payload.texture_id = texture_id; + static_assert(sizeof(payload.pos) == sizeof(rf::Vector3), "RF_Vector / rf::Vector3 layout mismatch"); + std::memcpy(&payload.pos, &pos, sizeof(payload.pos)); + std::memcpy(&payload.normal, &normal, sizeof(payload.normal)); + + af_client_req_packet packet{}; + packet.header.type = static_cast(af_packet_type::af_client_req); + packet.header.size = sizeof(uint8_t) + sizeof(payload.texture_id) + sizeof(payload.pos) + sizeof(payload.normal); + packet.req_type = af_client_req_type::af_req_spray; + packet.payload = payload; + + //xlog::info("sprays: sending af_req_spray to server (id={})", texture_id); + af_send_client_req_packet(packet); +} + // send client request packet void af_send_client_req_packet(const af_client_req_packet& packet) { @@ -711,6 +761,31 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: } break; } + case af_client_req_type::af_req_spray: { + constexpr size_t expected = sizeof(uint16_t) + sizeof(RF_Vector) + sizeof(RF_Vector); + if (remaining < expected) { + xlog::warn("af_process_client_req_packet: Spray payload too short ({} < {})", remaining, expected); + return; + } + + uint16_t texture_id = 0; + RF_Vector pos{}; + RF_Vector normal{}; + std::memcpy(&texture_id, bytes + offset, sizeof(texture_id)); + offset += sizeof(texture_id); + std::memcpy(&pos, bytes + offset, sizeof(pos)); + offset += sizeof(pos); + std::memcpy(&normal, bytes + offset, sizeof(normal)); + offset += sizeof(normal); + + rf::Vector3 spray_pos; + rf::Vector3 spray_normal; + std::memcpy(&spray_pos, &pos, sizeof(spray_pos)); + std::memcpy(&spray_normal, &normal, sizeof(spray_normal)); + + sprays_handle_spray_request(player, texture_id, spray_pos, spray_normal); + break; + } default: { xlog::debug("af_process_client_req_packet: unknown req_type {}", static_cast(req_type)); return; @@ -789,6 +864,58 @@ void af_send_teleport_entity_req( } } +void af_send_spray_to_player(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, + const rf::Vector3& normal, uint8_t flags, rf::Player* player) +{ + if (!rf::is_server || !player || !player->net_data) { + return; + } + if (!is_player_minimum_af_client_version(player, 1, 4, 0)) { + return; + } + + SprayPayload payload{}; + payload.player_id = player_id; + payload.texture_id = texture_id; + static_assert(sizeof(payload.pos) == sizeof(rf::Vector3), "RF_Vector / rf::Vector3 layout mismatch"); + std::memcpy(&payload.pos, &pos, sizeof(payload.pos)); + std::memcpy(&payload.normal, &normal, sizeof(payload.normal)); + payload.flags = flags; + + af_server_req_packet packet{}; + packet.header.type = static_cast(af_packet_type::af_server_req); + packet.header.size = sizeof(uint8_t) + sizeof(payload.player_id) + sizeof(payload.texture_id) + + sizeof(payload.pos) + sizeof(payload.normal) + sizeof(payload.flags); + packet.req_type = af_server_req_type::af_sreq_spray; + packet.payload = payload; + + af_send_server_req_packet(packet, player); +} + +void af_broadcast_spray(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal) +{ + if (!rf::is_server) { + return; + } + + int sent = 0; + int skipped = 0; + for (rf::Player& player : SinglyLinkedList{rf::player_list}) { + if (&player == rf::local_player) { + continue; // listen-server host renders locally instead + } + // Recipients (including the requesting player) are gated on AF 1.4 inside the sender. + if (is_player_minimum_af_client_version(&player, 1, 4, 0)) { + af_send_spray_to_player(player_id, texture_id, pos, normal, 0, &player); + ++sent; + } + else { + ++skipped; + } + } + //xlog::info("sprays: broadcast spray for player_id {} to {} clients ({} pre-1.4 skipped)", player_id, sent, skipped); +} + static void af_process_server_req_packet(const void* data, size_t len, const rf::NetAddr&) { // Receive: client <- server @@ -907,6 +1034,42 @@ static void af_process_server_req_packet(const void* data, size_t len, const rf: } break; } + case af_server_req_type::af_sreq_spray: { + constexpr size_t expected = + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(RF_Vector) + sizeof(RF_Vector) + sizeof(uint8_t); + if (remaining < expected) { + xlog::warn("af_process_server_req_packet: Spray payload too short ({} < {})", remaining, expected); + return; + } + + uint8_t player_id = 0; + uint16_t texture_id = 0; + RF_Vector pos{}; + RF_Vector normal{}; + uint8_t flags = 0; + std::memcpy(&player_id, bytes + offset, sizeof(player_id)); + offset += sizeof(player_id); + std::memcpy(&texture_id, bytes + offset, sizeof(texture_id)); + offset += sizeof(texture_id); + std::memcpy(&pos, bytes + offset, sizeof(pos)); + offset += sizeof(pos); + std::memcpy(&normal, bytes + offset, sizeof(normal)); + offset += sizeof(normal); + std::memcpy(&flags, bytes + offset, sizeof(flags)); + offset += sizeof(flags); + + rf::Vector3 spray_pos; + rf::Vector3 spray_normal; + std::memcpy(&spray_pos, &pos, sizeof(spray_pos)); + std::memcpy(&spray_normal, &normal, sizeof(spray_normal)); + + //xlog::info("sprays: received af_sreq_spray (player_id={}, id={}, flags={:#x})", player_id, texture_id, flags); + + // Unknown/reserved flag bits are ignored. + const bool play_sound = (flags & AF_SPRAY_FLAG_SILENT) == 0; + sprays_apply_client_state(player_id, texture_id, spray_pos, spray_normal, play_sound); + break; + } default: xlog::debug("af_process_server_req_packet: unknown req_type {}", static_cast(req_type)); break; @@ -1532,6 +1695,8 @@ static void build_af_server_info_packet(af_server_info_packet& pkt) af |= af_server_info_flags::SIF_CLEAR_STALE_MOVEMENT_INPUT; if (was_level_loaded_manually()) af |= af_server_info_flags::SIF_MANUAL_LEVEL_LOAD; + if (server_sprays_enabled()) + af |= af_server_info_flags::SIF_ALLOW_SPRAYS; if (g_alpine_server_config.signal_cfg_changed) { af |= af_server_info_flags::SIF_SERVER_CFG_CHANGED; for (rf::Player& player : SinglyLinkedList{rf::player_list}) { @@ -1615,6 +1780,7 @@ static void decode_af_server_info_flags(const af_server_info_packet& pkt, Alpine server_info.allow_outlines_xray = (pkt.af_flags & af_server_info_flags::SIF_ALLOW_OUTLINES_XRAY) != 0; server_info.clear_stale_movement_input = (pkt.af_flags & af_server_info_flags::SIF_CLEAR_STALE_MOVEMENT_INPUT) != 0; server_info.was_manual_level_load = (pkt.af_flags & af_server_info_flags::SIF_MANUAL_LEVEL_LOAD) != 0; + server_info.allow_sprays = (pkt.af_flags & af_server_info_flags::SIF_ALLOW_SPRAYS) != 0; } // Apply af_server_info_packet flags to the local server info (for listen server host) diff --git a/game_patch/multi/alpine_packets.h b/game_patch/multi/alpine_packets.h index cb385c40c..84116f6ab 100644 --- a/game_patch/multi/alpine_packets.h +++ b/game_patch/multi/alpine_packets.h @@ -80,6 +80,7 @@ enum class af_client_req_type : uint8_t { af_req_handicap = 0x0, af_req_server_cfg = 0x1, + af_req_spray = 0x2, }; struct HandicapPayload @@ -87,7 +88,15 @@ struct HandicapPayload uint8_t amount = 0; }; -using af_client_payload = std::variant; +struct SprayReqPayload +{ + uint16_t texture_id = 0; + RF_Vector pos = {}; + RF_Vector normal = {}; +}; +static_assert(sizeof(SprayReqPayload) == 26); + +using af_client_payload = std::variant; struct af_client_req_packet { @@ -100,6 +109,7 @@ enum class af_server_req_type : uint8_t { af_sreq_should_gib = 0x0, af_sreq_teleport_entity = 0x1, // Alpine 1.4 + af_sreq_spray = 0x2, // Alpine 1.4 }; struct ShouldGibPayload @@ -115,7 +125,22 @@ struct TeleportEntityPayload RF_Vector vel = {}; }; -using af_server_req_payload = std::variant; +enum af_spray_flags : uint8_t +{ + AF_SPRAY_FLAG_SILENT = 1 << 0, +}; + +struct SprayPayload +{ + uint8_t player_id = 0; + uint16_t texture_id = 0; + RF_Vector pos = {}; + RF_Vector normal = {}; + uint8_t flags = 0; // af_spray_flags +}; +static_assert(sizeof(SprayPayload) == 28); + +using af_server_req_payload = std::variant; struct af_server_req_packet { @@ -254,6 +279,7 @@ enum af_server_info_flags : uint32_t { SIF_ALLOW_OUTLINES_XRAY = 1u << 16, SIF_CLEAR_STALE_MOVEMENT_INPUT = 1u << 17, SIF_MANUAL_LEVEL_LOAD = 1u << 18, + SIF_ALLOW_SPRAYS = 1u << 19, }; // Subset of `rf::NetGameFlags`. @@ -430,6 +456,8 @@ static void af_process_client_req_packet(const void* data, size_t len, const rf: void af_send_server_req_packet(const af_server_req_packet& packet, rf::Player* player); void af_send_should_gib_req(uint32_t obj_handle); void af_send_teleport_entity_req(uint32_t obj_handle, const rf::Vector3& pos, const rf::Matrix3& orient, const rf::Vector3& vel); +void af_send_spray_to_player(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal, uint8_t flags, rf::Player* player); +void af_broadcast_spray(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal); static void af_process_server_req_packet(const void* data, size_t len, const rf::NetAddr& addr); void af_send_just_spawned_loadout(rf::Player* to_player, std::vector loadout); static void af_process_just_spawned_info_packet(const void* data, size_t len, const rf::NetAddr& addr); @@ -469,6 +497,7 @@ void af_send_server_console_msg(std::string_view msg, rf::Player* player, bool t // client requests void af_send_handicap_request(uint8_t amount); void af_send_server_cfg_request(); +void af_send_spray_request(uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal); // server bot control void af_send_bot_control_simple(rf::Player* player, af_bot_control_type subtype); diff --git a/game_patch/multi/dedi_cfg.cpp b/game_patch/multi/dedi_cfg.cpp index be7849479..908ca3c36 100644 --- a/game_patch/multi/dedi_cfg.cpp +++ b/game_patch/multi/dedi_cfg.cpp @@ -842,6 +842,19 @@ static DamageNotificationConfig parse_damage_notification_config(const toml::tab return o; } +static SprayConfig parse_spray_config(const toml::table& t) +{ + SprayConfig o; + if (auto v = t["enabled"].value()) + o.enabled = *v; + + if (o.enabled) { + if (auto v = t["cooldown_ms"].value()) + o.cooldown_ms = std::clamp(static_cast(*v), 0, 600000); + } + return o; +} + static AlpineRestrictConfig parse_alpine_restrict_config(const toml::table &t) { AlpineRestrictConfig o; @@ -1279,6 +1292,8 @@ static void apply_known_table_in_order( cfg.inactivity_config = parse_inactivity_config(tbl); else if (key == "damage_notifications") cfg.damage_notification_config = parse_damage_notification_config(tbl); + else if (key == "sprays") + cfg.spray_config = parse_spray_config(tbl); else if (key == "alpine_restrict") cfg.alpine_restricted_config = parse_alpine_restrict_config(tbl); else if (key == "click_limiter") @@ -2185,6 +2200,12 @@ void print_alpine_dedicated_server_config_info(std::string& output, bool verbose std::format_to(iter, " Legacy client compatibility: {}\n", cfg.damage_notification_config.support_legacy_clients); } + // sprays + std::format_to(iter, " Sprays: {}\n", cfg.spray_config.enabled); + if (cfg.spray_config.enabled) { + std::format_to(iter, " Cooldown: {} ms\n", cfg.spray_config.cooldown_ms); + } + // alpine restrict std::format_to(iter, " Advertise Alpine: {}\n", cfg.alpine_restricted_config.advertise_alpine); std::format_to(iter, " Only welcome Alpine players: {}\n", cfg.alpine_restricted_config.only_welcome_alpine); diff --git a/game_patch/multi/kill.cpp b/game_patch/multi/kill.cpp index 271c64e73..2ef13a514 100644 --- a/game_patch/multi/kill.cpp +++ b/game_patch/multi/kill.cpp @@ -21,6 +21,7 @@ #include "server_internal.h" #include "multi_private.h" #include "alpine_packets.h" +#include "sprays.h" #include "../misc/player.h" #include "../misc/misc.h" #include "kill.h" @@ -351,6 +352,9 @@ FunHook multi_level_init_hook{ multi_level_init_hook.call_target(); + // Clear all sprays on both client and server when a new level loads. + sprays_level_init(); + // Stop allowing endgame votes after the next level starts multi_player_set_can_endgame_vote(false); diff --git a/game_patch/multi/lms.cpp b/game_patch/multi/lms.cpp index dbab4972a..275bbd39b 100644 --- a/game_patch/multi/lms.cpp +++ b/game_patch/multi/lms.cpp @@ -326,7 +326,7 @@ bool lms_can_round_start() // us start while players are still mid-load, racing the spawn pipeline. int loaded = 0; for (rf::Player& p : SinglyLinkedList{rf::player_list}) { - if (p.is_browser) continue; + if (p.is_browser || p.is_spectator) continue; if (!player_is_loaded(&p)) continue; if (++loaded >= 2) return true; } diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index 9727a8b77..32ca0ba71 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -14,6 +14,7 @@ #include "endgame_votes.h" #include "multi_private.h" #include "alpine_packets.h" +#include "sprays.h" #include "server_internal.h" #include "gametype.h" #include "rounds.h" @@ -1216,6 +1217,7 @@ void multi_do_patch() multi_customize_listen_server_settings_patch.install(); multi_kill_do_patch(); + sprays_do_patch(); faction_files_do_patch(); level_download_do_patch(); network_init(); diff --git a/game_patch/multi/multi.h b/game_patch/multi/multi.h index 6ef634a44..5c9f4ced4 100644 --- a/game_patch/multi/multi.h +++ b/game_patch/multi/multi.h @@ -109,6 +109,7 @@ struct AlpineFactionServerInfo bool allow_outlines_xray = false; bool clear_stale_movement_input = false; bool was_manual_level_load = false; + bool allow_sprays = false; }; enum class AlpineRestrictVerdict : uint8_t diff --git a/game_patch/multi/network.cpp b/game_patch/multi/network.cpp index f531f7492..6620ffd73 100644 --- a/game_patch/multi/network.cpp +++ b/game_patch/multi/network.cpp @@ -30,6 +30,7 @@ #include "alpine_packets.h" #include "server.h" #include "server_internal.h" +#include "sprays.h" #include "bots/bot_chat_manager.h" #include "../main/main.h" #include "../os/os.h" @@ -1668,6 +1669,9 @@ CallHook send_join_accept_packet_ho if (server_clear_stale_movement_input()) { ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input; } + if (server_sprays_enabled()) { + ext_data.flags |= AlpineFactionJoinAcceptPacketExt::Flags::allow_sprays; + } // AF 1.3+ clients: use footer-based format for forward compatibility // Older clients: use legacy raw struct (they don't know about the footer) bool use_footer = g_joining_client_version == ClientSoftware::AlpineFaction @@ -1803,6 +1807,7 @@ CodeInjection process_join_accept_injection{ server_info.allow_outlines = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines); server_info.allow_outlines_xray = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_outlines_xray); server_info.clear_stale_movement_input = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::clear_stale_movement_input); + server_info.allow_sprays = !!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::allow_sprays); constexpr float default_fov = 90.0f; if (!!(ext_data.flags & AlpineFactionJoinAcceptPacketExt::Flags::max_fov) && ext_data.max_fov >= default_fov) { @@ -2621,6 +2626,7 @@ CodeInjection send_state_info_injection{ rf::Player* player = regs.edi; trigger_send_state_info(player); pf_player_level_load(player); + sprays_force_state_sync_to(player); }, }; diff --git a/game_patch/multi/network.h b/game_patch/multi/network.h index 691147dd1..1daee42e5 100644 --- a/game_patch/multi/network.h +++ b/game_patch/multi/network.h @@ -187,6 +187,7 @@ struct AlpineFactionJoinAcceptPacketExt allow_outlines = 1u << 14, allow_outlines_xray = 1u << 15, clear_stale_movement_input = 1u << 16, + allow_sprays = 1u << 17, } flags = Flags::none; float max_fov = 0.0f; diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 8e7801c5e..6dd157951 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -21,6 +21,7 @@ #include "server.h" #include "server_internal.h" #include "alpine_packets.h" +#include "sprays.h" #include "multi.h" #include "gametype.h" #include "bagman.h" @@ -2120,6 +2121,9 @@ FunHook multi_spawn_player_server_side_hook{ if (player->is_browser) { return; } + if (player->is_spectator) { + return; + } if (!check_can_player_spawn(player)) { return; } @@ -3818,6 +3822,16 @@ bool server_allow_footsteps() return g_alpine_server_config.allow_footsteps; } +bool server_sprays_enabled() +{ + return g_alpine_server_config.spray_config.enabled; +} + +int server_spray_cooldown_ms() +{ + return g_alpine_server_config.spray_config.cooldown_ms; +} + std::tuple server_features_require_alpine_client() { bool requires_alpine = false; // alpine required to spawn diff --git a/game_patch/multi/server.h b/game_patch/multi/server.h index 67683dcad..a10c19e34 100644 --- a/game_patch/multi/server.h +++ b/game_patch/multi/server.h @@ -33,6 +33,8 @@ bool server_gaussian_spread(); bool server_geo_chunk_physics(); bool server_clear_stale_movement_input(); bool server_allow_footsteps(); +bool server_sprays_enabled(); +int server_spray_cooldown_ms(); std::tuple server_features_require_alpine_client(); void server_reliable_socket_ready(rf::Player* player); bool server_weapon_items_give_full_ammo(); diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 4fdf9bd42..ee18529d5 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -228,6 +228,12 @@ struct DamageNotificationConfig bool support_legacy_clients = true; }; +struct SprayConfig +{ + bool enabled = true; + int cooldown_ms = 10000; +}; + struct CriticalHitsConfig { bool enabled = false; @@ -792,6 +798,7 @@ struct AlpineServerConfig AlpineRestrictConfig alpine_restricted_config; InactivityConfig inactivity_config; DamageNotificationConfig damage_notification_config; + SprayConfig spray_config; ClickLimiterConfig click_limiter_config; VoteConfig vote_match; VoteConfig vote_kick; diff --git a/game_patch/multi/sprays.cpp b/game_patch/multi/sprays.cpp new file mode 100644 index 000000000..a9effd9e0 --- /dev/null +++ b/game_patch/multi/sprays.cpp @@ -0,0 +1,577 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "sprays.h" +#include "alpine_packets.h" +#include "server.h" +#include "multi.h" +#include "../misc/player.h" +#include "../misc/alpine_settings.h" +#include "../sound/sound.h" +#include "../os/console.h" +#include "../os/os.h" +#include "../rf/geometry.h" +#include "../rf/collide.h" +#include "../rf/level.h" +#include "../rf/bmpman.h" +#include "../rf/entity.h" +#include "../rf/multi.h" +#include "../rf/os/timer.h" +#include "../rf/math/vector.h" +#include "../rf/math/matrix.h" +#include "../rf/player/player.h" +#include "../rf/player/camera.h" + +namespace +{ + // Full clip-box size of a spray decal in world units. + constexpr float kSprayExtent = 2.0f; + + // Max distance from the player to the sprayed surface. + constexpr float kSprayMaxDistance = 3.0f; + + // In single player there is no network player id, this is a stand-in. + constexpr uint8_t kSinglePlayerSprayerId = 0; + + // Spray textures, stretched to 1:1 aspect. + constexpr const char* g_spray_table[] = { + "dec_af_csp01.png", + "rf_red01_A.tga", + "rebel_insignia.tga", + "dec_af_csp04.png", + "mtl_bronze_ultor01_A.tga", + "ultorlogo01_A.tga", + "ultor_blue01_A.tga", + "mtl_dm17ultor01_A.tga", + "dec_af_csp05.png", + "dec_af_csp06.tga", + "dieultor_A.tga", + "mtl_eos_rules01.tga", + "mtl_kill_guards01.tga", + "mtl_plague01.tga", + "dec_af_csp07.png", + "mtl_ultsux01.tga", + "sucks_to_be_you.tga", + "sld_greenX_A.tga", + "GeoPointer01.tga", + "GeoPointer02.tga", + "dec_af_csp09.tga", + "mtl_apc01_dirty.tga", + "mtl_ultor_prop002.tga", + "mtl_high01_dirty.tga", + "mtl_horseplay.tga", + "mtl_highvoltage.tga", + "mtl_donotuseelv.tga", + "mtl_electric_fence01.tga", + "mtl_emergency_room01.tga", + "mtl_cavein01.tga", + "mtl_biohazard02.tga", + "mtl_shower_limit.tga", + "mtl_no_miners01.tga", + "mtl_no_admit.tga", + "mtl_obey_rules01.tga", + "mtl_no_horsing02.tga", + "mtl_heavy_equipment01.tga", + "blastedcem01_A.tga", + "BlastHole.tga", + "leaf.tga", + "AcidBlob.tga", + "AcidBlob02.tga", + "brownsplat01_A.tga", + "darkbluesplat01_A.tga", + "dec_af_csp08.png", + "dec_af_csp10.tga", + "dec_af_csp02.png", + "sld_merc_decal01.tga", + "dec_af_csp03.png", + "circle.tga", + "klown_a.tga", + "mtl_cargo_logo01.tga", + }; + constexpr size_t kSprayCount = std::size(g_spray_table); + + // Lazily-loaded, cached bitmap handles (nullopt = not yet attempted, -1 = load failed). + std::array, kSprayCount> g_spray_bitmaps; + + struct ClientSprayState + { + uint16_t texture_id = 0; + rf::Vector3 pos{}; + rf::Vector3 normal{}; + rf::GDecal* decal = nullptr; // may be null (display off or creation failed) + }; + + struct ServerSprayState + { + uint16_t texture_id = 0; + rf::Vector3 pos{}; + rf::Vector3 normal{}; + }; + + std::unordered_map g_client_sprays; + std::unordered_map g_server_sprays; + + int get_cached_spray_bitmap(uint16_t id) + { + if (id >= kSprayCount) { + return -1; + } + if (!g_spray_bitmaps[id].has_value()) { + int bm = rf::bm::load(g_spray_table[id], -1, true); + if (bm >= 0) { + rf::bm::texture_add_ref(bm); + } + else { + xlog::warn("sprays: failed to load spray texture '{}'", g_spray_table[id]); + } + g_spray_bitmaps[id] = bm; + } + return *g_spray_bitmaps[id]; + } + + bool is_finite_vec(const rf::Vector3& v) + { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); + } + + // Build an explicit, upright orientation for the decal. + // We cannot use Matrix3::make_quick here because it derives rvec/uvec from the matrix's own + // zero-initialized fvec rather than from the passed vector, yielding a fixed/degenerate basis. + rf::Matrix3 build_spray_orient(const rf::Vector3& normal) + { + rf::Vector3 f = normal; + f.normalize_safe(); + const rf::Vector3 ref = (std::fabs(f.y) < 0.98f) ? rf::Vector3{0.0f, 1.0f, 0.0f} + : rf::Vector3{1.0f, 0.0f, 0.0f}; + rf::Vector3 r = ref.cross(f); + r.normalize_safe(); + rf::Vector3 u = f.cross(r); // unit length (f, r orthonormal) + + rf::Matrix3 m; + m.rvec = r; + m.uvec = u; + m.fvec = f; + return m; + } + + bool spray_level_active() + { + return (rf::level.flags & rf::LEVEL_LOADED) != 0 && rf::g_level_solid != nullptr; + } + + void spray_destroy_decal(rf::GDecal* decal) + { + if (decal && spray_level_active()) { + rf::g_decal_destroy(decal); + } + } + + rf::GDecal* create_spray_decal(uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal, bool play_sound) + { + if (rf::is_dedicated_server || !spray_level_active()) { + return nullptr; + } + int bm = get_cached_spray_bitmap(texture_id); + if (bm < 0) { + return nullptr; + } + + rf::Vector3 n = normal; + n.normalize_safe(); + + rf::GDecalCreateInfo dci{}; + dci.pos = pos; + dci.orient = build_spray_orient(n); + dci.extents = {kSprayExtent, kSprayExtent, kSprayExtent}; + dci.texture = bm; + dci.room = rf::find_room(rf::g_level_solid, &pos); + dci.alpha = 255; + dci.flags = 0; + dci.object_handle = -1; + dci.solid = rf::g_level_solid; + dci.scale = 1.0f; + + rf::GDecal* decal = rf::g_decal_add(&dci); + + if (decal && play_sound) { + int sound_id = get_spray_sound_id(); + if (sound_id >= 0) { + play_local_sound_3d(static_cast(sound_id), pos, 0, 1.0f); + } + } + return decal; + } + + // Clean up spray decals. + FunHook g_decal_destroy_hook{ + 0x004D6C50, + [](rf::GDecal* decal) { + g_decal_destroy_hook.call_target(decal); + for (auto& [player_id, state] : g_client_sprays) { + if (state.decal == decal) { + state.decal = nullptr; + } + } + }, + }; + + // Newest decal renders on top. Stock function inserts a new decal poly at the HEAD of + // its face's poly list and the renderer draws that list head-first, so overlapping decals show + // the OLDEST on top. Append at the TAIL instead so the most recently created decal draws last + // (on top). + void __fastcall decal_face_poly_insert_newest_on_top(rf::GFace* face, int, rf::DecalPoly* poly) + { + poly->next_for_face = nullptr; + if (face->decal_list == nullptr) { + face->decal_list = poly; + } + else { + rf::DecalPoly* node = face->decal_list; + while (node->next_for_face != nullptr) { + node = node->next_for_face; + } + node->next_for_face = poly; + } + } + + FunHook decal_face_poly_insert_hook{ + 0x004E36D0, + decal_face_poly_insert_newest_on_top, + }; + + // Ensure sprays don't survive a level change in SP or MP. + CodeInjection level_set_to_load_clear_sprays_patch{ + 0x0045E2E0, + []() { + sprays_level_init(); + }, + }; + + ConsoleCommand2 cl_sprays_cmd{ + "cl_sprays", + []() { + g_alpine_game_config.spray_display = !g_alpine_game_config.spray_display; + sprays_apply_display_toggle(); + rf::console::print("Local spray display is {}", g_alpine_game_config.spray_display ? "enabled" : "disabled"); + }, + "Toggle whether players' sprays are displayed locally", + "cl_sprays", + }; + + ConsoleCommand2 spray_cmd{ + "spray", + [](std::optional spray_id) { + if (spray_id) { + if (*spray_id < 0 || *spray_id >= spray_count()) { + rf::console::print("Invalid spray id {}. Valid range is 0-{}.", *spray_id, spray_count() - 1); + return; + } + g_alpine_game_config.set_selected_spray_index(*spray_id); + } + rf::console::print("Selected spray: {} ({})", g_alpine_game_config.selected_spray_index, + spray_texture_name(static_cast(g_alpine_game_config.selected_spray_index))); + if (!spray_id) { + rf::console::print("Available sprays:"); + for (int i = 0; i < spray_count(); ++i) { + rf::console::print(" {} - {}", i, spray_texture_name(static_cast(i))); + } + } + }, + "Select the spray to use, or list all available sprays", + "spray [id]", + }; +} + +int spray_count() +{ + return static_cast(kSprayCount); +} + +bool is_valid_spray_id(uint16_t spray_id) +{ + return spray_id < kSprayCount; +} + +const char* spray_texture_name(uint16_t spray_id) +{ + return is_valid_spray_id(spray_id) ? g_spray_table[spray_id] : nullptr; +} + +int spray_get_bitmap(uint16_t id) +{ + return get_cached_spray_bitmap(id); +} + +void sprays_do_patch() +{ + g_decal_destroy_hook.install(); + decal_face_poly_insert_hook.install(); + level_set_to_load_clear_sprays_patch.install(); + cl_sprays_cmd.register_cmd(); + spray_cmd.register_cmd(); +} + +void sprays_level_init() +{ + g_server_sprays.clear(); + g_client_sprays.clear(); +} + +void sprays_on_player_destroyed(rf::Player* player) +{ + if (!player || !player->net_data) { + return; + } + const uint8_t player_id = player->net_data->player_id; + + if (rf::is_server) { + g_server_sprays.erase(player_id); + } + + auto it = g_client_sprays.find(player_id); + if (it != g_client_sprays.end()) { + spray_destroy_decal(it->second.decal); // no-op if the level is being torn down + g_client_sprays.erase(player_id); + } +} + +// Sync sprays for late joiners. +void sprays_force_state_sync_to(rf::Player* player) +{ + if (!rf::is_server || !player || !player->net_data) { + return; + } + if (!is_player_minimum_af_client_version(player, 1, 4, 0)) { + return; + } + for (const auto& [player_id, state] : g_server_sprays) { + af_send_spray_to_player(player_id, state.texture_id, state.pos, state.normal, AF_SPRAY_FLAG_SILENT, player); + } +} + +void sprays_handle_spray_request(rf::Player* player, uint16_t texture_id, const rf::Vector3& pos_in, const rf::Vector3& normal_in) +{ + if (!rf::is_server || !player || !player->net_data) { + return; + } + + //xlog::info("sprays: request from '{}' (id={}, pos=({:.2f}, {:.2f}, {:.2f}))", player->name, texture_id, pos_in.x, pos_in.y, pos_in.z); + + if (!server_sprays_enabled()) { + //xlog::info("sprays: rejected '{}': sprays disabled in config", player->name); + return; + } + + if (!is_valid_spray_id(texture_id)) { + //xlog::warn("sprays: rejected '{}': invalid spray id {}", player->name, texture_id); + //af_send_server_console_msg("Spray rejected: invalid spray", player); + return; + } + + if (!is_finite_vec(pos_in) || !is_finite_vec(normal_in)) { + //xlog::warn("sprays: rejected '{}': non-finite pos/normal", player->name); + //af_send_server_console_msg("Spray rejected: invalid data", player); + return; + } + + rf::Vector3 normal = normal_in; + const float nlen = normal.len(); + if (!std::isfinite(nlen) || nlen < 0.0001f) { + //xlog::warn("sprays: rejected '{}': degenerate normal", player->name); + //af_send_server_console_msg("Spray rejected: invalid data", player); + return; + } + normal /= nlen; + + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + //xlog::info("sprays: rejected '{}': no live entity", player->name); + return; // no live entity to spray from + } + + // Use the closer of feet and eye to the hit point. Measuring only from the feet + // (entity->pos) falsely rejects a player standing against a tall wall who aims high on it; + // the eye (entity->eye_pos, maintained every server tick for AI aiming) covers that. Taking + // the min keeps the anti-abuse cap intact while staying robust even if eye_pos were stale. + const float dist = std::min(entity->pos.distance_to(pos_in), entity->eye_pos.distance_to(pos_in)); + if (dist > kSprayMaxDistance + 1.0f) { + //xlog::info("sprays: rejected '{}': too far ({:.2f} > {:.2f})", player->name, dist, kSprayMaxDistance); + af_send_automated_chat_msg("Spray rejected: too far from surface", player); + return; + } + + const int64_t now = timer::get_i64(1000); + if (player->last_spray_ms && (now - *player->last_spray_ms) < server_spray_cooldown_ms()) { + //xlog::info("sprays: rejected '{}': on cooldown ({} ms since last)", player->name, now - *player->last_spray_ms); + af_send_automated_chat_msg("Spray rejected: on cooldown", player); + return; + } + + // Spray accepted. + player->last_spray_ms = now; + const uint8_t player_id = player->net_data->player_id; + g_server_sprays[player_id] = ServerSprayState{texture_id, pos_in, normal}; + //xlog::info("sprays: accepted spray from '{}' (player_id={}), broadcasting", player->name, player_id); + + // Broadcast to every client (including the requester). + // On a listen server also render locally for the host. + af_broadcast_spray(player_id, texture_id, pos_in, normal); + if (!rf::is_dedicated_server) { + sprays_apply_client_state(player_id, texture_id, pos_in, normal, true); + } +} + +void sprays_apply_client_state(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal, bool play_sound) +{ + if (rf::is_dedicated_server) { + return; // dedicated servers do not render + } + if (!is_valid_spray_id(texture_id)) { + xlog::debug("sprays: ignoring spray with unknown id {} for player_id {}", texture_id, player_id); + // The player switched to a spray we can't render. Remove their existing spray. + auto it = g_client_sprays.find(player_id); + if (it != g_client_sprays.end()) { + spray_destroy_decal(it->second.decal); // hook nulls the pointer; no-op during teardown + g_client_sprays.erase(it); + } + return; + } + + // Replace this player's previous spray everywhere. + auto it = g_client_sprays.find(player_id); + if (it != g_client_sprays.end()) { + spray_destroy_decal(it->second.decal); // hook nulls the pointer; no-op during teardown + } + + ClientSprayState state{}; + state.texture_id = texture_id; + state.pos = pos; + state.normal = normal; + state.decal = g_alpine_game_config.spray_display + ? create_spray_decal(texture_id, pos, normal, play_sound) + : nullptr; + + if (g_alpine_game_config.spray_display && !state.decal) { + xlog::warn("sprays: decal creation failed for player_id {} (id={}, pos=({:.2f}, {:.2f}, {:.2f}))", + player_id, texture_id, pos.x, pos.y, pos.z); + } + else { + //xlog::info("sprays: applied spray for player_id {} (id={}, decal={})", player_id, texture_id, state.decal != nullptr); + } + + g_client_sprays[player_id] = state; +} + +void sprays_apply_display_toggle() +{ + if (rf::is_dedicated_server) { + return; + } + if (g_alpine_game_config.spray_display) { + for (auto& [player_id, state] : g_client_sprays) { + if (!state.decal) { + state.decal = create_spray_decal(state.texture_id, state.pos, state.normal, false); + } + } + } + else { + for (auto& [player_id, state] : g_client_sprays) { + spray_destroy_decal(state.decal); // hook nulls state.decal + state.decal = nullptr; + } + } +} + +// On-screen feedback for user-visible guard failures. +static void spray_chat_feedback(const char* text) +{ + if (!rf::is_multi) { + rf::console::print("{}", text); + return; + } + rf::String msg{text}; + rf::String prefix; + rf::multi_chat_print(msg, rf::ChatMsgColor::white_white, prefix); +} + +void sprays_handle_spray_action() +{ + xlog::debug("sprays: spray action fired"); + + // Sprays are usable in single player as well as multiplayer. + const bool single_player = !rf::is_multi; + + // Whether sprays are allowed: a networked client learns this from the server's af_server_info + // (delivered in the join-accept); the listen-server host IS the server, so it consults its own + // config directly (it never receives an af_server_info for itself). Single player has no + // server, so it is always allowed here (still subject to the local toggle / valid selection). + if (!single_player) { + if (rf::is_server) { + if (!server_sprays_enabled()) { + spray_chat_feedback("Sprays are disabled on this server"); + return; + } + } + else { + const auto& server_info = get_af_server_info(); + if (!server_info.has_value() || !server_info->allow_sprays) { + spray_chat_feedback("This server does not allow sprays"); + return; + } + } + } + + const uint16_t spray_id = static_cast(g_alpine_game_config.selected_spray_index); + if (!is_valid_spray_id(spray_id)) { + return; + } + + // Local re-request throttle to avoid packet spam; the real cooldown is server-side. + static std::optional last_request_ms; + const int64_t now_ms = timer::get_i64(1000); + if (last_request_ms && now_ms - *last_request_ms < 1000) { + return; + } + + rf::Player* player = rf::local_player; + if (!player || !player->cam || !rf::level.geometry) { + return; + } + + const rf::Vector3 eye = rf::camera_get_pos(player->cam); + const rf::Matrix3 orient = rf::camera_get_orient(player->cam); + + // Trace against the level's static solid geometry for a spray location. + rf::Vector3 p0 = eye; + rf::Vector3 p1 = eye + orient.fvec * 50.0f; // server enforces the real max distance + rf::GCollisionOutput out; + const bool hit = rf::collide_linesegment_level_solid(p0, p1, 0, &out); + if (!hit || out.num_hits == 0) { + xlog::debug("sprays: raycast hit no level geometry within 50m"); + rf::console::print("No surface in view to spray"); + return; + } + + last_request_ms = now_ms; + xlog::debug("sprays: sending spray request (id={}, hit=({:.2f}, {:.2f}, {:.2f}), normal=({:.2f}, {:.2f}, {:.2f}), dist={:.2f})", + spray_id, out.hit_point.x, out.hit_point.y, out.hit_point.z, + out.normal.x, out.normal.y, out.normal.z, eye.distance_to(out.hit_point)); + + // Single player: no server and no networking, so just place the spray locally for yourself. + // A networked client sends the request to the server; the listen-server host has no separate + // server to receive a packet, so it runs the server-side handler directly in-process. + if (single_player) { + sprays_apply_client_state(kSinglePlayerSprayerId, spray_id, out.hit_point, out.normal, true); + } + else if (rf::is_server) { + sprays_handle_spray_request(rf::local_player, spray_id, out.hit_point, out.normal); + } + else { + af_send_spray_request(spray_id, out.hit_point, out.normal); + } +} diff --git a/game_patch/multi/sprays.h b/game_patch/multi/sprays.h new file mode 100644 index 000000000..36bfb42be --- /dev/null +++ b/game_patch/multi/sprays.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace rf +{ + struct Player; + struct Vector3; +} + +int spray_count(); +bool is_valid_spray_id(uint16_t spray_id); +const char* spray_texture_name(uint16_t spray_id); +int spray_get_bitmap(uint16_t id); +void sprays_do_patch(); +void sprays_level_init(); +void sprays_on_player_destroyed(rf::Player* player); +void sprays_force_state_sync_to(rf::Player* player); +void sprays_handle_spray_action(); +void sprays_handle_spray_request(rf::Player* player, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal); +void sprays_apply_client_state(uint8_t player_id, uint16_t texture_id, const rf::Vector3& pos, const rf::Vector3& normal, bool play_sound); +void sprays_apply_display_toggle(); diff --git a/game_patch/rf/geometry.h b/game_patch/rf/geometry.h index 93a7ab32d..6cb423ca8 100644 --- a/game_patch/rf/geometry.h +++ b/game_patch/rf/geometry.h @@ -620,6 +620,7 @@ namespace rf // Geomod effect functions static auto& geomod_push_nearby_entities = addr_as_ref(0x004C0160); static auto& g_decal_add = addr_as_ref(0x004D52E0); + static auto& g_decal_destroy = addr_as_ref(0x004D6C50); static auto& geomod_create_rock_debris = addr_as_ref(0x0048FE30); diff --git a/game_patch/rf/player/control_config.h b/game_patch/rf/player/control_config.h index b0bb3254e..d1d841b55 100644 --- a/game_patch/rf/player/control_config.h +++ b/game_patch/rf/player/control_config.h @@ -55,7 +55,8 @@ namespace rf AF_ACTION_INSPECT_WEAPON = 0xE, AF_ACTION_SPECTATE_TOGGLE_FREELOOK = 0xF, AF_ACTION_SPECTATE_TOGGLE = 0x10, - _AF_ACTION_LAST_VARIANT = AF_ACTION_SPECTATE_TOGGLE + AF_ACTION_SPRAY = 0x11, + _AF_ACTION_LAST_VARIANT = AF_ACTION_SPRAY }; struct ControlConfigItem diff --git a/game_patch/rf/player/player.h b/game_patch/rf/player/player.h index 6d8f0f0ff..ee202d4a4 100644 --- a/game_patch/rf/player/player.h +++ b/game_patch/rf/player/player.h @@ -64,6 +64,7 @@ struct PlayerAdditionalData { std::optional last_hit_sound_ms{}; std::optional last_critical_sound_ms{}; + std::optional last_spray_ms{}; struct { std::map saves{}; diff --git a/game_patch/sound/sound.cpp b/game_patch/sound/sound.cpp index 72ebf8a9d..465f20227 100644 --- a/game_patch/sound/sound.cpp +++ b/game_patch/sound/sound.cpp @@ -21,6 +21,7 @@ static int g_cutscene_bg_sound_sig = -1; static int g_custom_sound_entry_start = -1; static int g_taunt_sound_start = -1; static int g_radmsg_sound_start = -1; +static int g_spray_sound_id = -1; #ifdef DEBUG int g_sound_test = 0; #endif @@ -491,6 +492,11 @@ int get_custom_chat_message_sound_id(int custom_id, bool is_taunt) return is_taunt ? g_taunt_sound_start + custom_id : g_radmsg_sound_start + custom_id; } +int get_spray_sound_id() +{ + return g_spray_sound_id; +} + void gamesound_parse_custom_sounds() { // Record first custom sound ID @@ -614,6 +620,7 @@ void gamesound_parse_custom_sounds() {"MP_TAUNT_72.wav", 10.0f, 1.0f, 1.0f}, {"MP_TAUNT_73.wav", 10.0f, 1.0f, 1.0f}, {"MP_TAUNT_74.wav", 10.0f, 1.0f, 1.0f}, + {"af_spray1.ogg", 10.0f, 1.0f, 1.0f}, }; for (const auto& sound : custom_sounds) @@ -628,6 +635,7 @@ void gamesound_parse_custom_sounds() g_taunt_sound_start = rf::snd_pc_find_by_name("MP_TAUNT_16.wav"); g_radmsg_sound_start = rf::snd_pc_find_by_name("af_radmsg_000.ogg"); + g_spray_sound_id = rf::snd_pc_find_by_name("af_spray1.ogg"); //xlog::warn("Custom sounds added, starting at ID {}. Taunts start at ID {}", g_custom_sound_entry_start, g_taunt_sound_start); } diff --git a/game_patch/sound/sound.h b/game_patch/sound/sound.h index c886ea497..ec6eae2b5 100644 --- a/game_patch/sound/sound.h +++ b/game_patch/sound/sound.h @@ -19,6 +19,7 @@ void enable_sound_after_cutscene_skip(); void set_sound_enabled(bool enabled); int get_custom_sound_id(int custom_id); bool is_valid_custom_sound_id(int custom_id); +int get_spray_sound_id(); void play_local_sound_2d(uint16_t sound_id, int group, float volume); void play_local_sound_3d(uint16_t sound_id, rf::Vector3 pos, int group, float volume); void play_chat_sound(std::string_view msg, bool is_taunt); diff --git a/resources/CMakeLists.txt b/resources/CMakeLists.txt index 552d06145..df6b50e4d 100644 --- a/resources/CMakeLists.txt +++ b/resources/CMakeLists.txt @@ -89,6 +89,7 @@ add_packfile(alpinefaction.vpp sounds/af_achievement1.wav sounds/af_pinglocation1.wav sounds/af_hitsound1.wav + sounds/af_spray1.ogg sounds/af_killsound1.wav sounds/af_radmsg_000.ogg sounds/af_radmsg_001.ogg @@ -250,6 +251,16 @@ add_packfile(alpinefaction.vpp images/af_powerup-bag.vbm images/af_wh_bag_hold.tga images/af_wh_bag_take.tga + images/dec_af_csp01.png + images/dec_af_csp02.png + images/dec_af_csp03.png + images/dec_af_csp04.png + images/dec_af_csp05.png + images/dec_af_csp06.tga + images/dec_af_csp07.png + images/dec_af_csp08.png + images/dec_af_csp09.tga + images/dec_af_csp10.tga fonts/biggerfont.vf fonts/regularfont.ttf diff --git a/resources/images/dec_af_csp01.png b/resources/images/dec_af_csp01.png new file mode 100644 index 000000000..5cd22adec Binary files /dev/null and b/resources/images/dec_af_csp01.png differ diff --git a/resources/images/dec_af_csp02.png b/resources/images/dec_af_csp02.png new file mode 100644 index 000000000..eaa4f5a22 Binary files /dev/null and b/resources/images/dec_af_csp02.png differ diff --git a/resources/images/dec_af_csp03.png b/resources/images/dec_af_csp03.png new file mode 100644 index 000000000..8e46b4383 Binary files /dev/null and b/resources/images/dec_af_csp03.png differ diff --git a/resources/images/dec_af_csp04.png b/resources/images/dec_af_csp04.png new file mode 100644 index 000000000..b17cb94e3 Binary files /dev/null and b/resources/images/dec_af_csp04.png differ diff --git a/resources/images/dec_af_csp05.png b/resources/images/dec_af_csp05.png new file mode 100644 index 000000000..06599131e Binary files /dev/null and b/resources/images/dec_af_csp05.png differ diff --git a/resources/images/dec_af_csp06.tga b/resources/images/dec_af_csp06.tga new file mode 100644 index 000000000..8694845f4 Binary files /dev/null and b/resources/images/dec_af_csp06.tga differ diff --git a/resources/images/dec_af_csp07.png b/resources/images/dec_af_csp07.png new file mode 100644 index 000000000..aa3015c30 Binary files /dev/null and b/resources/images/dec_af_csp07.png differ diff --git a/resources/images/dec_af_csp08.png b/resources/images/dec_af_csp08.png new file mode 100644 index 000000000..ee45ae6c1 Binary files /dev/null and b/resources/images/dec_af_csp08.png differ diff --git a/resources/images/dec_af_csp09.tga b/resources/images/dec_af_csp09.tga new file mode 100644 index 000000000..f212488da Binary files /dev/null and b/resources/images/dec_af_csp09.tga differ diff --git a/resources/images/dec_af_csp10.tga b/resources/images/dec_af_csp10.tga new file mode 100644 index 000000000..2c9fa123a Binary files /dev/null and b/resources/images/dec_af_csp10.tga differ diff --git a/resources/licensing-info.txt b/resources/licensing-info.txt index ca164937d..4adadbf75 100644 --- a/resources/licensing-info.txt +++ b/resources/licensing-info.txt @@ -89,6 +89,9 @@ Rob_Marion: soneproject: * af_killsound1.wav (CC-BY 3.0: https://freesound.org/s/346425/) +theplax: +* af_spray1.ogg (CC-BY 4.0: https://freesound.org/s/657793/) + ############################################################################### ## stb_image, stb_vorbis diff --git a/resources/sounds/af_spray1.ogg b/resources/sounds/af_spray1.ogg new file mode 100644 index 000000000..f6475f31e Binary files /dev/null and b/resources/sounds/af_spray1.ogg differ