diff --git a/code/camera/photomode.cpp b/code/camera/photomode.cpp index a922fc46451..96cc2e51c8a 100644 --- a/code/camera/photomode.cpp +++ b/code/camera/photomode.cpp @@ -373,7 +373,7 @@ void photo_mode_do_frame(float frame_time) float bank = check_control_timef(BANK_LEFT) - check_control_timef(BANK_RIGHT); int axis[Action::NUM_VALUES] = {0}; - control_get_axes_readings(axis, flRealframetime); + control_get_axes_readings(axis, flRealframetime, false); pitch += -f2fl(axis[Action::PITCH]); heading += f2fl(axis[Action::HEADING]); bank -= f2fl(axis[Action::BANK]); diff --git a/code/controlconfig/controlsconfig.cpp b/code/controlconfig/controlsconfig.cpp index e9278713d32..d8fab32bbf5 100644 --- a/code/controlconfig/controlsconfig.cpp +++ b/code/controlconfig/controlsconfig.cpp @@ -2961,10 +2961,11 @@ int check_control(int id, int key) } /** - * Inverts the given raw axis value according to the action type + * Inverts the given raw axis value according to the input type * * @param[in] inv True for invert, False for noram - * @param[in] type Type of the axis value to invert, determines method of inversion + * @param[in] type Type of the input value (not of the action), determines method of inversion. Mouse deltas + * are always CC_TYPE_AXIS_REL * @param[in,out] val raw axis value in, maybe inverted axis value out */ inline @@ -2992,7 +2993,16 @@ void maybe_invert(bool inv, CC_type type, int &val) } /*! - * Scales, and maybe inverts, the input axis values + * Scale applied to mouse deltas, in fix units per mouse count + */ +static float mouse_axis_scale() +{ + float scale = i2fl(Mouse_sensitivity) + 1.77f; + return scale * scale / 0.6f; +} + +/*! + * Scales, and maybe inverts, the input axis values of a relative action * * @param[in] bind The control's binding to check * @param[in] action index into axis_out of the action @@ -3016,15 +3026,15 @@ void scale_invert(const CC_bind &bind, const auto cid = bind.get_cid(); const auto btn = bind.get_btn(); - factor = (float)Mouse_sensitivity + 1.77f; - factor = factor * factor / frame_time / 0.6f; + // mouse deltas become a rate, like the joystick's deflection + factor = mouse_axis_scale() / frame_time; switch (cid) { case CID_MOUSE: if (!Use_mouse_to_fly) { // Mouse is treated as mouse, get the axis values dx = axis_in[MOUSE_ID][btn]; - maybe_invert(bind.is_inverted(), type, dx); + maybe_invert(bind.is_inverted(), CC_TYPE_AXIS_REL, dx); axis_out[action] += (int)((float)dx * factor); } // else, Mouse is treated as joy, ignore and let CID_JOY0 case handle it on next call @@ -3034,7 +3044,7 @@ void scale_invert(const CC_bind &bind, if (Use_mouse_to_fly) { // Mouse is treated as Joy0 dx = axis_in[MOUSE_ID][btn]; - maybe_invert(bind.is_inverted(), type, dx); + maybe_invert(bind.is_inverted(), CC_TYPE_AXIS_REL, dx); axis_out[action] += (int)((float)dx * factor); } FALLTHROUGH; @@ -3042,19 +3052,7 @@ void scale_invert(const CC_bind &bind, case CID_JOY1: case CID_JOY2: case CID_JOY3: - switch (type) { - case CC_TYPE_AXIS_ABS: - dx = joy_get_unscaled_reading(axis_in[cid][btn]); - break; - - case CC_TYPE_AXIS_REL: - case CC_TYPE_AXIS_BTN_NEG: - case CC_TYPE_AXIS_BTN_POS: - default: - dx = joy_get_scaled_reading(axis_in[cid][btn]); - break; - } - + dx = joy_get_scaled_reading(axis_in[cid][btn]); maybe_invert(bind.is_inverted(), type, dx); axis_out[action] += dx; break; @@ -3065,9 +3063,71 @@ void scale_invert(const CC_bind &bind, } } -void control_get_axes_readings(int *axis_v, float frame_time) +static abs_axis_state Abs_axis_state[Action::NUM_VALUES]; +static CCB Abs_axis_bindings[Action::NUM_VALUES]; // the bindings each state was last updated with + +void control_reset_axes() +{ + for (auto &state : Abs_axis_state) + state.reset(); +} + +/*! + * Feeds the input of one binding of an absolute action into the action's state + * + * @param[in] bind The control's binding to check + * @param[in] source 0 for the first binding, 1 for the second + * @param[in] joy_connected[] Whether each joystick is connected + * @param[in] axis_in[][] Array of raw axis values + * @param[in,out] state The action's state + */ +static void update_abs_axis(const CC_bind &bind, + int source, + const bool (&joy_connected)[CID_JOY_MAX], + int (&axis_in)[CID_JOY_MAX + 1][JOY_NUM_AXES], + abs_axis_state &state) +{ + const int MOUSE_ID = CID_JOY_MAX; // Joy axes go in front here, mouse gets tacked on the end + const auto cid = bind.get_cid(); + const auto btn = bind.get_btn(); + int dx = 0; + + // The mouse reports deltas, so it nudges the value. Mouse is treated as Joy0 when flying with the mouse + if (cid == (Use_mouse_to_fly ? CID_JOY0 : CID_MOUSE)) + { + dx = axis_in[MOUSE_ID][btn]; + maybe_invert(bind.is_inverted(), CC_TYPE_AXIS_REL, dx); + + if (dx != 0) + state.update_delta(i2fl(dx) * mouse_axis_scale()); + } + + // Joysticks report positions, so they set the value + switch (cid) + { + case CID_JOY0: + case CID_JOY1: + case CID_JOY2: + case CID_JOY3: + // a missing joystick reads as centered, which would look like a lever position + if (!joy_connected[cid]) + break; + + dx = joy_get_unscaled_reading(axis_in[cid][btn]); + maybe_invert(bind.is_inverted(), CC_TYPE_AXIS_ABS, dx); + state.update_position(source, dx); + break; + + default: + // All others, ignore + break; + } +} + +void control_get_axes_readings(int *axis_v, float frame_time, bool update_abs_axes) { int axe[CID_JOY_MAX + 1][JOY_NUM_AXES] = {{0}}; + bool joy_connected[CID_JOY_MAX]; const int MOUSE_ID = CID_JOY_MAX; // Joy axes go in front here, mouse gets tacked on the end Assert(axis_v != nullptr); @@ -3079,7 +3139,7 @@ void control_get_axes_readings(int *axis_v, float frame_time) // Read raw sticks. for (short j = CID_JOY0; j < CID_JOY_MAX; ++j) { - joystick_read_raw_axis(j, JOY_NUM_AXES, axe[j]); + joy_connected[j] = joystick_read_raw_axis(j, JOY_NUM_AXES, axe[j]) != 0; } // Read raw mouse @@ -3091,14 +3151,37 @@ void control_get_axes_readings(int *axis_v, float frame_time) // Assume actions are all axis actions, no need to check // Assumes all axes are uniquely bound to an action - // Process first - if (!item.first.empty()) { - scale_invert(item.first, action, item.type, frame_time, axe, axis_v); - } + if (item.type == CC_TYPE_AXIS_ABS) { + auto &state = Abs_axis_state[action]; + + if (update_abs_axes) { + // A rebound or inverted lever reports a new position without having moved + if (Abs_axis_bindings[action] != item) { + state.clear_anchors(); + Abs_axis_bindings[action] = item; + } - // Process second. - if (!item.second.empty()) { - scale_invert(item.second, action, item.type, frame_time, axe, axis_v); + if (!item.first.empty()) { + update_abs_axis(item.first, 0, joy_connected, axe, state); + } + + if (!item.second.empty()) { + update_abs_axis(item.second, 1, joy_connected, axe, state); + } + } + + axis_v[action] = state.value; + + } else { + // Process first + if (!item.first.empty()) { + scale_invert(item.first, action, item.type, frame_time, axe, axis_v); + } + + // Process second. + if (!item.second.empty()) { + scale_invert(item.second, action, item.type, frame_time, axe, axis_v); + } } //Call Lua hooks diff --git a/code/controlconfig/controlsconfig.h b/code/controlconfig/controlsconfig.h index 67f24075400..8f9bb775e5d 100644 --- a/code/controlconfig/controlsconfig.h +++ b/code/controlconfig/controlsconfig.h @@ -343,6 +343,46 @@ enum { }; } +/*! + * @brief Current value of an absolute axis action, combined from all of its inputs + * + * @details Joystick axes report positions and mouse axes report deltas, so they can't simply be summed. Instead, the + * input that moved last wins: a joystick sets the value outright, and the mouse nudges it from wherever it is. A + * joystick that has lost control must move past TAKEOVER_THRESHOLD to regain it, so that an idle lever's jitter + * doesn't fight the other inputs. A joystick's first reading only takes control if no other joystick has reported or + * is in control. + */ +struct abs_axis_state +{ + static constexpr int NUM_POSITION_SOURCES = 2; // first and second binding + static constexpr int TAKEOVER_THRESHOLD = F1_0 / 80; // 1.25%, matching the analog throttle's keyboard override, which is 1.5% of its 120% throttle range + + int value; // 0 to F1_0 + int active; // position source in control, or -1 + int anchor[NUM_POSITION_SOURCES]; + bool anchored[NUM_POSITION_SOURCES]; + + abs_axis_state(); + + void reset(); + + /*! + * @brief Makes every joystick report again, keeping the value and the source in control. For when the bindings change. + */ + void clear_anchors(); + + /*! + * @param[in] source 0 for the first binding, 1 for the second + * @param[in] pos position from 0 to F1_0, already inverted if necessary + */ + void update_position(int source, int pos); + + /*! + * @param[in] delta change in value, already scaled and inverted if necessary + */ + void update_delta(float delta); +}; + /*! * Where the preset is located in memory */ @@ -983,13 +1023,20 @@ int check_control(int id, int key = -1); * * @param[in] frametime Frametime used to scale the mouse axes * @param[out] axis_v Output array of all control axes, must have size of JOY_NUM_AXIS_ACTIONS + * @param[in] update_abs_axes Whether the inputs should change the absolute controls, or only report them * * @details * * Clamps output to stay within +/-JOY_AXIS_RANGE. - * * Should multiple axes be bound to a control, their values are blended with a simple sum. This is to save a bit of - * processing for speed. + * * Should multiple axes be bound to a relative control, their values are blended with a simple sum. This is to save a + * bit of processing for speed. + * * Absolute controls are combined by ::abs_axis_state instead. + */ +void control_get_axes_readings(int *axis_v, float frame_time, bool update_abs_axes = true); + +/** + * @brief Resets the state of the absolute axis actions. Called at mission start. */ -void control_get_axes_readings(int *axis_v, float frame_time); +void control_reset_axes(); /** * @brief Marks the given control (by IoActionId) as used diff --git a/code/controlconfig/controlsconfigcommon.cpp b/code/controlconfig/controlsconfigcommon.cpp index 1dd4151effe..4a0163f8397 100644 --- a/code/controlconfig/controlsconfigcommon.cpp +++ b/code/controlconfig/controlsconfigcommon.cpp @@ -2945,6 +2945,71 @@ bool CCI::is_axis() { } } +abs_axis_state::abs_axis_state() +{ + reset(); +} + +void abs_axis_state::reset() +{ + value = F1_0 / 2; + active = -1; + + clear_anchors(); +} + +void abs_axis_state::clear_anchors() +{ + for (int i = 0; i < NUM_POSITION_SOURCES; ++i) + { + anchor[i] = 0; + anchored[i] = false; + } +} + +void abs_axis_state::update_position(int source, int pos) +{ + Assertion((source >= 0) && (source < NUM_POSITION_SOURCES), "Invalid position source %d!", source); + + if (source != active) + { + if (!anchored[source]) + { + // a new input doesn't take control from one that is in control or has already reported + bool other_reported = (active >= 0); + for (bool other_anchored : anchored) + { + if (other_anchored) + other_reported = true; + } + + if (other_reported) + { + anchor[source] = pos; + anchored[source] = true; + return; + } + } + // an idle input doesn't take control + else if (abs(pos - anchor[source]) <= TAKEOVER_THRESHOLD) + return; + } + + active = source; + value = pos; + anchor[source] = pos; + anchored[source] = true; +} + +void abs_axis_state::update_delta(float delta) +{ + float new_value = i2fl(value) + delta; + CLAMP(new_value, 0.0f, i2fl(F1_0)); + + active = -1; + value = fl2i(new_value); +} + CCI_builder::CCI_builder(SCP_vector& _ControlConfig) : ControlConfig(_ControlConfig) { ControlConfig.resize(CCFG_MAX); }; diff --git a/code/mission/missionload.cpp b/code/mission/missionload.cpp index a7061bbbe56..08fdf671205 100644 --- a/code/mission/missionload.cpp +++ b/code/mission/missionload.cpp @@ -140,6 +140,7 @@ bool mission_load(const char* filename_ext) ml_update_recent_missions(filename_ext); // update recently played missions list (save the csg later) control_reset_hook(); + control_reset_axes(); init_hud(); diff --git a/code/playerman/playercontrol.cpp b/code/playerman/playercontrol.cpp index ebb113fc0b9..57b4c54bdd3 100644 --- a/code/playerman/playercontrol.cpp +++ b/code/playerman/playercontrol.cpp @@ -319,7 +319,7 @@ void view_modify(angles *ma, angles *da, float max_p, float max_h) t = (check_control_timef(YAW_RIGHT) - check_control_timef(YAW_LEFT)); u = (check_control_timef(PITCH_FORWARD) - check_control_timef(PITCH_BACK)); - control_get_axes_readings(axis, flRealframetime); + control_get_axes_readings(axis, flRealframetime, false); // Does the same thing as t and u but for the joystick input h = f2fl(axis[Action::HEADING]); @@ -640,13 +640,15 @@ void player_control_reset_ci( control_info *ci ) ci->forward_cruise_percent = oldspeed; } +// reset at mission start, so that the analog throttle applies on the first frame +static float analog_throttle_last = 9e9f; +static int override_analog_throttle = 0; + void read_keyboard_controls( control_info * ci, float frame_time, physics_info *pi ) { float kh=0.0f, scaled, newspeed, delta, oldspeed; int axis[Action::NUM_VALUES]; static int afterburner_last = 0; - static float analog_throttle_last = 9e9f; - static int override_analog_throttle = 0; static float savedspeed = ci->forward_cruise_percent; //Backslash int centering_speed = 7; // the scale speed in which the camera will smoothly center when the player presses Center View @@ -1456,6 +1458,8 @@ void player_level_init() { toggle_glide = 0; press_glide = 0; + analog_throttle_last = 9e9f; + override_analog_throttle = 0; memset(&(Player->ci), 0, sizeof(control_info) ); // set the controls to 0 Viewer_slew_angles.p = 0.0f; Viewer_slew_angles.b = 0.0f; Viewer_slew_angles.h = 0.0f; diff --git a/test/src/controlconfig/test_abs_axis.cpp b/test/src/controlconfig/test_abs_axis.cpp new file mode 100644 index 00000000000..140352ec01b --- /dev/null +++ b/test/src/controlconfig/test_abs_axis.cpp @@ -0,0 +1,202 @@ + +#include + +#include + +namespace +{ +const int THRESHOLD = abs_axis_state::TAKEOVER_THRESHOLD; +} + +TEST(AbsAxisStateTests, starts_centered) +{ + abs_axis_state state; + + ASSERT_EQ(F1_0 / 2, state.value); + ASSERT_EQ(-1, state.active); +} + +TEST(AbsAxisStateTests, first_reading_takes_control) +{ + abs_axis_state state; + + state.update_position(0, 1000); + + ASSERT_EQ(1000, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, active_source_tracks_below_threshold) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(0, 1001); + + ASSERT_EQ(1001, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, new_source_does_not_take_control) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + + ASSERT_EQ(1000, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, idle_source_does_not_take_control) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + + // jitter on the second source + state.update_position(0, 1000); + state.update_position(1, 50000 + THRESHOLD); + + ASSERT_EQ(1000, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, moved_source_takes_control) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + + state.update_position(0, 1000); + state.update_position(1, 50000 + THRESHOLD + 1); + + ASSERT_EQ(50000 + THRESHOLD + 1, state.value); + ASSERT_EQ(1, state.active); + + // and the first source must now move to take it back + state.update_position(0, 1000 + THRESHOLD); + + ASSERT_EQ(50000 + THRESHOLD + 1, state.value); + ASSERT_EQ(1, state.active); + + state.update_position(0, 1000 + THRESHOLD + 1); + + ASSERT_EQ(1000 + THRESHOLD + 1, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, repeated_reads_are_idempotent) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + state.update_position(0, 1000); + state.update_position(1, 50000); + + ASSERT_EQ(1000, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, delta_nudges_current_value) +{ + abs_axis_state state; + + state.update_position(0, 20000); + state.update_delta(500.0f); + + ASSERT_EQ(20500, state.value); + ASSERT_EQ(-1, state.active); + + state.update_delta(-1500.0f); + + ASSERT_EQ(19000, state.value); +} + +TEST(AbsAxisStateTests, delta_clamps) +{ + abs_axis_state state; + + state.update_delta(10.0f * F1_0); + ASSERT_EQ(F1_0, state.value); + + state.update_delta(-10.0f * F1_0); + ASSERT_EQ(0, state.value); +} + +TEST(AbsAxisStateTests, source_must_move_to_retake_control_from_delta) +{ + abs_axis_state state; + + state.update_position(0, 20000); + state.update_delta(5000.0f); + + // the lever is still where it was + state.update_position(0, 20000 + THRESHOLD); + + ASSERT_EQ(25000, state.value); + ASSERT_EQ(-1, state.active); + + state.update_position(0, 20000 + THRESHOLD + 1); + + ASSERT_EQ(20000 + THRESHOLD + 1, state.value); + ASSERT_EQ(0, state.active); +} + +TEST(AbsAxisStateTests, reset_returns_to_center) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + state.reset(); + + ASSERT_EQ(F1_0 / 2, state.value); + ASSERT_EQ(-1, state.active); + + // after a reset, the first reading takes control again, from either source + state.update_position(1, 50000); + + ASSERT_EQ(50000, state.value); + ASSERT_EQ(1, state.active); +} + +TEST(AbsAxisStateTests, clear_anchors_keeps_source_in_control) +{ + abs_axis_state state; + + state.update_position(0, 1000); + state.update_position(1, 50000); + state.update_position(1, 50000 + THRESHOLD + 1); + state.clear_anchors(); + + ASSERT_EQ(50000 + THRESHOLD + 1, state.value); + ASSERT_EQ(1, state.active); + + // e.g. the idle lever was inverted: it reports again without taking control + state.update_position(0, F1_0 - 1000); + + ASSERT_EQ(50000 + THRESHOLD + 1, state.value); + ASSERT_EQ(1, state.active); + + // the lever in control applies its new reading at once + state.update_position(1, F1_0 - 50000); + + ASSERT_EQ(F1_0 - 50000, state.value); + ASSERT_EQ(1, state.active); +} + +TEST(AbsAxisStateTests, clear_anchors_keeps_value_from_delta) +{ + abs_axis_state state; + + state.update_position(0, 20000); + state.update_delta(5000.0f); + state.clear_anchors(); + + ASSERT_EQ(25000, state.value); + ASSERT_EQ(-1, state.active); +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 1c268dda86e..efd474a43f2 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -17,6 +17,10 @@ add_file_folder("CFile" cfile/cfile.cpp ) +add_file_folder("ControlConfig" + controlconfig/test_abs_axis.cpp +) + add_file_folder("Globalincs" globalincs/test_flagset.cpp globalincs/test_safe_strings.cpp