diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index af9dab03..1241af3c 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -62,7 +62,7 @@ void register_movement_commands(Commands ®istry) { registry.add(std::move(entry)); } auto drone_mode = variable("dronemode", - "Fly the free camera as an FPV drone. Easiest from the drone window (dronemenu): Start flying", + "Fly the free camera as an FPV drone. Easiest from the DRONE page of the ReSkate menu (Insert): Start flying", Group::movement, argument("0|1", Type::boolean)); drone_mode.inspect = [](const Model &m) { return boolean_state(m.debug.available, drone::is_enabled(), "Waiting for local player controls.", @@ -126,12 +126,11 @@ void register_movement_commands(Commands ®istry) { : std::format("{} = {:g} (the closest allowed value)", name, value)); }; registry.add(std::move(tune)); - auto menu_command = action("dronemenu", "Show or hide the drone window (it shows while this menu is open)", Group::movement); + auto menu_command = action("dronemenu", "Open the ReSkate menu on its DRONE page", Group::movement); menu_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; menu_command.run = [](const Model &, const Values &, const Output &out) { - const bool now_open = !drone::menu_flag().load(); - drone::menu_flag().store(now_open); - out(now_open ? "Drone window shown." : "Drone window hidden."); + drone::menu_flag().store(true); + out("Opening the DRONE page."); }; registry.add(std::move(menu_command)); auto arm_command = action("dronearm", "Arm the drone: motors on, ready to fly", Group::movement); @@ -162,13 +161,13 @@ void register_movement_commands(Commands ®istry) { Group::movement, argument("0|1", Type::boolean)); radio_mode.inspect = [](const Model &m) { return boolean_state(m.debug.available, drone::radio::config().enabled.load(), "Waiting for local player controls.", - "Set it up in the drone window (dronemenu, Controller tab)"); + "Set it up on the DRONE page (dronemenu), CONTROLLER tab"); }; radio_mode.run = [](const Model &, const Values &args, const Output &out) { const bool on = std::get(args[0]); drone::radio::config().enabled = on; profile_runtime::set_local_values({{"Drone.Radio", on ? 1.0 : 0.0}}); - out(on ? "Controller on. If a stick does the wrong thing, run Set up controller (dronemenu, Controller tab)." + out(on ? "Controller on. If a stick does the wrong thing, run Set up controller (dronemenu, CONTROLLER tab)." : "Controller off. The drone uses the keyboard and mouse."); }; radio_mode.reset = [](const Model &, const Output &) { @@ -196,7 +195,7 @@ void register_movement_commands(Commands ®istry) { out(std::format("Mapping (roll pitch throttle yaw): {} {} {} {}", map[0], map[1], map[2], map[3])); out(std::format("Drone reads: roll {:+.2f} (right +) pitch {:+.2f} (forward +) throttle {:.2f} yaw {:+.2f} (right +)", cmd.roll, cmd.pitch, cmd.throttle, cmd.yaw)); - out("A stick doing the wrong thing? Easiest fix: dronemenu, Controller tab, Set up controller."); + out("A stick doing the wrong thing? Easiest fix: dronemenu, CONTROLLER tab, Set up controller."); }; registry.add(std::move(radio_check)); const auto axis_argument = [](const char *name) { diff --git a/Extension/Skater/drone_flight.h b/Extension/Skater/drone_flight.h index 4f160352..6251d04a 100644 --- a/Extension/Skater/drone_flight.h +++ b/Extension/Skater/drone_flight.h @@ -93,10 +93,10 @@ inline std::atomic& armed_flag() { } inline bool armed() { return armed_flag().load(std::memory_order_acquire); } inline void set_armed(bool on) { armed_flag().store(on, std::memory_order_release); } -// The drone's settings window in the overlay (UI/Overlay/drone_menu_overlay.cpp): shown while the -// overlay is open; the X closes it and `dronemenu` brings it back. +// `dronemenu` asks for the ReSkate menu to open on its DRONE page (UI/Overlay/drone_page.cpp); +// the menu takes the request when it next draws. inline std::atomic& menu_flag() { - static std::atomic value{true}; + static std::atomic value{false}; return value; } // Asks the flight tick to put the drone back at its starting point and restart the timer and battery. @@ -162,7 +162,7 @@ inline constexpr KnobInfo knob_info[] = { {"motorpitch", "Motor sound pitch. Higher sounds like a smaller drone", 0.4f, 2.5f, 1.0f}, {"throttlecentre", "Your throttle stick springs back to the middle (gamepads): the middle hovers, up climbs, down descends", 0.0f, 1.0f, 0.0f}, {"deadzone", "How far a stick must move before the drone reacts (0-0.3). Raise it if the drone drifts on its own", 0.0f, 0.3f, 0.03f}, - {"goggles", "What your skater wears over their eyes while you fly: 0 = nothing, 1-8 = one of the eyewear choices in the drone window", 0.0f, 8.0f, 1.0f}, + {"goggles", "What your skater wears over their eyes while you fly: 0 = nothing, 1-8 = one of the eyewear choices on the DRONE page", 0.0f, 8.0f, 1.0f}, {"hideui", "Hide the game's own interface while you fly (Start flying hides it, Stop flying brings it back)", 0.0f, 1.0f, 1.0f}, }; inline constexpr std::size_t knob_count = std::size(knob_info); @@ -217,7 +217,7 @@ inline bool failsafe_due(float distance, float range, float seconds_lost) { } // Presets: whole drones in one choice. Each sets the handling, rates, size and motor sound, and -// the top speed (the `flyspeed` setting). The drone window applies them through the same commands +// the top speed (the `flyspeed` setting). The DRONE page applies them through the same commands // a player could type, so they are saved like any other change, and shows "Custom" once one is changed. struct PresetValue { Knob knob; float value; }; struct Preset { diff --git a/Extension/Skater/drone_radio.h b/Extension/Skater/drone_radio.h index bbe693b7..9e031638 100644 --- a/Extension/Skater/drone_radio.h +++ b/Extension/Skater/drone_radio.h @@ -94,7 +94,7 @@ struct Sample { // axis that moved furthest from rest, once it has been held well over for a moment, and takes the // direction it moved as "plus" (throttle up, yaw right, pitch forward, roll right). The stick must // come back to rest before the next one. Switches are any other axis that swings across, or a -// button that was not already down at rest. Plain maths: driven by the drone window's frames. +// button that was not already down at rest. Plain maths: driven by the DRONE page's frames. class Calibration { public: enum class Step { idle, rest, throttle, yaw, pitch, roll, arm, reset, done }; @@ -237,10 +237,10 @@ private: } } }; -// The one setup the drone window runs. Overlay thread only; the game thread reads calibrating(). +// The one setup the DRONE page runs. Overlay thread only; the game thread reads calibrating(). inline Calibration& calibration() { static Calibration value; return value; } // While the setup is on screen the game holds the drone still instead of flying it with the sticks. -// The drone window marks each frame it shows the setup; leaving the tab or closing the menu lets +// The DRONE page marks each frame it shows the setup; leaving the tab or closing the menu lets // the drone fly again within half a second, even with the setup unfinished. inline std::atomic& calibration_seen() { static std::atomic value{0}; return value; } inline std::int64_t calibration_clock() { diff --git a/Extension/UI/Overlay/drone_menu_overlay.cpp b/Extension/UI/Overlay/drone_menu_overlay.cpp deleted file mode 100644 index e26101f4..00000000 --- a/Extension/UI/Overlay/drone_menu_overlay.cpp +++ /dev/null @@ -1,466 +0,0 @@ -#include "overlay_internal.h" -#include "skate_style.h" -#include "Extension/Skater/drone_flight.h" -#include "Extension/Skater/drone_beacon.h" -#include "Extension/Skater/drone_outfit.h" -#include -#include - -// The FPV drone's settings window: fly / arm / reset buttons, handling, Betaflight rates, the -// display, the radio and multiplayer. It shows while the menu or console holds the pointer -// (like the profiler window). Every change takes effect at once; it is also saved by running the -// same `dronetune` command you could type, so the console and this window always agree. - -namespace dingosdk::overlay::detail { -namespace { -namespace skate = dingosdk::skate_theme; -namespace drone = dingosdk::drone; -using drone::Knob; - -float scale() { - return std::clamp(state().model.menu_scale, dingosdk::min_menu_scale, dingosdk::max_menu_scale); -} - -// Runs a console command on the game's thread (what the console itself does). -bool run(const std::string& command) { - auto& s = state(); - if (!s.callbacks.queue_console_command) return false; - std::array result{}; - return s.callbacks.queue_console_command(s.callbacks.user, command.c_str(), result.data(), result.size()); -} - -float get(Knob k) { return drone::knobs().value[k].load(std::memory_order_relaxed); } -void set(Knob k, float v) { drone::knobs().value[k].store(v, std::memory_order_relaxed); } -// Saves a setting by running `dronetune`, which also clamps it and keeps it for next time. -void save(Knob k) { run(std::format("dronetune {} {}", drone::knob_info[k].name, get(k))); } - -void tip(Knob k) { - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s.\n\nConsole: dronetune %s (default %g)", drone::knob_info[k].help, drone::knob_info[k].name, - static_cast(drone::knob_info[k].def)); -} - -void slider(Knob k, const char* label, const char* format = "%.2f") { - float v = get(k); - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::SliderFloat(label, &v, drone::knob_info[k].lo, drone::knob_info[k].hi, format)) set(k, v); - if (ImGui::IsItemDeactivatedAfterEdit()) save(k); - tip(k); -} -void integer(Knob k, const char* label) { - int v = static_cast(std::lround(get(k))); - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::SliderInt(label, &v, static_cast(drone::knob_info[k].lo), static_cast(drone::knob_info[k].hi))) - set(k, static_cast(v)); - if (ImGui::IsItemDeactivatedAfterEdit()) save(k); - tip(k); -} -void toggle(Knob k, const char* label) { - bool on = get(k) >= 0.5f; - if (ImGui::Checkbox(label, &on)) { set(k, on ? 1.0f : 0.0f); save(k); } - tip(k); -} -void choice(Knob k, const char* label, const char* const* items, int count) { - int v = std::clamp(static_cast(std::lround(get(k))), 0, count - 1); - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::Combo(label, &v, items, count)) { set(k, static_cast(v)); save(k); } - tip(k); -} - -// Presets (drone_flight.h): a whole drone in one choice. "Custom" once anything is changed. -void preset_picker() { - auto& s = state(); - const int current = drone::matching_preset(s.model.debug.camera_speed); - const char* shown = current >= 0 ? drone::presets[current].name : "Custom"; - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::BeginCombo("Drone", shown)) { - for (std::size_t p = 0; p < drone::preset_count; ++p) { - const auto& preset = drone::presets[p]; - if (ImGui::Selectable(preset.name, static_cast(p) == current)) { - for (const auto& v : preset.values) { set(v.knob, v.value); save(v.knob); } - s.model.debug.camera_speed = preset.top_speed; - run(std::format("flyspeed {}", preset.top_speed)); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", preset.description); - } - ImGui::EndCombo(); - } - if (current >= 0) ImGui::TextDisabled("%s", drone::presets[current].description); - else ImGui::TextDisabled("Your own settings. Pick a drone above to start from one of the presets."); -} - -void tab_fly() { - auto& s = state(); - const bool enabled = drone::is_enabled(); - const bool radio_on = drone::radio::config().enabled.load(); - const bool freecam = s.model.debug.free_camera; - if (enabled && freecam) ImGui::TextDisabled("Flying with %s.", radio_on ? "the controller" : "the keyboard and mouse"); - else if (enabled) ImGui::TextDisabled("Drone mode is on, but freecam is off: press Start flying."); - ImGui::BeginDisabled(!s.model.debug.camera_available); - if (ImGui::Button(enabled && freecam ? "Stop flying" : "Start flying", ImVec2(-FLT_MIN, 0))) { - // The game's interface is hidden for the flight (the `hideui` drone setting) and only - // brought back if the drone was what hid it. - static bool hid_ui = false; - if (enabled && freecam) { - run("dronemode 0"); - run("freecam 0"); // back to the skater - if (hid_ui) run("hideui 0"); - hid_ui = false; - } else { - if (!freecam) run("freecam 1"); - if (!enabled) run("dronemode 1"); - if (get(Knob::kHideUi) >= 0.5f && !s.model.debug.game_ui_hidden) { run("hideui 1"); hid_ui = true; } - } - } - ImGui::EndDisabled(); - if (!s.model.debug.camera_available) ImGui::TextDisabled("Load into a level to fly."); - bool radio = radio_on; - if (ImGui::Checkbox("Fly with a controller (RC radio or gamepad)", &radio)) run(radio ? "droneradio 1" : "droneradio 0"); - if (!radio_on && drone::radio::poll().valid) - ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.3f, 1.0f), "A controller is connected. Tick the box above to fly with it, or the game " - "keeps using it for your skater."); - ImGui::Separator(); - preset_picker(); - { // What the pilot's skater wears over their eyes while flying. - const int choice = std::clamp(static_cast(std::lround(get(Knob::kGoggles))), 0, static_cast(drone::outfit::eyewear_count)); - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::BeginCombo("Goggles", choice ? drone::outfit::eyewear[choice - 1].label : "None")) { - if (ImGui::Selectable("None", choice == 0)) { set(Knob::kGoggles, 0.0f); save(Knob::kGoggles); } - for (std::size_t i = 0; i < drone::outfit::eyewear_count; ++i) - if (ImGui::Selectable(drone::outfit::eyewear[i].label, choice == static_cast(i) + 1)) { - set(Knob::kGoggles, static_cast(i + 1)); - save(Knob::kGoggles); - } - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Your skater wears these while you fly, and other players see them. Your own eyewear comes back when you land."); - } - toggle(Knob::kHideUi, "Hide the game's interface while flying"); - ImGui::Separator(); - const bool armed = drone::armed(); - ImGui::Text("Motors: %s", armed ? "ARMED" : "DISARMED"); - const float half = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; - if (ImGui::Button(armed ? "Disarm" : "Arm", ImVec2(half, 0))) drone::set_armed(!armed); - ImGui::SameLine(); - if (ImGui::Button("Back to takeoff", ImVec2(half, 0))) drone::request_reset(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts the drone back where it took off, with a fresh battery and timer."); - ImGui::SameLine(); - if (ImGui::Button("Reset settings", ImVec2(half, 0))) run("dronetune reset"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts every drone setting back to its default."); - ImGui::Separator(); - if (drone::flying()) { - const auto& t = drone::telemetry(); - ImGui::Text("Throttle %3.0f%% Speed %.0f km/h Altitude %.1f m Time %.0f s", - static_cast(t.throttle.load() * 100.0f), static_cast(t.speed.load() * 3.6f), - static_cast(t.altitude.load()), static_cast(t.seconds.load())); - } else { - ImGui::TextDisabled("Not flying."); - } - // The game's value comes back every frame; hold the one being set until the game has it, or - // the slider would snap back while it is dragged. - static float pending = -1.0f; - static double sent_at = 0.0; - if (pending >= 0.0f && (std::abs(s.model.debug.camera_speed - pending) < 0.01f || - (sent_at > 0.0 && ImGui::GetTime() - sent_at > 1.0))) { pending = -1.0f; sent_at = 0.0; } - float top = pending >= 0.0f ? pending : s.model.debug.camera_speed; - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::SliderFloat("Top speed", &top, 0.6f, 300.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) pending = top; - if (ImGui::IsItemDeactivatedAfterEdit()) { run(std::format("flyspeed {}", top)); sent_at = ImGui::GetTime(); } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("The same setting as freecam's fly speed.\n\nConsole: flyspeed"); - // The flight model caps speed at four times the fly speed (m/s), twice that with boost. - ImGui::TextDisabled("Up to about %.0f km/h (twice that with boost).", static_cast(top * 4.0f * 3.6f)); -} - -void tab_handling() { - slider(Knob::kGravity, "Gravity", "%.1f"); - slider(Knob::kHover, "Hover throttle (lower = more power)"); - slider(Knob::kDrag, "Air drag"); - slider(Knob::kUptilt, "Camera tilt (degrees)", "%.0f"); - ImGui::Separator(); - toggle(Knob::kCollide, "Collide with the world"); - slider(Knob::kRadius, "Collision size"); - slider(Knob::kFloor, "Invisible floor (with collisions off)", "%.1f"); - toggle(Knob::kDisarmFreeze, "Hang in the air when disarmed"); - ImGui::Separator(); - ImGui::TextUnformatted("Range"); - bool limited = get(Knob::kRange) > 0.0f; - if (ImGui::Checkbox("Limited range", &limited)) { set(Knob::kRange, limited ? 300.0f : 0.0f); save(Knob::kRange); } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Like a real radio and video link: the picture breaks up as you near the limit, and\n" - "further out the motors cut (failsafe) until you reset. Off = fly anywhere on the map."); - if (limited) { - float metres = get(Knob::kRange); - ImGui::SetNextItemWidth(-ImGui::GetFontSize() * 9.0f); - if (ImGui::SliderFloat("Range (metres)", &metres, 50.0f, 5000.0f, "%.0f", ImGuiSliderFlags_Logarithmic)) set(Knob::kRange, metres); - if (ImGui::IsItemDeactivatedAfterEdit()) save(Knob::kRange); - ImGui::TextWrapped("Measured from where you took off. The picture starts to break up at %.0f m and is lost at %.0f m. " - "Stay without a picture for %.0f seconds, or go past %.0f m, and the motors cut.", - static_cast(get(Knob::kRange) * drone::range_clean), static_cast(get(Knob::kRange)), - static_cast(drone::failsafe_after_seconds), - static_cast(get(Knob::kRange) * drone::range_failsafe)); - } -} - -void rate_row(const char* axis, Knob centre, Knob max, Knob expo) { - ImGui::PushID(axis); - ImGui::TextUnformatted(axis); - slider(centre, "Centre sensitivity", "%.0f"); - slider(max, "Max rate", "%.0f"); - slider(expo, "Expo"); - ImGui::PopID(); -} -void tab_rates() { - ImGui::TextWrapped("How fast the drone turns, set like Betaflight's \"Actual\" rates. Centre: the speed near the middle of " - "the stick. Max: the speed at full stick. Expo: how much softer the middle is."); - rate_row("Roll", Knob::kRollCenter, Knob::kRollMax, Knob::kRollExpo); - ImGui::Separator(); - rate_row("Pitch", Knob::kPitchCenter, Knob::kPitchMax, Knob::kPitchExpo); - ImGui::Separator(); - rate_row("Yaw", Knob::kYawCenter, Knob::kYawMax, Knob::kYawExpo); -} - -void tab_display() { - toggle(Knob::kOsd, "Show the goggle display (OSD)"); - static const char* const colours[] = {"White", "Green", "Yellow", "Cyan", "Orange", "Pink"}; - static const char* const fonts[] = {"Pixel (goggles)", "Smooth"}; - slider(Knob::kOsdScale, "Text size"); - choice(Knob::kOsdColor, "Colour", colours, 6); - choice(Knob::kOsdFont, "Font", fonts, 2); - slider(Knob::kOsdMargin, "Distance from the edge"); - toggle(Knob::kCrosshair, "Crosshair"); - ImGui::TextDisabled("Show:"); - toggle(Knob::kShowArmed, "Armed / disarmed"); ImGui::SameLine(); - toggle(Knob::kShowBattery, "Battery"); - toggle(Knob::kShowTimer, "Timer"); ImGui::SameLine(); - toggle(Knob::kShowThrottle, "Throttle"); ImGui::SameLine(); - toggle(Knob::kShowAlt, "Altitude"); ImGui::SameLine(); - toggle(Knob::kShowSpeed, "Speed"); - ImGui::Separator(); - toggle(Knob::kSticks, "Show my sticks on screen"); - slider(Knob::kStickSize, "On-screen sticks size"); - ImGui::Separator(); - toggle(Knob::kAnalog, "Analogue video look"); - slider(Knob::kAnalogNoise, "Static"); - slider(Knob::kAnalogScan, "Scanlines"); - slider(Knob::kAnalogVignette, "Dark corners"); - slider(Knob::kAnalogGlitch, "Rolling glitches"); - ImGui::Separator(); - slider(Knob::kVolume, "Motor sound volume"); - slider(Knob::kMotorPitch, "Motor sound pitch"); -} - -const char* axis_text(int signed_axis, char* buffer, std::size_t size) { - if (signed_axis == 0) std::snprintf(buffer, size, "none"); - else std::snprintf(buffer, size, "axis %d%s", std::abs(signed_axis), signed_axis < 0 ? " (reversed)" : ""); - return buffer; -} - -// Live bars for the six axes; `highlight` (0-5) is drawn as the one being picked. -void axis_bars(const drone::radio::Sample& sample, int highlight) { - for (int i = 0; i < 6; ++i) { - const auto v = sample.axes[static_cast(i)]; - char value[16]; - std::snprintf(value, sizeof value, "%+.2f", static_cast(v)); - const bool lit = i == highlight; - if (lit) ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(120, 220, 120, 255)); - ImGui::ProgressBar((v + 1.0f) * 0.5f, ImVec2(ImGui::GetContentRegionAvail().x * 0.7f, 0), value); - if (lit) ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::Text("Axis %d%s", i + 1, lit ? " <" : ""); - } -} - -// The guided setup (drone_radio.h: Calibration). Saves through the same commands as the manual controls. -void setup_wizard(const drone::radio::Sample& sample) { - using Step = drone::radio::Calibration::Step; - auto& c = drone::radio::calibration(); - if (sample.valid) c.update(sample.axes, sample.buttons, ImGui::GetIO().DeltaTime); - const auto step = c.step(); - if (c.active()) drone::radio::mark_calibrating(); - const char* title = ""; - const char* body = ""; - switch (step) { - case Step::rest: - title = "Step 1 of 7: sticks at rest"; - body = "Let go of both sticks so they centre, and put the throttle all the way down (a gamepad's sticks just " - "centre). Then press Ready."; - break; - case Step::throttle: - title = "Step 2 of 7: throttle"; - body = c.waiting_for_return() ? "Got it. Now let the throttle back down." - : "Push the throttle all the way up and hold it there. On a gamepad, that's the left stick, up."; - break; - case Step::yaw: - title = "Step 3 of 7: yaw"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the yaw stick (left stick, sideways) fully right and hold it."; - break; - case Step::pitch: - title = "Step 4 of 7: pitch"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully forward (away from you) and hold it."; - break; - case Step::roll: - title = "Step 5 of 7: roll"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully right and hold it."; - break; - case Step::arm: - title = "Step 6 of 7: arm switch (optional)"; - body = "Flip the switch you want to arm the motors with to its ARMED position. No arm switch? Press Skip."; - break; - case Step::reset: - title = "Step 7 of 7: reset switch (optional)"; - body = c.waiting_for_return() ? "Got it. Let the switch go back." - : "Flick the switch or press the button you want to put the drone back at its start. No reset switch? Press Skip."; - break; - default: break; - } - if (step == Step::done) { - // Save once, then show what was found. - static bool saved = false; - if (!saved) { - const auto m = c.mapping(); - run(std::format("droneaxes {} {} {} {}", m[0], m[1], m[2], m[3])); - set(Knob::kArmAxis, static_cast(c.arm_axis())); save(Knob::kArmAxis); - set(Knob::kArmButton, static_cast(c.arm_button())); save(Knob::kArmButton); - set(Knob::kResetAxis, static_cast(c.reset_axis())); save(Knob::kResetAxis); - set(Knob::kResetButton, static_cast(c.reset_button())); save(Knob::kResetButton); - set(Knob::kThrottleCentre, c.throttle_centred() ? 1.0f : 0.0f); save(Knob::kThrottleCentre); - // Gamepad sticks drift more than a radio's gimbals. - if (c.throttle_centred() && get(Knob::kDeadzone) < 0.12f) { set(Knob::kDeadzone, 0.12f); save(Knob::kDeadzone); } - // Setting a controller up means flying with it. - if (!drone::radio::config().enabled.load()) run("droneradio 1"); - saved = true; - } - ImGui::TextUnformatted("Setup saved. The drone now flies with this controller."); - const auto m = c.mapping(); - static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; - char buffer[32]; - for (int i = 0; i < 4; ++i) ImGui::BulletText("%s: %s", names[i], axis_text(m[static_cast(i)], buffer, sizeof buffer)); - const auto switch_text = [&buffer](int axis, int button) -> std::string { - if (button > 0) return std::format("button {}", button); - return axis_text(axis, buffer, sizeof buffer); - }; - ImGui::BulletText("Arm switch: %s", switch_text(c.arm_axis(), c.arm_button()).c_str()); - ImGui::BulletText("Reset switch: %s", switch_text(c.reset_axis(), c.reset_button()).c_str()); - if (c.throttle_centred()) - ImGui::TextWrapped("Your throttle springs back to the middle, so the middle now holds the drone up: push up to " - "climb, down to descend."); - if (ImGui::Button("Done", ImVec2(-FLT_MIN, 0))) { c.cancel(); saved = false; } - return; - } - ImGui::TextUnformatted(title); - ImGui::TextWrapped("%s", body); - if (!sample.valid) ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.3f, 1.0f), "The controller is not connected."); - else axis_bars(sample, c.waiting_for_return() ? -1 : c.candidate()); - const float third = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; - if (step == Step::rest) { - ImGui::BeginDisabled(!sample.valid); - if (ImGui::Button("Ready", ImVec2(third, 0))) c.capture_rest(sample.axes, sample.buttons); - ImGui::EndDisabled(); - ImGui::SameLine(); - } else if ((step == Step::arm || step == Step::reset) && !c.waiting_for_return()) { - if (ImGui::Button("Skip", ImVec2(third, 0))) c.skip(); - ImGui::SameLine(); - } - if (ImGui::Button("Cancel", ImVec2(third, 0))) c.cancel(); -} - -void tab_radio() { - const auto sample = drone::radio::poll(); - auto& c = drone::radio::calibration(); - if (c.step() != drone::radio::Calibration::Step::idle) { - setup_wizard(sample); - return; - } - if (!sample.valid) { - ImGui::TextWrapped("No controller found. Plug in a gamepad, or an RC radio with its USB mode set to Joystick (on " - "EdgeTX radios, pick \"USB Joystick (HID)\" when you plug it in)."); - } else { - ImGui::Text("Controller: %s", sample.name.c_str()); - ImGui::BeginDisabled(!sample.valid); - if (ImGui::Button("Set up controller", ImVec2(-FLT_MIN, 0))) c.start(); - ImGui::EndDisabled(); - ImGui::TextDisabled("Moves you through each stick and switch and saves which is which. The drone holds still meanwhile."); - } - if (!ImGui::CollapsingHeader("Manual setup")) return; - if (sample.valid) { - axis_bars(sample, -1); - std::string pressed; - for (int n = 1; n <= 32; ++n) - if (sample.buttons & (1u << (n - 1))) pressed += (pressed.empty() ? "" : " ") + std::to_string(n); - ImGui::Text("Buttons pressed: %s", pressed.empty() ? "none" : pressed.c_str()); - ImGui::TextDisabled("Move a stick or flip a switch to see which axis or button it is."); - } - ImGui::Separator(); - ImGui::TextUnformatted("Which axis each stick uses"); - static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; - static const char* const axes[] = {"Axis 1", "Axis 2", "Axis 3", "Axis 4", "Axis 5", "Axis 6"}; - auto map = drone::radio::mapping_snapshot(); - bool changed = false; - for (int i = 0; i < 4; ++i) { - ImGui::PushID(i); - int axis = std::clamp(std::abs(map[static_cast(i)]), 1, 6) - 1; - bool flip = map[static_cast(i)] < 0; - ImGui::SetNextItemWidth(ImGui::GetFontSize() * 7.0f); - if (ImGui::Combo(names[i], &axis, axes, 6)) changed = true; - ImGui::SameLine(); - if (ImGui::Checkbox("Reverse", &flip)) changed = true; - map[static_cast(i)] = (axis + 1) * (flip ? -1 : 1); - ImGui::PopID(); - } - if (changed) run(std::format("droneaxes {} {} {} {}", map[0], map[1], map[2], map[3])); - toggle(Knob::kThrottleCentre, "Throttle springs back to the middle (gamepads)"); - slider(Knob::kDeadzone, "Stick dead zone"); - ImGui::Separator(); - ImGui::TextUnformatted("Switches (use an axis or button no stick uses; 0 = none)"); - integer(Knob::kArmAxis, "Arm switch axis"); - integer(Knob::kArmButton, "Arm button"); - integer(Knob::kResetAxis, "Reset switch axis"); - integer(Knob::kResetButton, "Reset button"); -} - -void tab_lobby() { - if (!drone::beacon::server_allows().load()) { - ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.3f, 1.0f), "This server has drones turned off."); - ImGui::TextWrapped("You can still fly, but nobody here sees your drone and you don't see theirs."); - ImGui::Separator(); - } - ImGui::TextWrapped("Other players only see drones if they are running this version of ReSkate."); - toggle(Knob::kBeacon, "Let other players see my drone"); - toggle(Knob::kShowBeacons, "Show other players' drones"); -} -} // namespace - -void draw_drone_window() { - if (!drone::menu_flag().load()) return; - auto& s = state(); - const float k = scale(); - const auto P = [k](float value) { return value * k; }; - auto& io = ImGui::GetIO(); - const auto restore_font_scale = io.FontGlobalScale; - io.FontGlobalScale = k; - const auto* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(ImVec2(viewport->WorkPos.x + 16, viewport->WorkPos.y + 16), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(P(520), std::min(P(640), viewport->WorkSize.y - 32)), ImGuiCond_FirstUseEver); - ImGui::PushFont(s.menu.body); - const int colours = skate::push_widget_colours(); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0.0f); - bool open = true; - if (ImGui::Begin("FPV drone###ReSkateDrone", &open, ImGuiWindowFlags_NoCollapse)) { - if (ImGui::BeginTabBar("##drone_tabs")) { - if (ImGui::BeginTabItem("Fly")) { tab_fly(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Handling")) { tab_handling(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Rates")) { tab_rates(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Display")) { tab_display(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Controller")) { tab_radio(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Lobby")) { tab_lobby(); ImGui::EndTabItem(); } - ImGui::EndTabBar(); - } - } - ImGui::End(); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(colours); - ImGui::PopFont(); - io.FontGlobalScale = restore_font_scale; - if (!open) drone::menu_flag().store(false); -} -} // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/drone_page.cpp b/Extension/UI/Overlay/drone_page.cpp new file mode 100644 index 00000000..99582977 --- /dev/null +++ b/Extension/UI/Overlay/drone_page.cpp @@ -0,0 +1,463 @@ +#include "skate_menu_internal.h" +#include "overlay_internal.h" +#include "Extension/Skater/drone_flight.h" +#include "Extension/Skater/drone_beacon.h" +#include "Extension/Skater/drone_outfit.h" +#include +#include +#include +#include +#include + +// The DRONE page of the ReSkate menu: flying, handling, rates, the display, the controller and +// multiplayer. Every change takes effect at once and is saved by running the same `dronetune` +// command a player could type, so the console and this page always agree. + +namespace dingosdk::overlay::menu { +namespace { +namespace drone = dingosdk::drone; +using drone::Knob; + +// Runs a console command on the game's thread, quietly: a slider's save is not news. +void run(const CallbacksV3& callbacks, const std::string& command) { + if (!callbacks.queue_console_command) return; + std::array result{}; + callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size()); +} + +float get(Knob k) { return drone::knobs().value[k].load(std::memory_order_relaxed); } +void set(Knob k, float v) { drone::knobs().value[k].store(v, std::memory_order_relaxed); } +// Saves a setting by running `dronetune`, which also clamps it and keeps it for next time. +void save(const CallbacksV3& callbacks, Knob k) { run(callbacks, std::format("dronetune {} {}", drone::knob_info[k].name, get(k))); } +std::string tip(Knob k) { + return std::format("{}.\n\nConsole: dronetune {} (default {:g})", drone::knob_info[k].help, drone::knob_info[k].name, + drone::knob_info[k].def); +} + +void slider(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label, const char* format = "%.2f") { + const auto help = tip(k); + field(menu, label, help.c_str()); + float v = get(k); + ImGui::PushID(static_cast(k)); + if (ImGui::SliderFloat("##value", &v, drone::knob_info[k].lo, drone::knob_info[k].hi, format)) set(k, v); + if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); + ImGui::PopID(); +} +void integer(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label) { + const auto help = tip(k); + field(menu, label, help.c_str()); + int v = static_cast(std::lround(get(k))); + ImGui::PushID(static_cast(k)); + if (ImGui::SliderInt("##value", &v, static_cast(drone::knob_info[k].lo), static_cast(drone::knob_info[k].hi))) + set(k, static_cast(v)); + if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); + ImGui::PopID(); +} +void toggle(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label) { + const auto help = tip(k); + bool on = get(k) >= 0.5f; + if (toggle_row(menu, label, help.c_str(), on)) { set(k, on ? 1.0f : 0.0f); save(callbacks, k); } +} +void options(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label, std::initializer_list names) { + const auto help = tip(k); + field(menu, label, help.c_str()); + int v = std::clamp(static_cast(std::lround(get(k))), 0, static_cast(names.size()) - 1); + if (choice(menu, drone::knob_info[k].name, v, names)) { set(k, static_cast(v)); save(callbacks, k); } +} + +// ---- FLY +void fly_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + const bool enabled = drone::is_enabled(); + const bool radio_on = drone::radio::config().enabled.load(); + const bool freecam = model.debug.free_camera; + const bool flying = enabled && freecam; + + begin_card(menu, "drone-fly", "FLY"); + // The game's interface is hidden for the flight (the `hideui` drone setting) and only + // brought back if the drone was what hid it. + static bool hid_ui = false; + if (primary_button(menu, flying ? "STOP FLYING" : "START FLYING", model.debug.camera_available)) { + if (flying) { + run(callbacks, "dronemode 0"); + run(callbacks, "freecam 0"); // back to the skater + if (hid_ui) run(callbacks, "hideui 0"); + hid_ui = false; + } else { + if (!freecam) run(callbacks, "freecam 1"); + if (!enabled) run(callbacks, "dronemode 1"); + if (get(Knob::kHideUi) >= 0.5f && !model.debug.game_ui_hidden) { run(callbacks, "hideui 1"); hid_ui = true; } + } + } + if (!model.debug.camera_available) warn("Load into a level to fly."); + else if (flying) note(radio_on ? "Flying with the controller. Close the menu to fly." : "Flying with the keyboard and mouse. Close the menu to fly."); + else if (enabled) note("Drone mode is on, but freecam is off: press Start flying."); + else note("Keyboard: W/S tilt forward/back, A/D roll, E/Q throttle up/down, mouse turns, Shift boosts."); + bool radio = radio_on; + if (toggle_row(menu, "Fly with a controller", "An RC radio in USB Joystick mode, or a gamepad. Set it up on the CONTROLLER tab.", radio)) + run(callbacks, radio ? "droneradio 1" : "droneradio 0"); + if (!radio_on && drone::radio::poll().valid) + warn("A controller is connected. Turn on Fly with a controller to use it, or the game keeps using it for your skater."); + toggle(menu, callbacks, Knob::kHideUi, "Hide the game's interface while flying"); + end_card(); + + begin_card(menu, "drone-quad", "DRONE"); + { // Presets (drone_flight.h): a whole drone in one choice. "Custom" once anything is changed. + const int current = drone::matching_preset(model.debug.camera_speed); + field(menu, "Preset", "A whole drone in one choice: handling, rates, size, camera tilt, motor sound and top speed."); + if (ImGui::BeginCombo("##preset", current >= 0 ? drone::presets[current].name : "Custom")) { + for (std::size_t p = 0; p < drone::preset_count; ++p) { + const auto& preset = drone::presets[p]; + if (ImGui::Selectable(preset.name, static_cast(p) == current)) { + for (const auto& v : preset.values) { set(v.knob, v.value); save(callbacks, v.knob); } + run(callbacks, std::format("flyspeed {}", preset.top_speed)); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", preset.description); + } + ImGui::EndCombo(); + } + note(current >= 0 ? drone::presets[current].description : "Your own settings. Pick a preset to start from one of them."); + } + { // What the pilot's skater wears over their eyes while flying. + const int worn = std::clamp(static_cast(std::lround(get(Knob::kGoggles))), 0, static_cast(drone::outfit::eyewear_count)); + field(menu, "Goggles", "Your skater wears these while you fly. Your own eyewear comes back when you land."); + if (ImGui::BeginCombo("##goggles", worn ? drone::outfit::eyewear[worn - 1].label : "None")) { + if (ImGui::Selectable("None", worn == 0)) { set(Knob::kGoggles, 0.0f); save(callbacks, Knob::kGoggles); } + for (std::size_t i = 0; i < drone::outfit::eyewear_count; ++i) + if (ImGui::Selectable(drone::outfit::eyewear[i].label, worn == static_cast(i) + 1)) { + set(Knob::kGoggles, static_cast(i + 1)); + save(callbacks, Knob::kGoggles); + } + ImGui::EndCombo(); + } + } + { // The game's value comes back every frame; hold the one being set until the game has it, + // or the slider would snap back while it is dragged. + static float pending = -1.0f; + static double sent_at = 0.0; + if (pending >= 0.0f && (std::abs(model.debug.camera_speed - pending) < 0.01f || + (sent_at > 0.0 && ImGui::GetTime() - sent_at > 1.0))) { pending = -1.0f; sent_at = 0.0; } + float top = pending >= 0.0f ? pending : model.debug.camera_speed; + field(menu, "Top speed", "The same setting as freecam's flight speed.\n\nConsole: flyspeed"); + if (ImGui::SliderFloat("##top-speed", &top, 0.6f, 300.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) pending = top; + if (ImGui::IsItemDeactivatedAfterEdit()) { run(callbacks, std::format("flyspeed {}", top)); sent_at = ImGui::GetTime(); } + // The flight model caps speed at four times the fly speed (m/s), twice that with boost. + const auto speed = std::format("Up to about {:.0f} km/h (twice that with boost).", top * 4.0f * 3.6f); + note(speed.c_str()); + } + end_card(); + + begin_card(menu, "drone-motors", "MOTORS"); + const bool armed = drone::armed(); + info(menu, "Motors", armed ? "ARMED" : "DISARMED"); + if (drone::flying()) { + const auto& t = drone::telemetry(); + info(menu, "Flight", std::format("{:.0f}% throttle, {:.0f} km/h, {:.1f} m up, {:.0f} s", t.throttle.load() * 100.0f, + t.speed.load() * 3.6f, t.altitude.load(), t.seconds.load())); + } + const float third = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; + if (ImGui::Button(armed ? "Disarm" : "Arm", ImVec2(third, 0))) drone::set_armed(!armed); + ImGui::SameLine(); + if (ImGui::Button("Back to takeoff", ImVec2(third, 0))) drone::request_reset(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts the drone back where it took off, with a fresh battery and timer."); + ImGui::SameLine(); + if (ImGui::Button("Reset settings", ImVec2(third, 0))) run(callbacks, "dronetune reset"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts every drone setting back to its default."); + end_card(); + + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 170, 170, 255)); + ImGui::TextUnformatted("Created by neekgate for ReSkate"); + ImGui::PopStyleColor(); +} + +// ---- HANDLING +void handling_tab(SkateMenu& menu, const CallbacksV3& callbacks) { + begin_card(menu, "drone-flight", "FLIGHT"); + slider(menu, callbacks, Knob::kGravity, "Gravity", "%.1f"); + slider(menu, callbacks, Knob::kHover, "Hover throttle"); + slider(menu, callbacks, Knob::kDrag, "Air drag"); + slider(menu, callbacks, Knob::kUptilt, "Camera tilt", "%.0f degrees"); + end_card(); + + begin_card(menu, "drone-collisions", "COLLISIONS"); + toggle(menu, callbacks, Knob::kCollide, "Collide with the world"); + slider(menu, callbacks, Knob::kRadius, "Collision size", "%.2f m"); + slider(menu, callbacks, Knob::kFloor, "Invisible floor", "%.1f m"); + toggle(menu, callbacks, Knob::kDisarmFreeze, "Hang in the air when disarmed"); + end_card(); + + begin_card(menu, "drone-range", "RANGE"); + bool limited = get(Knob::kRange) > 0.0f; + if (toggle_row(menu, "Limited range", + "Like a real radio and video link: the picture breaks up as you near the limit, and further out the motors cut " + "(failsafe) until you reset. Off: fly anywhere on the map.", limited)) { + set(Knob::kRange, limited ? 300.0f : 0.0f); + save(callbacks, Knob::kRange); + } + if (limited) { + field(menu, "Range"); + float metres = get(Knob::kRange); + if (ImGui::SliderFloat("##range", &metres, 50.0f, 5000.0f, "%.0f m", ImGuiSliderFlags_Logarithmic)) set(Knob::kRange, metres); + if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, Knob::kRange); + const auto explained = std::format( + "Measured from where you took off. The picture starts to break up at {:.0f} m and is lost at {:.0f} m. Stay without a " + "picture for {:.0f} seconds, or go past {:.0f} m, and the motors cut.", + get(Knob::kRange) * drone::range_clean, get(Knob::kRange), drone::failsafe_after_seconds, + get(Knob::kRange) * drone::range_failsafe); + note(explained.c_str()); + } else { + note("Unlimited: fly anywhere on the map."); + } + end_card(); +} + +// ---- RATES +void rates_card(SkateMenu& menu, const CallbacksV3& callbacks, const char* id, const char* title, Knob centre, Knob max, Knob expo) { + begin_card(menu, id, title); + slider(menu, callbacks, centre, "Centre sensitivity", "%.0f deg/s"); + slider(menu, callbacks, max, "Max rate", "%.0f deg/s"); + slider(menu, callbacks, expo, "Expo"); + end_card(); +} +void rates_tab(SkateMenu& menu, const CallbacksV3& callbacks) { + note("How fast the drone turns, set like Betaflight's \"Actual\" rates. Centre: the speed near the middle of the stick. " + "Max: the speed at full stick. Expo: how much softer the middle is."); + rates_card(menu, callbacks, "drone-roll", "ROLL", Knob::kRollCenter, Knob::kRollMax, Knob::kRollExpo); + rates_card(menu, callbacks, "drone-pitch", "PITCH", Knob::kPitchCenter, Knob::kPitchMax, Knob::kPitchExpo); + rates_card(menu, callbacks, "drone-yaw", "YAW", Knob::kYawCenter, Knob::kYawMax, Knob::kYawExpo); +} + +// ---- DISPLAY +void display_tab(SkateMenu& menu, const CallbacksV3& callbacks) { + begin_card(menu, "drone-osd", "GOGGLE DISPLAY", "the OSD"); + toggle(menu, callbacks, Knob::kOsd, "Show the goggle display"); + slider(menu, callbacks, Knob::kOsdScale, "Text size"); + options(menu, callbacks, Knob::kOsdColor, "Colour", {"White", "Green", "Yellow", "Cyan", "Orange", "Pink"}); + options(menu, callbacks, Knob::kOsdFont, "Font", {"Pixel", "Smooth"}); + slider(menu, callbacks, Knob::kOsdMargin, "Distance from the edge"); + toggle(menu, callbacks, Knob::kCrosshair, "Crosshair"); + end_card(); + + begin_card(menu, "drone-osd-show", "SHOW"); + toggle(menu, callbacks, Knob::kShowArmed, "Armed / disarmed"); + toggle(menu, callbacks, Knob::kShowBattery, "Battery"); + toggle(menu, callbacks, Knob::kShowTimer, "Timer"); + toggle(menu, callbacks, Knob::kShowThrottle, "Throttle"); + toggle(menu, callbacks, Knob::kShowAlt, "Altitude"); + toggle(menu, callbacks, Knob::kShowSpeed, "Speed"); + toggle(menu, callbacks, Knob::kSticks, "My sticks on screen"); + slider(menu, callbacks, Knob::kStickSize, "Sticks size"); + end_card(); + + begin_card(menu, "drone-analog", "ANALOGUE VIDEO"); + toggle(menu, callbacks, Knob::kAnalog, "Analogue video look"); + slider(menu, callbacks, Knob::kAnalogNoise, "Static"); + slider(menu, callbacks, Knob::kAnalogScan, "Scanlines"); + slider(menu, callbacks, Knob::kAnalogVignette, "Dark corners"); + slider(menu, callbacks, Knob::kAnalogGlitch, "Rolling glitches"); + end_card(); + + begin_card(menu, "drone-sound", "SOUND"); + slider(menu, callbacks, Knob::kVolume, "Motor volume"); + slider(menu, callbacks, Knob::kMotorPitch, "Motor pitch"); + end_card(); +} + +// ---- CONTROLLER +const char* axis_text(int signed_axis, char* buffer, std::size_t size) { + if (signed_axis == 0) std::snprintf(buffer, size, "none"); + else std::snprintf(buffer, size, "axis %d%s", std::abs(signed_axis), signed_axis < 0 ? " (reversed)" : ""); + return buffer; +} +// Live bars for the six axes; `highlight` (0-5) is drawn as the one being picked. +void axis_bars(const drone::radio::Sample& sample, int highlight) { + for (int i = 0; i < 6; ++i) { + const auto v = sample.axes[static_cast(i)]; + char value[16]; + std::snprintf(value, sizeof value, "%+.2f", static_cast(v)); + const bool lit = i == highlight; + if (lit) ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(120, 220, 120, 255)); + ImGui::ProgressBar((v + 1.0f) * 0.5f, ImVec2(ImGui::GetContentRegionAvail().x * 0.75f, 0), value); + if (lit) ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::Text("Axis %d%s", i + 1, lit ? " <" : ""); + } +} +// The guided setup (drone_radio.h: Calibration). Saves through the same commands as the manual controls. +void setup_wizard(SkateMenu& menu, const CallbacksV3& callbacks, const drone::radio::Sample& sample) { + using Step = drone::radio::Calibration::Step; + auto& c = drone::radio::calibration(); + if (sample.valid) c.update(sample.axes, sample.buttons, ImGui::GetIO().DeltaTime); + const auto step = c.step(); + if (c.active()) drone::radio::mark_calibrating(); + const char* title = ""; + const char* body = ""; + switch (step) { + case Step::rest: + title = "STEP 1 OF 7: STICKS AT REST"; + body = "Let go of both sticks so they centre, and put the throttle all the way down (a gamepad's sticks just centre). " + "Then press Ready."; + break; + case Step::throttle: + title = "STEP 2 OF 7: THROTTLE"; + body = c.waiting_for_return() ? "Got it. Now let the throttle back down." + : "Push the throttle all the way up and hold it there. On a gamepad, that's the left stick, up."; + break; + case Step::yaw: + title = "STEP 3 OF 7: YAW"; + body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the yaw stick (left stick, sideways) fully right and hold it."; + break; + case Step::pitch: + title = "STEP 4 OF 7: PITCH"; + body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully forward (away from you) and hold it."; + break; + case Step::roll: + title = "STEP 5 OF 7: ROLL"; + body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully right and hold it."; + break; + case Step::arm: + title = "STEP 6 OF 7: ARM SWITCH (OPTIONAL)"; + body = "Flip the switch you want to arm the motors with to its ARMED position. No arm switch? Press Skip."; + break; + case Step::reset: + title = "STEP 7 OF 7: RESET SWITCH (OPTIONAL)"; + body = c.waiting_for_return() ? "Got it. Let the switch go back." + : "Flick the switch or press the button you want to put the drone back at its start. No reset switch? Press Skip."; + break; + default: break; + } + if (step == Step::done) { + // Save once, then show what was found. + static bool saved = false; + if (!saved) { + const auto m = c.mapping(); + run(callbacks, std::format("droneaxes {} {} {} {}", m[0], m[1], m[2], m[3])); + set(Knob::kArmAxis, static_cast(c.arm_axis())); save(callbacks, Knob::kArmAxis); + set(Knob::kArmButton, static_cast(c.arm_button())); save(callbacks, Knob::kArmButton); + set(Knob::kResetAxis, static_cast(c.reset_axis())); save(callbacks, Knob::kResetAxis); + set(Knob::kResetButton, static_cast(c.reset_button())); save(callbacks, Knob::kResetButton); + set(Knob::kThrottleCentre, c.throttle_centred() ? 1.0f : 0.0f); save(callbacks, Knob::kThrottleCentre); + // Gamepad sticks drift more than a radio's gimbals. + if (c.throttle_centred() && get(Knob::kDeadzone) < 0.12f) { set(Knob::kDeadzone, 0.12f); save(callbacks, Knob::kDeadzone); } + // Setting a controller up means flying with it. + if (!drone::radio::config().enabled.load()) run(callbacks, "droneradio 1"); + saved = true; + } + begin_card(menu, "drone-setup-done", "SETUP SAVED", "the drone now flies with this controller"); + const auto m = c.mapping(); + static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; + char buffer[32]; + for (int i = 0; i < 4; ++i) info(menu, names[i], axis_text(m[static_cast(i)], buffer, sizeof buffer)); + const auto switch_text = [&buffer](int axis, int button) -> std::string { + if (button > 0) return std::format("button {}", button); + return axis_text(axis, buffer, sizeof buffer); + }; + info(menu, "Arm switch", switch_text(c.arm_axis(), c.arm_button())); + info(menu, "Reset switch", switch_text(c.reset_axis(), c.reset_button())); + if (c.throttle_centred()) + note("Your throttle springs back to the middle, so the middle now holds the drone up: push up to climb, down to descend."); + if (primary_button(menu, "DONE")) { c.cancel(); saved = false; } + end_card(); + return; + } + begin_card(menu, "drone-setup", title); + note(body); + if (!sample.valid) warn("The controller is not connected."); + else axis_bars(sample, c.waiting_for_return() ? -1 : c.candidate()); + const float third = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; + if (step == Step::rest) { + ImGui::BeginDisabled(!sample.valid); + if (ImGui::Button("Ready", ImVec2(third, 0))) c.capture_rest(sample.axes, sample.buttons); + ImGui::EndDisabled(); + ImGui::SameLine(); + } else if ((step == Step::arm || step == Step::reset) && !c.waiting_for_return()) { + if (ImGui::Button("Skip", ImVec2(third, 0))) c.skip(); + ImGui::SameLine(); + } + if (ImGui::Button("Cancel", ImVec2(third, 0))) c.cancel(); + end_card(); +} +void controller_tab(SkateMenu& menu, const CallbacksV3& callbacks) { + const auto sample = drone::radio::poll(); + auto& c = drone::radio::calibration(); + if (c.step() != drone::radio::Calibration::Step::idle) { + setup_wizard(menu, callbacks, sample); + return; + } + begin_card(menu, "drone-controller", "CONTROLLER"); + if (!sample.valid) { + warn("No controller found. Plug in a gamepad, or an RC radio with its USB mode set to Joystick (on EdgeTX radios, pick " + "\"USB Joystick (HID)\" when you plug it in)."); + } else { + info(menu, "Connected", sample.name); + if (primary_button(menu, "SET UP CONTROLLER")) c.start(); + note("Goes through each stick and switch and saves which is which. The drone holds still meanwhile."); + } + end_card(); + + begin_card(menu, "drone-manual", "MANUAL SETUP"); + if (sample.valid) { + axis_bars(sample, -1); + std::string pressed; + for (int n = 1; n <= 32; ++n) + if (sample.buttons & (1u << (n - 1))) pressed += (pressed.empty() ? "" : " ") + std::to_string(n); + info(menu, "Buttons pressed", pressed.empty() ? "none" : pressed); + note("Move a stick or flip a switch to see which axis or button it is."); + } + static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; + static const char* const axes[] = {"Axis 1", "Axis 2", "Axis 3", "Axis 4", "Axis 5", "Axis 6"}; + auto map = drone::radio::mapping_snapshot(); + bool changed = false; + for (int i = 0; i < 4; ++i) { + ImGui::PushID(i); + int axis = std::clamp(std::abs(map[static_cast(i)]), 1, 6) - 1; + bool flip = map[static_cast(i)] < 0; + field(menu, names[i]); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.6f); + if (ImGui::Combo("##axis", &axis, axes, 6)) changed = true; + ImGui::SameLine(); + if (ImGui::Checkbox("Reverse", &flip)) changed = true; + map[static_cast(i)] = (axis + 1) * (flip ? -1 : 1); + ImGui::PopID(); + } + if (changed) run(callbacks, std::format("droneaxes {} {} {} {}", map[0], map[1], map[2], map[3])); + toggle(menu, callbacks, Knob::kThrottleCentre, "Throttle springs back to the middle"); + slider(menu, callbacks, Knob::kDeadzone, "Stick dead zone"); + end_card(); + + begin_card(menu, "drone-switches", "SWITCHES", "an axis or button no stick uses; 0 = none"); + integer(menu, callbacks, Knob::kArmAxis, "Arm switch axis"); + integer(menu, callbacks, Knob::kArmButton, "Arm button"); + integer(menu, callbacks, Knob::kResetAxis, "Reset switch axis"); + integer(menu, callbacks, Knob::kResetButton, "Reset button"); + end_card(); +} + +// ---- LOBBY +void lobby_tab(SkateMenu& menu, const CallbacksV3& callbacks) { + begin_card(menu, "drone-lobby", "OTHER PLAYERS"); + if (!drone::beacon::server_allows().load()) { + warn("This server has drones turned off. You can still fly, but nobody here sees your drone and you don't see theirs."); + } + toggle(menu, callbacks, Knob::kBeacon, "Let other players see my drone"); + toggle(menu, callbacks, Knob::kShowBeacons, "Show other players' drones"); + note("Other players only see drones if they are on a version of ReSkate with the drone."); + end_card(); +} +} // namespace + +void drone_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { + category_tabs(menu, menu.drone_tab, {"FLY", "HANDLING", "RATES", "DISPLAY", "CONTROLLER", "LOBBY"}, "drone-tabs"); + ImGui::PushID(menu.drone_tab); + ImGui::BeginChild("drone-tab", ImVec2(0, page_body_height(menu))); + switch (menu.drone_tab) { + case 0: fly_tab(menu, model, callbacks); break; + case 1: handling_tab(menu, callbacks); break; + case 2: rates_tab(menu, callbacks); break; + case 3: display_tab(menu, callbacks); break; + case 4: controller_tab(menu, callbacks); break; + default: lobby_tab(menu, callbacks); break; + } + ImGui::EndChild(); + ImGui::PopID(); +} +} // namespace dingosdk::overlay::menu diff --git a/Extension/UI/Overlay/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index 09c6527c..e98fa851 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -315,7 +315,7 @@ void draw_nametags() { const auto dim = with_alpha(IM_COL32(230, 230, 230, 255), 0.85f * alpha); draw->AddText(font, detail, ImVec2(distance_at.x + offset, distance_at.y + offset), shadow, distance.c_str()); draw->AddText(font, detail, distance_at, dim, distance.c_str()); - // Flying the FPV drone: said above the name, unless drones are hidden (drone window, Lobby). + // Flying the FPV drone: said above the name, unless drones are hidden (DRONE page, LOBBY tab). if (tag.flying_drone && dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f) { const char *label = "FLYING DRONE"; diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index a4000eb2..0bede0a5 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -321,8 +321,6 @@ void draw_nametags(); bool perf_hud_pending(); void draw_perf_hud(); void draw_perf_window(); -// The FPV drone settings window (drone_menu_overlay.cpp): drawn while the menu or console is open. -void draw_drone_window(); // The skater item grids' search bar and prompts (item_browser_overlay.cpp): polled every presented frame. enum class ItemBrowserKey { favorite, filter }; bool item_browser_pending(); diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 4c51f479..3cadc8bc 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -3,6 +3,7 @@ #include "Engine/Core/Profiling/profiler.h" #include "overlay_internal.h" #include "Extension/Trainer/trainer_page.h" +#include "Extension/Skater/drone_flight.h" #include "Extension/HallOfMeat/hall_of_meat_overlay.h" #include "park_previews.h" #include "chat_emotes.h" @@ -640,6 +641,7 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); const bool hub_frame = hub_page_pending(); if (trainer_open_requested()) s.visible.store(true); + if (dingosdk::drone::menu_flag().load()) s.visible.store(true); // `dronemenu`: the menu opens on its DRONE page const bool menu_frame = interactive_visible(s); if (!menu_frame) { if (s.ui_was_interactive) { @@ -704,7 +706,6 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_menu(); draw_console(); draw_perf_window(); - draw_drone_window(); } draw_hub_page(); draw_hall_of_meat(); diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 400c0db1..00e25494 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -1,5 +1,6 @@ #include "Engine/Core/Platform/launcher_support.h" #include "skate_menu_internal.h" +#include "Extension/Skater/drone_flight.h" #include "skate_menu.h" #include "modding_menu.h" #include "skate_style.h" @@ -278,11 +279,11 @@ ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) { &config, ranges); } -enum Page { map, world, build, skater, training, multiplayer, progress, mods, music, settings, special, developer, page_count }; +enum Page { map, world, build, skater, training, drone, multiplayer, progress, mods, music, settings, special, developer, page_count }; constexpr std::array page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "MUSIC", "SETTINGS", "SPECIAL", "DEVELOPER"}; + "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "DRONE", "MULTIPLAYER", "PROGRESS", "MODS", "MUSIC", "SETTINGS", "SPECIAL", "DEVELOPER"}; constexpr std::array page_subtitles{ - "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", + "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", "Take it to the sky.", "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your sound, in the game.", "Your controls, your screen.", "Not everyone gets this page.", "Under the hood."}; } @@ -370,6 +371,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal } draw->AddText(menu.body, px(12), ImVec2(origin.x + px(18), origin.y + px(66)), muted, "YOUR SESSION. YOUR RULES."); if (trainer_page_wanted()) menu.page = training; + if (dingosdk::drone::menu_flag().exchange(false)) menu.page = drone; // `dronemenu` menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); // Developer (the network view) is hidden for now; offline there is no Multiplayer either. // Special is only there for a player the backend lists. @@ -429,6 +431,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal case build: build_page(menu, model, callbacks); break; case skater: skater_page(menu, model, callbacks); break; case training: trainer_page(menu, model, callbacks); break; + case drone: drone_page(menu, model, callbacks); break; case multiplayer: multiplayer_page(menu, model, callbacks); break; case progress: progression_page(menu, model, callbacks); break; case mods: diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index 99feff1a..fd85eaf9 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -83,6 +83,7 @@ struct SkateMenu { int world_layer_category = 0; std::array world_layer_search{}; int progression_tab = 0; + int drone_tab = 0; // the DRONE page (drone_page.cpp) int bus_stop_map = 0; int challenge_type = 0; std::array challenge_search{}; diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index 3f5e53dd..56e39104 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -62,6 +62,8 @@ void graphics_page(SkateMenu&, const Model&, const CallbacksV3&); void developer_page(SkateMenu&, const Model&, const CallbacksV3&); // Extension/Music/music_page.cpp void music_page(SkateMenu&, const Model&, const CallbacksV3&); +// The FPV drone (drone_page.cpp). +void drone_page(SkateMenu&, const Model&, const CallbacksV3&); // Extension/Trainer/trainer_page.cpp void trainer_page(SkateMenu&, const Model&, const CallbacksV3&); bool trainer_page_wanted(); diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 82e4ae79..38b43cb6 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -16,7 +16,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/hub_page.cpp Extension/UI/Overlay/game_text_overlay.cpp Extension/UI/Overlay/drone_hud_overlay.cpp - Extension/UI/Overlay/drone_menu_overlay.cpp + Extension/UI/Overlay/drone_page.cpp Extension/UI/Overlay/perf_overlay.cpp Extension/UI/Overlay/skate_hud_overlay.cpp Extension/UI/Overlay/nametag_overlay.cpp