diff --git a/editor_patch/level.cpp b/editor_patch/level.cpp index 829739276..a4968876d 100644 --- a/editor_patch/level.cpp +++ b/editor_patch/level.cpp @@ -619,6 +619,12 @@ CodeInjection CLevelDialog_OnInitDialog_patch{ std::snprintf(buffer, sizeof(buffer), "%.3f", alpine_level_props.static_mesh_ambient_light_modifier); SetDlgItemTextA(hdlg, IDC_MESH_AMBIENT_LIGHT_MODIFIER, buffer); CheckDlgButton(hdlg, IDC_RF2_STYLE_GEOMOD, alpine_level_props.rf2_style_geomod ? BST_CHECKED : BST_UNCHECKED); + + // Populate perspective dropdown + HWND combo = GetDlgItem(hdlg, IDC_PERSPECTIVE_COMBO); + SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("First person")); + SendMessageA(combo, CB_ADDSTRING, 0, reinterpret_cast("Side-scroller")); + SendMessageA(combo, CB_SETCURSEL, alpine_level_props.perspective, 0); }, }; @@ -643,6 +649,11 @@ CodeInjection CLevelDialog_OnOK_patch{ alpine_level_props.static_mesh_ambient_light_modifier = modifier; } alpine_level_props.rf2_style_geomod = IsDlgButtonChecked(hdlg, IDC_RF2_STYLE_GEOMOD) == BST_CHECKED; + + int sel = static_cast(SendDlgItemMessageA(hdlg, IDC_PERSPECTIVE_COMBO, CB_GETCURSEL, 0, 0)); + if (sel != CB_ERR) { + alpine_level_props.perspective = static_cast(sel); + } }, }; diff --git a/editor_patch/level.h b/editor_patch/level.h index f859dd6d9..6f7397f70 100644 --- a/editor_patch/level.h +++ b/editor_patch/level.h @@ -349,6 +349,8 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; + uint8_t perspective = 0; // 0 = First person, 1 = Side-scroller + // v4 (continued - vectors kept after scalars for clean versioning) std::vector geoable_brush_uids; std::vector geoable_room_uids; // computed at save time, parallel to geoable_brush_uids std::vector breakable_brush_uids; @@ -377,6 +379,7 @@ struct AlpineLevelProperties override_static_mesh_ambient_light_modifier = false; static_mesh_ambient_light_modifier = 2.0f; rf2_style_geomod = false; + perspective = 0; geoable_brush_uids.clear(); geoable_room_uids.clear(); breakable_brush_uids.clear(); @@ -433,6 +436,7 @@ struct AlpineLevelProperties uint8_t mat = (i < breakable_materials.size()) ? breakable_materials[i] : 0; file.write(mat); } + file.write(perspective); } void Deserialize(rf::File& file, std::size_t chunk_len) @@ -545,6 +549,15 @@ struct AlpineLevelProperties breakable_materials[i] = mat; } } + + // perspective appended to v4 + if (version >= 4) { + std::uint8_t u8 = 0; + if (!read_bytes(&u8, sizeof(u8))) + return; + perspective = u8; + xlog::debug("[AlpineLevelProps] perspective {}", perspective); + } } }; diff --git a/editor_patch/resources.h b/editor_patch/resources.h index 090986dff..8f2ac548f 100644 --- a/editor_patch/resources.h +++ b/editor_patch/resources.h @@ -25,6 +25,8 @@ #define IDC_MATERIAL_COMBO 2008 #define IDC_MATERIAL_LABEL 2009 #define IDC_NO_DEBRIS 2010 +#define IDC_PERSPECTIVE_LABEL 2011 +#define IDC_PERSPECTIVE_COMBO 2012 #define IDD_BRUSH_MODE_PANEL 205 #define IDD_BRUSH_PROPERTIES 270 diff --git a/editor_patch/resources.rc b/editor_patch/resources.rc index b37b2e12b..e9edb23c4 100644 --- a/editor_patch/resources.rc +++ b/editor_patch/resources.rc @@ -100,7 +100,7 @@ FONT 8, "MS Sans Serif", 0, 0, 1 } LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT -IDD_LEVEL_PROPERTIES DIALOGEX 0, 0, 298, 362 +IDD_LEVEL_PROPERTIES DIALOGEX 0, 0, 298, 382 STYLE DS_MODALFRAME | DS_SETFONT | WS_CAPTION | WS_VISIBLE | WS_POPUP | WS_SYSMENU CAPTION "Level Properties" FONT 8, "MS Sans Serif" @@ -144,7 +144,7 @@ FONT 8, "MS Sans Serif" DEFPUSHBUTTON "OK", IDOK, 241, 7, 50, 14 PUSHBUTTON "Cancel", IDCANCEL, 241, 24, 50, 14 - GROUPBOX "Advanced Options", -1, 7, 268, 284, 90 + GROUPBOX "Advanced Options", -1, 7, 268, 284, 110 AUTOCHECKBOX "Legacy Cyclic_Timer events", IDC_LEGACY_CYCLIC_TIMERS, 13, 280, 175, 10 AUTOCHECKBOX "Legacy movers", IDC_LEGACY_MOVERS, 13, 292, 175, 10 AUTOCHECKBOX "Player starts with headlamp", IDC_STARTS_WITH_HEADLAMP, 13, 304, 175, 10 @@ -152,6 +152,8 @@ FONT 8, "MS Sans Serif" LTEXT "New scale value:", -1, 30, 331, 120, 8 EDITTEXT IDC_MESH_AMBIENT_LIGHT_MODIFIER, 90, 328, 30, 14, ES_AUTOHSCROLL AUTOCHECKBOX "Brush-based geomod (RF2-style)", IDC_RF2_STYLE_GEOMOD, 13, 344, 200, 10 + LTEXT "Perspective:", IDC_PERSPECTIVE_LABEL, 13, 360, 45, 8 + COMBOBOX IDC_PERSPECTIVE_COMBO, 60, 358, 100, 60, WS_TABSTOP | CBS_DROPDOWNLIST } LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 81360f159..5c698a2c0 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -195,6 +195,8 @@ set(SRCS misc/destruction.cpp misc/destruction.h misc/camera.cpp + misc/side_scroller.cpp + misc/side_scroller.h misc/ui.cpp misc/game.cpp misc/level.cpp diff --git a/game_patch/graphics/d3d11/gr_d3d11_context.cpp b/game_patch/graphics/d3d11/gr_d3d11_context.cpp index 4fbe18319..c60b3f6fe 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_context.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_context.cpp @@ -2,6 +2,7 @@ #include #include "../../rf/gr/gr_light.h" #include "../../rf/os/frametime.h" +#include "../../misc/side_scroller.h" #include "gr_d3d11.h" #include "gr_d3d11_context.h" #include "gr_d3d11_texture.h" @@ -251,6 +252,16 @@ namespace df::gr::d3d11 float disable_textures; std::array fog_color; float pad0; + // Side-scroller occlusion (dithered transparency) + float ss_fade_strength; + float ss_radius; + float ss_is_detail; + float ss_num_entities; + // Each entity position stored as float4 (xyz + pad) for HLSL array packing + std::array ss_entity_pos[max_ss_occlusion_entities]; + // Camera position for computing entity-to-camera cylinder axis + std::array ss_camera_pos; + float pad1; }; static_assert(sizeof(RenderModeBufferData) % 16 == 0); @@ -327,6 +338,23 @@ namespace df::gr::d3d11 data.disable_textures = current_lightmap_only_ ? 1.0f : 0.0f; data.pad0 = 0.0f; + const auto& ss = get_ss_occlusion_params(); + if (ss.active) { + data.ss_fade_strength = ss.fade_strength; + data.ss_radius = ss.radius; + data.ss_num_entities = static_cast(ss.num_entities); + for (int i = 0; i < ss.num_entities; ++i) { + data.ss_entity_pos[i] = {ss.entity_pos[i].x, ss.entity_pos[i].y, ss.entity_pos[i].z, 0.0f}; + } + data.ss_camera_pos = {ss.camera_pos.x, ss.camera_pos.y, ss.camera_pos.z}; + } + else { + data.ss_fade_strength = 0.0f; + data.ss_radius = 0.0f; + data.ss_num_entities = 0.0f; + } + data.ss_is_detail = current_ss_is_detail_ ? 1.0f : 0.0f; + D3D11_MAPPED_SUBRESOURCE mapped_subres; DF_GR_D3D11_CHECK_HR( device_context->Map(buffer_, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped_subres) diff --git a/game_patch/graphics/d3d11/gr_d3d11_context.h b/game_patch/graphics/d3d11/gr_d3d11_context.h index 8d4169dd8..a79cfe2e2 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_context.h +++ b/game_patch/graphics/d3d11/gr_d3d11_context.h @@ -9,6 +9,7 @@ #include "gr_d3d11_texture.h" #include "gr_d3d11_state.h" #include "../../misc/alpine_settings.h" +#include "../../misc/side_scroller.h" #include "../../rf/gr/gr.h" namespace df::gr::d3d11 @@ -86,7 +87,8 @@ namespace df::gr::d3d11 bool alpha_test = mode.get_zbuffer_type() == gr::ZBUFFER_TYPE_FULL_ALPHA_TEST; bool fog_allowed = mode.get_fog_type() != gr::FOG_NOT_ALLOWED; int colorblind_mode = g_alpine_game_config.colorblind_mode; - if (force_update_ || current_alpha_test_ != alpha_test || current_fog_allowed_ != fog_allowed || current_color_ != color || current_colorblind_mode_ != colorblind_mode || current_lightmap_only_ != lightmap_only) { + bool ss_active = get_ss_occlusion_params().active; + if (force_update_ || ss_active || current_alpha_test_ != alpha_test || current_fog_allowed_ != fog_allowed || current_color_ != color || current_colorblind_mode_ != colorblind_mode || current_lightmap_only_ != lightmap_only) { current_alpha_test_ = alpha_test; current_fog_allowed_ = fog_allowed; current_color_ = color; @@ -109,6 +111,14 @@ namespace df::gr::d3d11 } } + void set_ss_is_detail(bool is_detail) + { + if (current_ss_is_detail_ != is_detail) { + current_ss_is_detail_ = is_detail; + force_update_ = true; + } + } + private: void update_buffer(ID3D11DeviceContext* device_context); @@ -119,6 +129,7 @@ namespace df::gr::d3d11 rf::Color current_color_{255, 255, 255}; int current_colorblind_mode_ = 0; bool current_lightmap_only_ = false; + bool current_ss_is_detail_ = false; }; class PerFrameBuffer @@ -333,6 +344,11 @@ namespace df::gr::d3d11 per_frame_buffer_.update(device_context_); } + void set_ss_is_detail(bool is_detail) + { + render_mode_cbuffer_.set_ss_is_detail(is_detail); + } + void fog_set() { render_mode_cbuffer_.handle_fog_change(); diff --git a/game_patch/graphics/d3d11/gr_d3d11_solid.cpp b/game_patch/graphics/d3d11/gr_d3d11_solid.cpp index b4ac0c306..7673142c3 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_solid.cpp +++ b/game_patch/graphics/d3d11/gr_d3d11_solid.cpp @@ -2,6 +2,7 @@ #undef NDEBUG #include +#include #include #include #include @@ -254,6 +255,8 @@ namespace df::gr::d3d11 void add_face(GFace* face, GSolid* solid); GRenderCache build(ID3D11Device* device); + void set_is_sky(bool is_sky) { is_sky_ = is_sky; } + int get_num_verts() const { return num_verts_; @@ -305,18 +308,10 @@ namespace df::gr::d3d11 for (GFace& face : room->face_list) { add_face(&face, solid); } - // Only iterate detail_rooms for non-detail rooms. Detail rooms should never - // have sub-detail rooms in Red Faction. After RF2-style geomod, the boolean - // engine may corrupt the detail_rooms VArray on detail rooms, causing infinite - // recursion if we iterate it unconditionally. - if (!room->is_detail) { - for (GRoom* detail_room : room->detail_rooms) { - if (detail_room->face_list.empty()) { - continue; // skip destroyed breakable detail rooms - } - add_room(detail_room, solid); - } - } + // Detail rooms are rendered separately via render_detail() which sets the + // ss_is_detail flag for side-scroller dithered transparency. Including them + // here would cause double-rendering and break that effect. Sky rooms also + // render their detail rooms explicitly in render_sky_room(). } static inline FaceRenderType determine_face_render_type(GFace* face) @@ -746,12 +741,14 @@ namespace df::gr::d3d11 return cache; } - void SolidRenderer::render_detail(rf::GSolid* solid, GRoom* room, bool alpha) + void SolidRenderer::render_detail(rf::GSolid* solid, GRoom* room, bool alpha, bool is_sky) { - GRenderCache* cache = get_or_create_detail_room_cache(solid, room); + GRenderCache* cache = get_or_create_detail_room_cache(solid, room, is_sky); if (!cache) return; FaceRenderType render_type = alpha ? FaceRenderType::alpha : FaceRenderType::opaque; + render_context_.set_ss_is_detail(true); cache->render(render_type, render_context_); + render_context_.set_ss_is_detail(false); } // Sentinel value stored in room->geo_cache to mark detail rooms that have been checked @@ -759,16 +756,41 @@ namespace df::gr::d3d11 // clear_cache() resets all geo_cache to nullptr, which properly clears this sentinel. static const auto k_empty_detail_sentinel = reinterpret_cast(uintptr_t(1)); - GRenderCache* SolidRenderer::get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room) + GRenderCache* SolidRenderer::get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room, bool is_sky) { + auto cache = reinterpret_cast(room->geo_cache); + + // Check if existing cache needs invalidation due to sky mode mismatch + // (e.g. pre-cached as normal but now needed for a dynamic sky room set via Set_Skybox) + bool cached_as_sky = sky_detail_rooms_.count(room) > 0; + if (is_sky != cached_as_sky && (cache || room->geo_cache == k_empty_detail_sentinel)) { + if (cache) { + // Remove the old cache object from detail_render_cache_ + auto it = std::find_if(detail_render_cache_.begin(), detail_render_cache_.end(), + [cache](const auto& ptr) { return ptr.get() == cache; }); + if (it != detail_render_cache_.end()) { + detail_render_cache_.erase(it); + } + } + room->geo_cache = nullptr; + cache = nullptr; + } + if (room->geo_cache == k_empty_detail_sentinel) { return nullptr; } - auto cache = reinterpret_cast(room->geo_cache); + if (!cache) { - xlog::debug("Creating render cache for detail room {} (faces: {})", - room->room_index, room->face_list.size()); + xlog::debug("Creating render cache for detail room {} (faces: {} sky: {})", + room->room_index, room->face_list.size(), is_sky); GRenderCacheBuilder builder; + if (is_sky) { + builder.set_is_sky(true); + sky_detail_rooms_.insert(room); + } + else { + sky_detail_rooms_.erase(room); + } builder.add_room(room, solid); xlog::debug("Detail room {} builder: verts={} inds={} batches={}", room->room_index, builder.get_num_verts(), builder.get_num_inds(), builder.get_num_batches()); @@ -800,6 +822,7 @@ namespace df::gr::d3d11 detail_render_cache_.clear(); mover_render_cache_.clear(); geo_cache_rooms_.clear(); + sky_detail_rooms_.clear(); geo_cache_num_rooms = 0; xlog::debug("Room render cache clear complete"); } @@ -830,7 +853,24 @@ namespace df::gr::d3d11 before_render(sky_room_offset, rf::identity_matrix); } render_room_faces(rf::level.geometry, room, FaceRenderType::opaque); + + // Render detail rooms without frustum culling — skybox geometry is rendered + // with an offset/rotation so camera-based bounding box checks don't apply. + // All opaque detail faces must be drawn before any alpha detail faces to + // ensure correct blending when detail rooms overlap. + for (GRoom* detail_room : room->detail_rooms) { + if (!detail_room->face_list.empty()) { + render_detail(rf::level.geometry, detail_room, false, true); + } + } + render_room_faces(rf::level.geometry, room, FaceRenderType::alpha); + + for (GRoom* detail_room : room->detail_rooms) { + if (!detail_room->face_list.empty()) { + render_detail(rf::level.geometry, detail_room, true, true); + } + } render_context_.update_lights(); } @@ -839,8 +879,10 @@ namespace df::gr::d3d11 xlog::trace("Rendering movable solid {}", solid); GRenderCache* cache = get_or_create_movable_solid_cache(solid); before_render(pos, orient); + render_context_.set_ss_is_detail(true); cache->render(FaceRenderType::opaque, render_context_); cache->render(FaceRenderType::alpha, render_context_); + render_context_.set_ss_is_detail(false); if (decals_enabled) { render_movable_solid_dynamic_decals(solid, pos, orient); } @@ -931,6 +973,8 @@ namespace df::gr::d3d11 } } for (rf::GRoom* room: solid->cached_detail_room_list) { + // Pre-cache as non-sky; if later rendered in a sky room context, + // get_or_create_detail_room_cache will detect the mismatch and rebuild. get_or_create_detail_room_cache(solid, room); } } diff --git a/game_patch/graphics/d3d11/gr_d3d11_solid.h b/game_patch/graphics/d3d11/gr_d3d11_solid.h index a56d01609..d0f9f9b42 100644 --- a/game_patch/graphics/d3d11/gr_d3d11_solid.h +++ b/game_patch/graphics/d3d11/gr_d3d11_solid.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include "gr_d3d11_shader.h" @@ -47,14 +48,14 @@ namespace df::gr::d3d11 void before_render(const rf::Vector3& pos, const rf::Matrix3& orient); void after_render(); void render_room_faces(rf::GSolid* solid, rf::GRoom* room, FaceRenderType render_type); - void render_detail(rf::GSolid* solid, rf::GRoom* room, bool alpha); + void render_detail(rf::GSolid* solid, rf::GRoom* room, bool alpha, bool is_sky = false); void render_dynamic_decals(rf::GRoom** rooms, int num_rooms); void render_alpha_detail_dynamic_decals(rf::GRoom* detail_room); void render_movable_solid_dynamic_decals(rf::GSolid* solid, const rf::Vector3& pos, const rf::Matrix3& orient); void before_render_decals(); void after_render_decals(); RoomRenderCache* get_or_create_normal_room_cache(rf::GSolid* solid, rf::GRoom* room); - GRenderCache* get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room); + GRenderCache* get_or_create_detail_room_cache(rf::GSolid* solid, rf::GRoom* room, bool is_sky = false); GRenderCache* get_or_create_movable_solid_cache(rf::GSolid* solid); ComPtr device_; @@ -67,5 +68,6 @@ namespace df::gr::d3d11 std::vector> detail_render_cache_; std::unordered_map> mover_render_cache_; std::vector geo_cache_rooms_; + std::unordered_set sky_detail_rooms_; }; } diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index 1467f7180..162f56da4 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -274,6 +274,7 @@ FunHook gameplay_render_frame_hook{ } multi_spectate_sync_crouch_anim(); + gameplay_render_frame_hook.call_target(pp, flags); }, }; diff --git a/game_patch/hud/hud_weapons.cpp b/game_patch/hud/hud_weapons.cpp index ed3a83ab3..68859b5e6 100644 --- a/game_patch/hud/hud_weapons.cpp +++ b/game_patch/hud/hud_weapons.cpp @@ -14,6 +14,7 @@ #include "../misc/alpine_settings.h" #include "../misc/misc.h" #include "hud_internal.h" +#include "../misc/side_scroller.h" float g_hud_ammo_scale = 1.0f; bool g_displaying_custom_reticle = false; @@ -52,6 +53,13 @@ CallHook render_reticle_gr_bitmap_hook{ x = static_cast((x - clip_w / 2.0F) * scale + clip_w / 2.0F); y = static_cast((y - clip_h / 2.0F) * scale + clip_h / 2.0F); + // In side-scroller mode, offset reticle to the cursor position + int reticle_off_x = 0, reticle_off_y = 0; + if (side_scroller_get_reticle_offset(reticle_off_x, reticle_off_y)) { + x += reticle_off_x; + y += reticle_off_y; + } + hud_scaled_bitmap(bm_handle, x, y, scale, mode); }, }; diff --git a/game_patch/misc/camera.cpp b/game_patch/misc/camera.cpp index 72fdcf736..8ba5f6311 100644 --- a/game_patch/misc/camera.cpp +++ b/game_patch/misc/camera.cpp @@ -16,6 +16,7 @@ #include "../rf/os/frametime.h" #include "player.h" #include "../hud/multi_spectate.h" +#include "side_scroller.h" constexpr auto screen_shake_fps = 150.0f; static float g_camera_shake_factor = 0.6f; @@ -85,6 +86,12 @@ CallHook camera_enter_first_person_level_post{ 0x004A43AB }, [](rf::Camera* camera) { + // Side-scroller mode always uses third-person camera + if (is_side_scroller_mode()) { + rf::camera_enter_third_person(camera); + return; + } + const bool default_third_person = g_alpine_options_config.is_option_loaded(AlpineOptionID::DefaultThirdPerson) && std::get(g_alpine_options_config.options.at(AlpineOptionID::DefaultThirdPerson)); diff --git a/game_patch/misc/level.cpp b/game_patch/misc/level.cpp index b142ada5c..c55b011ee 100644 --- a/game_patch/misc/level.cpp +++ b/game_patch/misc/level.cpp @@ -12,6 +12,7 @@ #include "level.h" #include "misc.h" #include "player.h" +#include "side_scroller.h" #include "../multi/server.h" #include "../object/alpine_corona.h" @@ -105,6 +106,7 @@ CodeInjection level_load_init_patch{ alpine_mesh_clear_state(); alpine_corona_clear_state(); set_headlamp_toggle_enabled(AlpineLevelProperties::instance().starts_with_headlamp); + side_scroller_on_level_load(); }, }; diff --git a/game_patch/misc/level.h b/game_patch/misc/level.h index 354cae10f..6e3f99010 100644 --- a/game_patch/misc/level.h +++ b/game_patch/misc/level.h @@ -27,6 +27,7 @@ struct AlpineLevelProperties float static_mesh_ambient_light_modifier = 2.0f; // v4 bool rf2_style_geomod = false; + uint8_t perspective = 0; // 0 = First person, 1 = Side-scroller // std::vector geoable_brush_uids; // unnecessary in game std::vector geoable_room_uids; // std::vector breakable_brush_uids; // unnecessary in game @@ -152,6 +153,15 @@ struct AlpineLevelProperties } xlog::trace("[AlpineLevelProps] GAME: total breakable entries loaded={}", bcount); } + + // perspective appended to v4 + if (version >= 4) { + std::uint8_t u8 = 0; + if (!read_bytes(&u8, sizeof(u8))) + return; + perspective = u8; + xlog::debug("[AlpineLevelProps] perspective {}", perspective); + } } }; diff --git a/game_patch/misc/misc.cpp b/game_patch/misc/misc.cpp index 863072ab3..a357d1e86 100644 --- a/game_patch/misc/misc.cpp +++ b/game_patch/misc/misc.cpp @@ -44,6 +44,7 @@ void player_fpgun_do_patch(); void g_solid_do_patch(); void destruction_do_patch(); void camera_do_patch(); +void side_scroller_do_patch(); void ui_apply_patch(); void game_apply_patch(); void character_apply_patch(); @@ -666,6 +667,7 @@ void misc_init() destruction_do_patch(); register_sound_commands(); camera_do_patch(); + side_scroller_do_patch(); ui_apply_patch(); game_apply_patch(); character_apply_patch(); diff --git a/game_patch/misc/side_scroller.cpp b/game_patch/misc/side_scroller.cpp new file mode 100644 index 000000000..28f5fa59d --- /dev/null +++ b/game_patch/misc/side_scroller.cpp @@ -0,0 +1,634 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "side_scroller.h" +#include "level.h" +#include "../rf/player/player.h" +#include "../rf/player/camera.h" +#include "../rf/entity.h" +#include "../rf/input.h" +#include "../rf/gr/gr.h" +#include "../rf/os/frametime.h" +#include "../rf/vmesh.h" +#include "../rf/weapon.h" + +// Side-scroller camera orientation (fixed): +// Camera is at player.x + offset, looking in -X direction +// rvec = (0, 0, 1) -> screen right is world +Z +// uvec = (0, 1, 0) -> screen up is world +Y +// fvec = (-1, 0, 0) -> camera looks in world -X +static constexpr float camera_offset_x = 12.0f; + +static const rf::Matrix3 g_side_scroller_orient = { + {0.0f, 0.0f, 1.0f}, // rvec: right is +Z + {0.0f, 1.0f, 0.0f}, // uvec: up is +Y + {-1.0f, 0.0f, 0.0f}, // fvec: forward is -X +}; + +// Cached side-scroller camera position (updated each frame, read by getter hooks) +static rf::Vector3 g_ss_camera_pos = {0.0f, 0.0f, 0.0f}; +static bool g_ss_camera_active = false; + +// Reticle position in screen pixels (from top-left) +static float g_reticle_x = 0.0f; +static float g_reticle_y = 0.0f; +static bool g_reticle_initialized = false; + +// Mouse deltas captured before stock processing +static int g_stolen_mouse_dx = 0; +static int g_stolen_mouse_dy = 0; + +// Raw mouse delta globals (mouse_get_delta only copies, doesn't clear) +static auto& g_mouse_dx = addr_as_ref(0x01885464); +static auto& g_mouse_dy = addr_as_ref(0x01885468); + +// True when reticle is behind the player (left of screen center), flips body to face -Z +static bool g_ss_aiming_backward = false; + +// X-axis lock: player is always locked to X=0 in side-scroller maps +static constexpr float g_ss_locked_x = 0.0f; + +// Debug: aim line from fire position along aim direction (red = apply_side_scroller_aim) +static rf::Vector3 g_dbg_aim_start = {0, 0, 0}; +static rf::Vector3 g_dbg_aim_end = {0, 0, 0}; +static bool g_dbg_aim_valid = false; + +// Debug: fire line from actual fire pos hook output (green = entity_calc_fire_pos_hook) +static rf::Vector3 g_dbg_fire_start = {0, 0, 0}; +static rf::Vector3 g_dbg_fire_end = {0, 0, 0}; +static bool g_dbg_fire_valid = false; + +// Cached hand position for debug line (updated in fire pos hook, read in aim function) +static rf::Vector3 g_ss_hand_pos = {0, 0, 0}; +static bool g_ss_hand_pos_valid = false; + +// Cached aim orient: computed from reticle each frame, used by fire pos hook. +// Stored separately because stock player_do_frame may overwrite entity->eye_orient +// between our pre-hook (where we set it) and weapon firing (where the hook reads it). +static rf::Matrix3 g_ss_aim_orient = {}; +static bool g_ss_aim_orient_valid = false; + +// Actual rendering FOV in degrees, captured from gr_setup_3d each frame. +// The rendering pipeline applies Hor+ scaling and user FOV settings, so this may differ +// from player->viewport.fov_h. We must use the same FOV for reticle unprojection. +static float g_ss_render_fov_h_deg = 90.0f; + +// Side-scroller occlusion: dithered transparency for geometry between camera and player +static SsOcclusionParams g_ss_occlusion = {}; +static float g_ss_occlusion_fade_current = 0.0f; // smoothly ramps toward target + +bool is_side_scroller_mode() +{ + return AlpineLevelProperties::instance().perspective == 1; +} + +const SsOcclusionParams& get_ss_occlusion_params() +{ + return g_ss_occlusion; +} + +void side_scroller_on_level_load() +{ + g_reticle_initialized = false; + g_reticle_x = 0.0f; + g_reticle_y = 0.0f; + g_stolen_mouse_dx = 0; + g_stolen_mouse_dy = 0; + g_ss_camera_active = false; + g_ss_aiming_backward = false; + g_ss_hand_pos_valid = false; + g_ss_aim_orient_valid = false; + g_ss_occlusion = {}; + g_ss_occlusion_fade_current = 0.0f; +} + +bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y) +{ + if (!is_side_scroller_mode()) { + return false; + } + int half_w = rf::gr::clip_width() / 2; + int half_h = rf::gr::clip_height() / 2; + offset_x = static_cast(g_reticle_x) - half_w; + offset_y = static_cast(g_reticle_y) - half_h; + return true; +} + +static void steal_mouse_deltas() +{ + // Read mouse deltas, then zero the globals so the stock code can't use them + // for camera rotation. mouse_get_delta only copies, it doesn't clear. + int dz = 0; + rf::mouse_get_delta(g_stolen_mouse_dx, g_stolen_mouse_dy, dz); + g_mouse_dx = 0; + g_mouse_dy = 0; +} + +static void update_reticle_from_stolen_mouse() +{ + int screen_w = rf::gr::clip_width(); + int screen_h = rf::gr::clip_height(); + + if (!g_reticle_initialized) { + g_reticle_x = static_cast(screen_w) / 2.0f; + g_reticle_y = static_cast(screen_h) / 2.0f; + g_reticle_initialized = true; + } + + // Apply mouse sensitivity from player controls + float sensitivity = 1.0f; + if (rf::local_player) { + sensitivity = rf::local_player->settings.controls.mouse_sensitivity; + } + + g_reticle_x += static_cast(g_stolen_mouse_dx) * sensitivity; + g_reticle_y += static_cast(g_stolen_mouse_dy) * sensitivity; + + // Clamp to screen bounds + g_reticle_x = std::clamp(g_reticle_x, 0.0f, static_cast(screen_w - 1)); + g_reticle_y = std::clamp(g_reticle_y, 0.0f, static_cast(screen_h - 1)); +} + +// Pre-physics: zero X dynamics so input doesn't drive X movement, +// but leave positions alone so collision detection can work naturally. +static void pre_lock_entity_x(rf::Entity* entity) +{ + entity->p_data.vel.x = 0.0f; + entity->p_data.force.x = 0.0f; +} + +// Post-physics: measure X displacement from collision response, +// convert it to a Z impulse (pushing player back out of angled surfaces), +// then snap everything to X=0. +static void post_lock_entity_x(rf::Entity* entity) +{ + // Capture how much collision pushed us in X + float dx = entity->pos.x - g_ss_locked_x; + + // Apply as Z correction: collision wanted to push us out in X, + // convert that to pushing back in Z (opposite movement direction) + float abs_dx = std::abs(dx); + if (abs_dx > 0.0001f) { + float sign = (entity->p_data.vel.z >= 0.0f) ? -1.0f : 1.0f; + entity->pos.z += sign * abs_dx; + entity->p_data.pos.z += sign * abs_dx; + entity->p_data.next_pos.z += sign * abs_dx; + } + + // Snap all X state to locked value + entity->pos.x = g_ss_locked_x; + entity->last_pos.x = g_ss_locked_x; + entity->correct_pos.x = g_ss_locked_x; + entity->p_data.pos.x = g_ss_locked_x; + entity->p_data.next_pos.x = g_ss_locked_x; + entity->p_data.vel.x = 0.0f; + entity->p_data.force.x = 0.0f; +} + +static void update_side_scroller_camera_pos(rf::Player* player) +{ + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + g_ss_camera_active = false; + return; + } + + // Compute desired camera position: offset to the right (+X) of the player, at eye height. + // Camera Y at eye height is used as the reference for reticle unprojection. + float eye_y = 0.0f; + if (entity->info) { + eye_y = entity->info->local_eye_offset.y; + } + g_ss_camera_pos.x = entity->pos.x + camera_offset_x; + g_ss_camera_pos.y = entity->pos.y + eye_y; + g_ss_camera_pos.z = entity->pos.z; + g_ss_camera_active = true; +} + +// Get the world position of the weapon muzzle in third-person. +// Two-step tag lookup: character hand tag → weapon muzzle tag (same as stock NPC code). +// Falls back to hand position if the weapon lacks a muzzle tag. +static bool get_muzzle_world_pos(rf::Entity* entity, rf::Vector3& out_pos) +{ + if (!entity->info || !entity->vmesh) return false; + int hand_tag = entity->info->hand_tags[1]; // right hand + if (hand_tag == -1) return false; + + // Step 1: character model hand tag → hand world pos/orient + rf::Matrix3 hand_orient; + rf::Vector3 hand_pos; + rf::vmesh_get_tag_world_pos(entity->vmesh, hand_tag, entity->orient, entity->pos, + hand_orient, hand_pos); + + // Step 2: weapon third-person model muzzle tag → muzzle world pos + int weapon_type = entity->ai.current_primary_weapon; + if (weapon_type >= 0 && weapon_type < rf::num_weapon_types) { + rf::WeaponInfo& wi = rf::weapon_types[weapon_type]; + if (wi.third_person_vmesh_handle && wi.third_person_muzzle_tag != -1) { + rf::Matrix3 muzzle_orient; + rf::vmesh_get_tag_world_pos(wi.third_person_vmesh_handle, wi.third_person_muzzle_tag, + hand_orient, hand_pos, muzzle_orient, out_pos); + return true; + } + } + + // Fallback: use hand position if weapon has no muzzle tag + out_pos = hand_pos; + return true; +} + +// Hook entity_calc_fire_pos (0x0041b040) to override fire origin with hand tag position. +// Stock code falls back to eye_pos for player characters since they lack primary_fire_points. +// In side-scroller mode we want projectiles to spawn from the visible weapon position. +FunHook entity_calc_fire_pos_hook{ + 0x0041b040, + [](rf::Entity* entity, int weapon_type, rf::Vector3* out_pos, rf::Matrix3* out_orient, + int fire_slot, int is_primary) { + // In side-scroller mode, stock code may have reset entity orient to +Z. + // Temporarily restore the correct body orient so that both stock fire pos + // calculation and get_muzzle_world_pos compute the muzzle on the correct side. + bool is_local_ss = false; + rf::Matrix3 saved_orient; + if (is_side_scroller_mode() && rf::local_player) { + rf::Entity* local_entity = rf::entity_from_handle(rf::local_player->entity_handle); + if (entity == local_entity) { + is_local_ss = true; + saved_orient = entity->orient; + if (g_ss_aiming_backward) { + entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } else { + entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + } + } + + entity_calc_fire_pos_hook.call_target(entity, weapon_type, out_pos, out_orient, + fire_slot, is_primary); + + if (!is_local_ss) return; + + rf::Vector3 hand_pos; + if (get_muzzle_world_pos(entity, hand_pos)) { + *out_pos = hand_pos; + g_ss_hand_pos = hand_pos; + g_ss_hand_pos_valid = true; + } + + // Restore original orient (stock code may need it intact) + entity->orient = saved_orient; + + if (g_ss_aim_orient_valid) { + *out_orient = g_ss_aim_orient; + } + + // Debug: record actual fire pos hook output for green debug line + g_dbg_fire_start = *out_pos; + g_dbg_fire_end.x = out_pos->x + out_orient->fvec.x * 50.0f; + g_dbg_fire_end.y = out_pos->y + out_orient->fvec.y * 50.0f; + g_dbg_fire_end.z = out_pos->z + out_orient->fvec.z * 50.0f; + g_dbg_fire_valid = true; + }, +}; + +static void apply_side_scroller_aim(rf::Player* player) +{ + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + return; + } + + int screen_w = rf::gr::clip_width(); + int screen_h = rf::gr::clip_height(); + + // Use the actual rendering FOV (captured from gr_setup_3d), not player->viewport.fov_h, + // because the rendering pipeline applies Hor+ scaling and user FOV settings. + constexpr float deg2rad = 3.14159265f / 180.0f; + float fov_h = g_ss_render_fov_h_deg * deg2rad; + float aspect = static_cast(screen_w) / static_cast(screen_h); + float fov_v = 2.0f * std::atan(std::tan(fov_h * 0.5f) / aspect); + + // Normalized screen coordinates: -1 to +1 from center + float half_w = static_cast(screen_w) * 0.5f; + float half_h = static_cast(screen_h) * 0.5f; + float norm_x = (g_reticle_x - half_w) / half_w; + float norm_y = (g_reticle_y - half_h) / half_h; + + // Unproject reticle to world-space point on the X=0 plane. + // Camera is at (player.x + offset, camera_y, player.z) looking in -X. + float dz = norm_x * std::tan(fov_h * 0.5f) * camera_offset_x; // screen right = +Z + float dy = -norm_y * std::tan(fov_v * 0.5f) * camera_offset_x; // screen up = +Y + + float reticle_world_y = g_ss_camera_pos.y + dy; + float reticle_world_z = g_ss_camera_pos.z + dz; + + // Get muzzle position (where projectiles actually spawn from) + rf::Vector3 muzzle_pos; + if (!get_muzzle_world_pos(entity, muzzle_pos)) { + muzzle_pos = entity->eye_pos; + } + + // Aim direction from muzzle to reticle world point. + // This ensures the debug line, bullets, and projectiles all pass through the reticle. + float muzzle_dir_y = reticle_world_y - muzzle_pos.y; + float muzzle_dir_z = reticle_world_z - muzzle_pos.z; + float muzzle_dist = std::sqrt(muzzle_dir_y * muzzle_dir_y + muzzle_dir_z * muzzle_dir_z); + + // Camera-center direction (always points outward from the player, never flips) + float cam_dir_y = dy; + float cam_dir_z = dz; + float cam_dist = std::sqrt(cam_dir_y * cam_dir_y + cam_dir_z * cam_dir_z); + + if (cam_dist < 0.001f) { + return; + } + + // Normalize both directions + float mn_y = 0.0f, mn_z = 0.0f; + if (muzzle_dist > 0.001f) { + mn_y = muzzle_dir_y / muzzle_dist; + mn_z = muzzle_dir_z / muzzle_dist; + } + float cn_y = cam_dir_y / cam_dist; + float cn_z = cam_dir_z / cam_dist; + + // Blend: when the reticle is close to the muzzle, smoothly transition from + // muzzle→reticle direction to camera-center direction to avoid abrupt jumps. + constexpr float blend_min = 1.0f; // full camera-center direction + constexpr float blend_max = 3.0f; // full muzzle→reticle direction + float t = std::clamp((muzzle_dist - blend_min) / (blend_max - blend_min), 0.0f, 1.0f); + + rf::Vector3 dir; + dir.x = 0.0f; + dir.y = mn_y * t + cn_y * (1.0f - t); + dir.z = mn_z * t + cn_z * (1.0f - t); + + float dir_len = std::sqrt(dir.y * dir.y + dir.z * dir.z); + if (dir_len < 0.001f) { + return; + } + dir.y /= dir_len; + dir.z /= dir_len; + + // Build eye_orient matrix from aim direction. + // fvec = aim direction in YZ plane + // rvec = +X or -X (mirror when aiming left to keep character upright) + // uvec = fvec × rvec + rf::Matrix3 aim_orient; + aim_orient.fvec = dir; + + if (dir.z >= 0.0f) { + aim_orient.rvec = {1.0f, 0.0f, 0.0f}; + } + else { + aim_orient.rvec = {-1.0f, 0.0f, 0.0f}; + } + + // uvec = fvec × rvec + aim_orient.uvec.x = aim_orient.fvec.y * aim_orient.rvec.z - aim_orient.fvec.z * aim_orient.rvec.y; + aim_orient.uvec.y = aim_orient.fvec.z * aim_orient.rvec.x - aim_orient.fvec.x * aim_orient.rvec.z; + aim_orient.uvec.z = aim_orient.fvec.x * aim_orient.rvec.y - aim_orient.fvec.y * aim_orient.rvec.x; + + // Set eye_orient directly (controls weapon aim direction). + // Also cache in g_ss_aim_orient so the fire pos hook has a reliable copy + // even if stock code overwrites eye_orient mid-frame. + entity->eye_orient = aim_orient; + g_ss_aim_orient = aim_orient; + g_ss_aim_orient_valid = true; + + // Debug aim line: from muzzle along aim direction (through reticle world point) + g_dbg_aim_start = muzzle_pos; + g_dbg_aim_end.x = muzzle_pos.x + dir.x * 50.0f; + g_dbg_aim_end.y = muzzle_pos.y + dir.y * 50.0f; + g_dbg_aim_end.z = muzzle_pos.z + dir.z * 50.0f; + g_dbg_aim_valid = true; + + // Zero all phb so the body stays facing +Z for movement. + entity->control_data.phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.eye_phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.delta_phb = {0.0f, 0.0f, 0.0f}; + entity->control_data.delta_eye_phb = {0.0f, 0.0f, 0.0f}; +} + +// Hook the player control input reading function (FUN_004a6060). This runs in the game loop +// right after keyboard/mouse input is read into ci, BEFORE any game tick processing. +// This is the correct place to remap controls since ci.move is consumed later during +// player_do_frame → FUN_004a77a0 (physics processing). +FunHook side_scroller_control_read_hook{ + 0x004A6060, + [](rf::Player* player) { + side_scroller_control_read_hook.call_target(player); + + if (!is_side_scroller_mode() || player != rf::local_player) return; + + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) return; + + auto& ci = entity->ai.ci; + + // A/D (strafe = ci.move.x) controls left/right movement on screen. + // Remap to ci.move.z (forward/back along body's facing direction). + // Body orient already handles direction — no negation needed. + float original_strafe = ci.move.x; // A/D + ci.move.z = original_strafe; + ci.move.x = 0.0f; // no toward/away-from-camera movement + // ci.move.y preserved (jump/crouch) + + // Suppress mouse look rotation (camera is fixed) + ci.mouse_dh = 0.0f; + ci.mouse_dp = 0.0f; + ci.rot = {0.0f, 0.0f, 0.0f}; + }, +}; + +// Hook player_do_frame to steal mouse input and remap controls each frame +FunHook side_scroller_player_do_frame_hook{ + 0x004A2700, + [](rf::Player* player) { + bool is_local_ss = is_side_scroller_mode() && player == rf::local_player; + + if (is_local_ss) { + // BEFORE stock processing: steal mouse deltas and zero the globals + // so the stock code can't use them for camera rotation. + steal_mouse_deltas(); + update_reticle_from_stolen_mouse(); + + // Determine if aiming backward (reticle left of screen center = -Z) + float half_w = static_cast(rf::gr::clip_width()) * 0.5f; + g_ss_aiming_backward = (g_reticle_x < half_w); + + // Set body orient BEFORE stock processing so movement uses correct direction. + // Facing +Z: fvec=(0,0,1), rvec=(1,0,0), uvec=(0,1,0) + // Facing -Z: fvec=(0,0,-1), rvec=(-1,0,0), uvec=(0,1,0) + rf::Entity* pre_entity = rf::entity_from_handle(player->entity_handle); + if (pre_entity) { + // Zero X dynamics so input doesn't drive X movement, + // but let collision response happen naturally + pre_lock_entity_x(pre_entity); + + if (g_ss_aiming_backward) { + pre_entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + pre_entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + pre_entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + else { + pre_entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + pre_entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + pre_entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + + // Compute fresh aim orient from current reticle BEFORE stock processing, + // so weapons firing mid-frame use the correct direction. + update_side_scroller_camera_pos(player); + apply_side_scroller_aim(player); + } + } + + side_scroller_player_do_frame_hook.call_target(player); + + if (!is_local_ss) { + return; + } + + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) { + return; + } + + // AFTER stock processing: convert X collision displacement to Z, + // then lock X to 0 + post_lock_entity_x(entity); + + // Re-set body orient BEFORE apply_side_scroller_aim so that + // get_muzzle_world_pos() computes the muzzle on the correct side + if (g_ss_aiming_backward) { + entity->orient.fvec = {0.0f, 0.0f, -1.0f}; + entity->orient.rvec = {-1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + else { + entity->orient.fvec = {0.0f, 0.0f, 1.0f}; + entity->orient.rvec = {1.0f, 0.0f, 0.0f}; + entity->orient.uvec = {0.0f, 1.0f, 0.0f}; + } + + update_side_scroller_camera_pos(player); + apply_side_scroller_aim(player); + }, +}; + +// Hook gr_setup_3d call inside gameplay_render_frame to override viewer position/orientation. +// This intercepts at the final call that sets up the D3D view matrix, bypassing all +// intermediate third-person camera adjustments that were overwriting our values. +CallHook side_scroller_gr_setup_3d_hook{ + 0x00431D14, + [](rf::Matrix3& viewer_orient, rf::Vector3& viewer_pos, float horizontal_fov, bool zbuffer_flag, bool z_scale) { + if (g_ss_camera_active && is_side_scroller_mode()) { + viewer_pos = g_ss_camera_pos; + viewer_orient = g_side_scroller_orient; + // Capture the actual rendering FOV for reticle unprojection. + // This includes Hor+ scaling and user FOV settings applied by the render pipeline. + g_ss_render_fov_h_deg = horizontal_fov; + + // Update occlusion state for dithered transparency. + // Ramp fade strength over 200ms (5.0 per second to reach 0.5 target in 100ms, + // or decay at same rate). + constexpr float fade_target = 0.5f; + constexpr float fade_speed = fade_target / 0.2f; // reach target in 200ms + float dt = rf::frametime; + if (g_ss_occlusion_fade_current < fade_target) { + g_ss_occlusion_fade_current = std::min(g_ss_occlusion_fade_current + fade_speed * dt, fade_target); + } + + // Collect positions of all alive entities for occlusion cylinders, + // prioritizing those closest to the local player + g_ss_occlusion.active = true; + g_ss_occlusion.fade_strength = g_ss_occlusion_fade_current; + g_ss_occlusion.radius = 2.5f; + g_ss_occlusion.num_entities = 0; + + rf::Entity* local_entity = rf::local_player + ? rf::entity_from_handle(rf::local_player->entity_handle) : nullptr; + + struct EntityEntry { + float dist_sq; + rf::Vector3 pos; + }; + static std::vector candidates; + candidates.clear(); + + for (auto& entity : DoublyLinkedList{rf::entity_list}) { + if (rf::entity_is_dying(&entity)) continue; + float dist_sq = 0.0f; + if (local_entity) { + float dx = entity.pos.x - local_entity->pos.x; + float dy = entity.pos.y - local_entity->pos.y; + float dz = entity.pos.z - local_entity->pos.z; + dist_sq = dx * dx + dy * dy + dz * dz; + } + candidates.push_back({dist_sq, entity.pos}); + } + + if (static_cast(candidates.size()) > max_ss_occlusion_entities) { + std::partial_sort(candidates.begin(), + candidates.begin() + max_ss_occlusion_entities, + candidates.end(), + [](const EntityEntry& a, const EntityEntry& b) { return a.dist_sq < b.dist_sq; }); + } + + int count = std::min(static_cast(candidates.size()), max_ss_occlusion_entities); + for (int i = 0; i < count; ++i) { + auto& ep = g_ss_occlusion.entity_pos[i]; + ep.x = candidates[i].pos.x; + ep.y = candidates[i].pos.y; + ep.z = candidates[i].pos.z; + } + g_ss_occlusion.num_entities = count; + g_ss_occlusion.camera_pos = {g_ss_camera_pos.x, g_ss_camera_pos.y, g_ss_camera_pos.z}; + } + else { + // Fade out when leaving side-scroller mode + if (g_ss_occlusion_fade_current > 0.0f) { + constexpr float fade_speed = 0.5f / 0.2f; + float dt = rf::frametime; + g_ss_occlusion_fade_current = std::max(g_ss_occlusion_fade_current - fade_speed * dt, 0.0f); + g_ss_occlusion.fade_strength = g_ss_occlusion_fade_current; + } + if (g_ss_occlusion_fade_current <= 0.0f) { + g_ss_occlusion.active = false; + } + } + side_scroller_gr_setup_3d_hook.call_target(viewer_orient, viewer_pos, horizontal_fov, zbuffer_flag, z_scale); + + // Draw debug aim line (red = apply_side_scroller_aim direction) + if (g_dbg_aim_valid && is_side_scroller_mode()) { + rf::gr::line_arrow( + g_dbg_aim_start.x, g_dbg_aim_start.y, g_dbg_aim_start.z, + g_dbg_aim_end.x, g_dbg_aim_end.y, g_dbg_aim_end.z, + 255, 0, 0); + } + // Draw debug fire line (green = entity_calc_fire_pos_hook actual output) + if (g_dbg_fire_valid && is_side_scroller_mode()) { + rf::gr::line_arrow( + g_dbg_fire_start.x, g_dbg_fire_start.y, g_dbg_fire_start.z, + g_dbg_fire_end.x, g_dbg_fire_end.y, g_dbg_fire_end.z, + 0, 255, 0); + } + }, +}; + +void side_scroller_do_patch() +{ + side_scroller_player_do_frame_hook.install(); + side_scroller_control_read_hook.install(); + side_scroller_gr_setup_3d_hook.install(); + entity_calc_fire_pos_hook.install(); +} diff --git a/game_patch/misc/side_scroller.h b/game_patch/misc/side_scroller.h new file mode 100644 index 000000000..b58316d28 --- /dev/null +++ b/game_patch/misc/side_scroller.h @@ -0,0 +1,21 @@ +#pragma once + +void side_scroller_do_patch(); +void side_scroller_on_level_load(); +bool is_side_scroller_mode(); + +// Called by hud_weapons to get reticle offset for side-scroller mode +bool side_scroller_get_reticle_offset(int& offset_x, int& offset_y); + +// Side-scroller occlusion parameters for dithered transparency. +// Read by the D3D11 render pipeline to fade geometry between camera and player. +static constexpr int max_ss_occlusion_entities = 16; +struct SsOcclusionParams { + bool active = false; + float fade_strength = 0.0f; // 0.0 = fully opaque, 0.5 = 50% dithered + float radius = 2.5f; // cylinder radius perpendicular to entity-camera axis + int num_entities = 0; + struct { float x, y, z; } entity_pos[max_ss_occlusion_entities]; + struct { float x, y, z; } camera_pos = {0.0f, 0.0f, 0.0f}; +}; +const SsOcclusionParams& get_ss_occlusion_params(); diff --git a/game_patch/rf/vmesh.h b/game_patch/rf/vmesh.h index d44af1c5b..24045167c 100644 --- a/game_patch/rf/vmesh.h +++ b/game_patch/rf/vmesh.h @@ -113,6 +113,8 @@ namespace rf using CharMeshLoadActionFn = int(__thiscall*)(void* mesh_data, const char* rfa_filename, char is_state, char unused); static const auto character_mesh_load_action = reinterpret_cast(0x0051CC10); static auto& vmesh_create_anim_fx = addr_as_ref(0x00502A60); + static auto& vmesh_get_tag_world_pos = addr_as_ref(0x005034F0); static auto& vclip_lookup = addr_as_ref(0x004C1D00); static auto& vclip_play_3d = addr_as_ref 0.0f && ss_is_detail > 0.5f) { + float3 world_pos = input.world_pos_and_depth.xyz; + float max_fade = 0.0f; + for (int ei = 0; ei < (int)ss_num_entities; ++ei) { + float3 entity_pos = ss_entity_pos[ei].xyz; + float3 axis = ss_camera_pos - entity_pos; + float axis_len = length(axis); + if (axis_len < 0.001f) continue; + float3 axis_dir = axis / axis_len; + float3 to_pixel = world_pos - entity_pos; + float proj = dot(to_pixel, axis_dir); + // Only affect pixels between entity and camera (along the axis) + if (proj < 0.0f || proj > axis_len) continue; + float3 perp = to_pixel - proj * axis_dir; + float dist = length(perp); + if (dist < ss_radius) { + float fade = ss_fade_strength * saturate(1.0f - dist / ss_radius); + max_fade = max(max_fade, fade); + } + } + if (max_fade > 0.0f) { + uint2 pixel = uint2(input.pos.xy) % 4; + float dither = bayer4x4[pixel.y][pixel.x]; + clip(dither - max_fade); + } + } + float3 light_color = tex1.Sample(samp1, input.uv1).rgb; if (disable_textures < 0.5f) { light_color *= 2;