diff --git a/docs/configuration/example_community_patch_settings_en.toml b/docs/configuration/example_community_patch_settings_en.toml index cf692d3a3..c14fa1962 100644 --- a/docs/configuration/example_community_patch_settings_en.toml +++ b/docs/configuration/example_community_patch_settings_en.toml @@ -186,10 +186,20 @@ zoom_label_player_threshold = 0.5 # This controls the zoom speed when using Q/E in System View, defaults to 350 keyboard_zoom_speed = 350.0 -# Controls the system pan momentum speed. Defaults to 0.4. Read with enable_experimental, but not applied yet +# Controls mouse/touch and keyboard panning speed in System view only. +# 1.0 = normal, 0.5 = half speed, 1.5 = 50% faster. Valid range: 0.1 to 10. +# Invalid values, including zero and values below 0.1, use normal speed (1.0). +# Replaces system_pan_momentum, which was unused. Does not require experimental mode. +system_pan_speed = 1.0 + +# Independent mouse/touch and keyboard panning speed in Galaxy view, with the same range. +galaxy_pan_speed = 1.0 + +# Legacy compatibility setting; read without experimental mode, but does not affect panning. Defaults to 0.4. system_pan_momentum = 0.4 -# Controls how quickly the momentum falls off when letting go of the mouse. Lower = quicker stop. Defaults to 0.8 +# Momentum retained each update after releasing the mouse, touch or movement keys. +# Lower = quicker stop; 0 = immediate stop; 0.99 = long glide. Clamped to 0 to 0.99. Defaults to 0.8. system_pan_momentum_falloff = 0.8 # Subgroup: Loader diff --git a/src/mod/config.cc b/src/mod/config.cc index f445b2c84..d005140c0 100644 --- a/src/mod/config.cc +++ b/src/mod/config.cc @@ -610,9 +610,20 @@ void Config::Read(const toml::table& file, toml::table& effective, bool log_valu r.Read("graphics", "free_resize", g.free_resize); r.Read("graphics", "allow_cursor", g.allow_cursor); r.Read("graphics", "keyboard_zoom_speed", g.keyboard_zoom_speed); - if (control.enable_experimental) - r.Read("graphics", "system_pan_momentum", g.system_pan_momentum); - r.Read("graphics", "system_pan_momentum_falloff", g.system_pan_momentum_falloff); + r.ReadValid("graphics", "system_pan_speed", g.system_pan_speed, + [](float v) { return std::isfinite(v) && v >= 0.1f && v <= 10.0f; }); + r.ReadValid("graphics", "galaxy_pan_speed", g.galaxy_pan_speed, + [](float v) { return std::isfinite(v) && v >= 0.1f && v <= 10.0f; }); + r.Read("graphics", "system_pan_momentum", g.system_pan_momentum); + r.ReadValid("graphics", "system_pan_momentum_falloff", g.system_pan_momentum_falloff, + [](float v) { return std::isfinite(v); }); + const auto bounded_falloff = std::clamp(g.system_pan_momentum_falloff, 0.0f, 0.99f); + if (g.system_pan_momentum_falloff != bounded_falloff) { + spdlog::warn("config graphics.system_pan_momentum_falloff: '{}' clamped to {}", g.system_pan_momentum_falloff, + bounded_falloff); + g.system_pan_momentum_falloff = bounded_falloff; + r.Record("graphics", "system_pan_momentum_falloff", bounded_falloff); + } r.Read("graphics", "disable_target_camera_lock", g.disable_target_camera_lock); r.Read("graphics", "borderless_fullscreen", g.borderless_fullscreen); r.Read("graphics", "transition_time", g.transition_time); diff --git a/src/mod/config.h b/src/mod/config.h index 214ed0a35..7c8053d72 100644 --- a/src/mod/config.h +++ b/src/mod/config.h @@ -215,7 +215,9 @@ class Config final bool free_resize = true; bool allow_cursor = true; float keyboard_zoom_speed = 350.0f; - // TODO(tashcan): read (with enable_experimental) but nothing applies it. + float system_pan_speed = 1.0f; + float galaxy_pan_speed = 1.0f; + // Retained compatibility key; use system_pan_speed for panning speed. float system_pan_momentum = 0.4f; // How much of the pan speed is kept each frame after a drag or key is released. float system_pan_momentum_falloff = 0.8f; diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index 1da7bc3e8..3349c7f15 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -256,6 +256,49 @@ void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2Cpp bool PointerHeld() { return unity::Input::GetMouseButton(0) || unity::Input::touchCount() > 0; } +// Native LateUpdate uses deltaScaler for keyboard movement and coasting. Supply +// the configured fade once while preserving native keyboard amplitude. +struct NativePanScalers { + NavigationPan* pan; + float delta, keyboard; + + NativePanScalers(NavigationPan* value, float speed) + : pan(value) + , delta(value->deltaScaler()) + , keyboard(value->keyboardDeltaScaler()) + { + pan->SetDeltaScaler(1.0f); + pan->SetKeyboardDeltaScaler(keyboard * delta * speed); + } + + ~NativePanScalers() + { + pan->SetDeltaScaler(delta); + pan->SetKeyboardDeltaScaler(keyboard); + } +}; + +float CameraPanSpeed(NodeDepth depth, float system_speed, float galaxy_speed) +{ + switch (depth) { + case NodeDepth::SolarSystem: + return system_speed; + case NodeDepth::Galaxy: + return galaxy_speed; + default: + return 1.0f; + } +} + +void NavigationPan_MoveCamera_Hook(auto original, NavigationPan* _this, unity::Vector2 delta, bool momentum) +{ + if (!momentum && _this->panningStarted()) { + const auto& graphics = Config::Get().graphics; + delta = delta * CameraPanSpeed(_this->nodeDepth(), graphics.system_pan_speed, graphics.galaxy_pan_speed); + } + original(_this, delta, momentum); +} + // Replaces the game's LateUpdate: the keyboard source is the mod's shortcuts, and the pan keeps gliding after the // drag or key is released. Each frame moves the camera once: by the keys while one is held, otherwise by the // glide. @@ -271,20 +314,27 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) // until the arrow is released. A modified move binding such as CTRL-LEFT still pans. const bool keyboard = config.control.hotkeys_enabled && !config.ui.disable_move_keys; - // With the game's own hotkeys, its LateUpdate pans and glides by itself. The mod still applies its momentum falloff - // to that glide, without moving the camera a second time. - const bool native = keyboard && (config.control.use_scopely_hotkeys || !config.patches.hotkeyhooks); - if (native) - original(_this); + const bool native = keyboard && (config.control.use_scopely_hotkeys || !config.patches.hotkeyhooks); + const float pan_speed = + CameraPanSpeed(_this->nodeDepth(), config.graphics.system_pan_speed, config.graphics.galaxy_pan_speed); auto d = _this->lastDelta(); const bool blocked = _this->BlockPan() || (_this->trackingPOI() && !disable_target_camera_lock); // The native recognizer owns held-drag movement, including when the legacy Input API does not report the pointer. // Never replay its delta as momentum. const bool pointer = PointerHeld() || _this->panningStarted(); - bool by_keys = false; + if (native) { + if (!pointer) { + _this->SetLastDelta(blocked ? unity::Vector2{} : d * config.graphics.system_pan_momentum_falloff); + NativePanScalers scalers(_this, pan_speed); + original(_this); + } + _this->SetFarMagRadiusRatioSystemExtended(_this->farMagRadiusRatioSystemNormal()); + return true; + } + bool by_keys = false; // The game's own gate for its shortcuts also covers the tutorial's input lock, modals and a focused text field. - if (keyboard && !native && !blocked && !pointer && ShortcutsManager::CanUseShortcuts()) { + if (keyboard && !blocked && !pointer && ShortcutsManager::CanUseShortcuts()) { // Opposite bindings cancel; normalize diagonals like the native 2D composite. MoveCamera takes a drag, and // dragging left moves the view right, hence the negation. const float x = float(Shortcut::IsPressed(Action::kMoveRight)) - float(Shortcut::IsPressed(Action::kMoveLeft)); @@ -294,7 +344,8 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) d = {-x / length * speed, -y / length * speed}; by_keys = true; const float scale = _this->deltaScaler(); - _this->MoveCamera(d * scale, true); + _this->MoveCamera(d * (scale * pan_speed), true); + d = _this->lastDelta(); } } @@ -304,10 +355,11 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) // Glide: the last drag or key delta shrinks by the falloff every frame. const auto falloff = config.graphics.system_pan_momentum_falloff; d = d * falloff; - if (!native) - _this->MoveCamera(d, true); + _this->MoveCamera(d, true); + d = _this->lastDelta(); } - _this->SetLastDelta(d); + if (!pointer || blocked) + _this->SetLastDelta(d); _this->SetFarMagRadiusRatioSystemExtended(_this->farMagRadiusRatioSystemNormal()); return true; } @@ -403,6 +455,8 @@ bool InstallZoom() bool InstallPan() { il2cpp::HookSet hooks("Pan"); + IL2CPP_STAGE(hooks, NavigationPan::Class::Method("MoveCamera"), + NavigationPan_MoveCamera_Hook); IL2CPP_STAGE(hooks, OrbitFrameProvider::Class::Method("UpdateInputData"), OrbitFrameProvider_UpdateInputData_Hook); IL2CPP_STAGE(hooks, TKTouch::Class::Method("populateWithPosition"), TKTouch_populateWithPosition_Hook); IL2CPP_STAGE(hooks, NavigationPan::Class::Method("LateUpdate"), NavigationPan_LateUpdate_Hook); diff --git a/src/mod/game/navigation.h b/src/mod/game/navigation.h index a04dca8d7..d7b7337ff 100644 --- a/src/mod/game/navigation.h +++ b/src/mod/game/navigation.h @@ -27,9 +27,14 @@ struct NavigationLOD : il2cpp::Object { { Call<"UpdateLOD">(level); } }; +enum class NodeDepth : int32_t; + struct NavigationPan : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationPan">; + NodeDepth nodeDepth() + { return Field<"_nodeDepth", NodeDepth>(); } + unity::Vector2 lastDelta() { return Field<"_lastDelta", unity::Vector2>(); } void SetLastDelta(unity::Vector2 value) @@ -38,8 +43,12 @@ struct NavigationPan : il2cpp::Object { { return Field<"_panningStarted", bool>(); } float deltaScaler() { return Field<"_deltaScaler", float>(); } + void SetDeltaScaler(float value) + { SetField<"_deltaScaler">(value); } float keyboardDeltaScaler() { return Field<"_keyboardDeltaScaler", float>(); } + void SetKeyboardDeltaScaler(float value) + { SetField<"_keyboardDeltaScaler">(value); } // Managed POI; only checked for null or cleared. void* trackingPOI() { return Field<"_trackingPOI", void*>(); } diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index f63d93826..94ead4acc 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -444,6 +444,60 @@ void RenamedSettings() Check(both->graphics.ui_scale_step == 0.2f, "the new name wins over the old one"); } +void PanControls() +{ + { + toml::table effective; + const auto parsed = Config::Parse(toml::parse(R"( + [control] + enable_experimental = false + [graphics] + system_pan_momentum = 0.25 + )"), + effective, false); + Check(!parsed->control.enable_experimental && parsed->graphics.system_pan_momentum == 0.25f, + "legacy pan momentum remains readable without experimental mode"); + Check(effective["graphics"]["system_pan_momentum"].value() == 0.25, + "runtime vars retain the legacy pan momentum value"); + } + for (const auto* text : {"", "[graphics]\nsystem_pan_speed=0.5", "[graphics]\ngalaxy_pan_speed=2.5", + "[graphics]\nsystem_pan_speed=0.5\ngalaxy_pan_speed=2.5"}) { + toml::table effective; + const auto input = toml::parse(text); + const auto parsed = Config::Parse(input, effective, false); + Check(parsed->graphics.system_pan_speed == (input["graphics"]["system_pan_speed"] ? 0.5f : 1.0f), + "System pan speed keeps its own default and saved value"); + Check(parsed->graphics.galaxy_pan_speed == (input["graphics"]["galaxy_pan_speed"] ? 2.5f : 1.0f), + "Galaxy pan speed keeps its own default and saved value"); + } + struct Case { + const char* text; + float expected; + }; + for (const auto& item : std::array{Case{"0", 0}, Case{"0.8", 0.8f}, Case{"0.99", 0.99f}, Case{"1", 0.99f}, + Case{"1.5", 0.99f}, Case{"-0.1", 0}, Case{"nan", 0.8f}, Case{"inf", 0.8f}, + Case{"-inf", 0.8f}, Case{"\"bad\"", 0.8f}, Case{"[]", 0.8f}}) { + toml::table effective; + const auto parsed = Config::Parse( + toml::parse(std::string("[graphics]\nsystem_pan_momentum_falloff = ") + item.text), effective, false); + Check(parsed->graphics.system_pan_momentum_falloff == item.expected, + "pan glide saturates finite values below one and defaults unreadable or nonfinite values"); + Check(effective["graphics"]["system_pan_momentum_falloff"].value() == double(item.expected), + "runtime vars record the bounded pan glide actually used"); + } + for (const auto* key : {"system_pan_speed", "galaxy_pan_speed"}) + for (const auto& item : std::array{Case{"0.1", 0.1f}, Case{"0.5", 0.5f}, Case{"1", 1}, Case{"10", 10}, Case{"0", 1}, + Case{"0.05", 1}, Case{"1e-40", 1}, Case{"-1", 1}, Case{"10.1", 1}, + Case{"nan", 1}, Case{"inf", 1}, Case{"\"bad\"", 1}}) { + toml::table effective; + const auto parsed = + Config::Parse(toml::parse(std::string("[graphics]\n") + key + " = " + item.text), effective, false); + const float value = std::string_view(key) == "system_pan_speed" ? parsed->graphics.system_pan_speed + : parsed->graphics.galaxy_pan_speed; + Check(value == item.expected, "pan speed accepts only finite values in [0.1, 10]"); + } +} + void ParseDoesNotActivateSettings() { Load("[shortcuts]\nquit = \"F1\""); @@ -550,6 +604,7 @@ int main() DailyFactionSettings(); UpstreamSettings(); RenamedSettings(); + PanControls(); ParseDoesNotActivateSettings(); Matching(); ChatStartupGate();