Files
ReSkate/Extension/UI/Overlay/drone_page.cpp
T
AyeZeeBB 4c07f2493b Menu: a full screen layout for controllers beside the window, and the pages regrouped
The ReSkate menu now has two styles, switched with View / F11 or the buttons in its top bar
and saved with the profile (`ui fullscreen`):
- Window: the mouse menu as before, with a side rail of pages in groups.
- Full screen: made for a controller, in the pause menu's look. Pages across the top (LB / RB),
  each page's sections as the ONLINE page's tabs (LT / RT), button prompts at the bottom, the
  pause menu's sounds, and the pause menu's own Multiplayer pages as ONLINE.

The pages are regrouped: HOME (quick switches and a tile for each page), MAP, BUILD, SKATER
(freecam, first person, cheats), SANDBOX (once Trainer), ADDONS (drone, Board Break, Road
Rash, Hall of Meat, grind exits, music), ONLINE, PROGRESS, MODS, SETTINGS, DEVELOPER.

Full screen, every shared control is drawn for a pad: switches and option rows changed with
left / right one step a press, sliders as steppers that repeat when held, rows of buttons,
and pages that scroll with the right stick. Search boxes and typed values stay in the window.
A pad press takes the menu's focus back and hides the pointer until the mouse moves.

Also: map pictures in the map picker (official loading screens, a mod's icon) with a search
box in the window, Splash and Tutorial Island left out of it; number boxes beside the
window's sliders; the binds in groups, with an action bind to open the menu (`menu`); a menu
size for each style (`ui scalefull`); the time of day stepping from the one last asked for.
2026-10-16 23:37:33 -05:00

574 lines
31 KiB
C++

#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 <algorithm>
#include <cmath>
#include <cstdio>
#include <format>
#include <string>
// 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<char, 256> 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<int>(k));
if (::dingosdk::overlay::menu::slider_float("##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<int>(std::lround(get(k)));
ImGui::PushID(static_cast<int>(k));
if (::dingosdk::overlay::menu::slider_int("##value", &v, static_cast<int>(drone::knob_info[k].lo), static_cast<int>(drone::knob_info[k].hi)))
set(k, static_cast<float>(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<const char*> names) {
const auto help = tip(k);
field(menu, label, help.c_str());
int v = std::clamp(static_cast<int>(std::lround(get(k))), 0, static_cast<int>(names.size()) - 1);
if (choice(menu, drone::knob_info[k].name, v, names)) { set(k, static_cast<float>(v)); save(callbacks, k); }
}
// ---- FLY
// The game's interface is hidden for a flight or a follow (the `hideui` drone setting) and only
// brought back if the drone was what hid it.
bool hid_ui = false;
void hide_ui_for_drone(const Model& model, const CallbacksV3& callbacks) {
if (get(Knob::kHideUi) >= 0.5f && !model.debug.game_ui_hidden) { run(callbacks, "hideui 1"); hid_ui = true; }
}
void bring_back_ui(const CallbacksV3& callbacks) {
if (hid_ui) run(callbacks, "hideui 0");
hid_ui = false;
}
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");
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
bring_back_ui(callbacks);
} else {
if (!freecam) run(callbacks, "freecam 1");
if (!enabled) run(callbacks, "dronemode 1");
hide_ui_for_drone(model, callbacks);
}
}
const bool gamepad = drone::gamepad_mode();
if (!model.debug.camera_available) warn("Load into a level to fly.");
else if (flying) note(!radio_on ? "Flying with the keyboard and mouse. Close the menu to fly."
: gamepad ? (overlay::read_gamepad().valid ? "Flying with the gamepad. Close the menu to fly."
: "No gamepad found: flying with the keyboard and mouse. Close the menu to fly.")
: "Flying with the controller. 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) {
options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"});
if (gamepad)
note("Left stick: throttle (the middle hovers) and turning. Right stick: tilt forward, back and sideways. "
"A (cross) arms and disarms, Menu (Options) puts the drone back.");
}
if (!radio_on && (drone::radio::poll().valid || overlay::read_gamepad().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 or following");
end_card();
// Drone follow (Skater/drone_follow.h): the drone flies itself behind the skater.
begin_card(menu, "drone-follow", "FOLLOW ME", "skate while it films you");
const bool following = model.debug.drone_follow;
if (primary_button(menu, following ? "STOP FOLLOWING" : "FOLLOW ME", model.debug.camera_available)) {
run(callbacks, following ? "dronefollow 0" : "dronefollow 1");
if (following) bring_back_ui(callbacks);
else hide_ui_for_drone(model, callbacks);
}
if (!model.debug.camera_available) warn("Load into a level to use it.");
else note(following ? "Skate as normal: the drone flies itself behind you, as a pilot would."
: "The drone takes off behind you and flies itself, keeping you in shot while you skate. "
"It uses your drone's settings, flown self-levelling.");
// (Read every tick: a change shows while it follows.)
slider(menu, callbacks, Knob::kFollowDistance, "Follow distance", "%.1f m");
slider(menu, callbacks, Knob::kFollowAggression, "Aggressiveness");
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<int>(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<int>(std::lround(get(Knob::kGoggles))), 0, static_cast<int>(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<int>(i) + 1)) {
set(Knob::kGoggles, static_cast<float>(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 (::dingosdk::overlay::menu::slider_float("##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's drag is set so full throttle flat out settles near four times the fly speed (m/s).
const auto speed = std::format("Flat out at about {:.0f} km/h (a little faster 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-mode", "FLIGHT MODE");
options(menu, callbacks, Knob::kFlightMode, "Mode", {"Acro", "Angle (self-levelling)"});
if (get(Knob::kFlightMode) >= 0.5f) {
slider(menu, callbacks, Knob::kMaxAngle, "Most lean", "%.0f degrees");
note("The sticks set how far the drone leans; let go and it levels out. Easier to learn on, but no flips.");
} else {
note("The sticks set how fast the drone turns, and it stays at whatever angle you leave it: how real FPV pilots fly.");
}
toggle(menu, callbacks, Knob::kAltHold, "Hold height with a springing throttle");
slider(menu, callbacks, Knob::kClimbRate, "Climb speed", "%.1f m/s");
note("For gamepads and the keyboard, whose throttle springs back. Off: the middle of the stick is hover power, and the "
"throttle never changes by itself. On: the middle holds your height, adding throttle as you lean to keep it. "
"An RC radio's throttle stays where you put it and is not affected.");
end_card();
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-crashes", "CRASHES");
toggle(menu, callbacks, Knob::kCrash, "Crashes");
if (get(Knob::kCrash) >= 0.5f) {
slider(menu, callbacks, Knob::kCrashSpeed, "Hit that cuts the motors", "%.1f m/s");
const auto cut = get(Knob::kCrashSpeed) > 0.0f
? std::format("Bumps bounce the drone off and knock it about. Hitting something at {:.0f} km/h or more cuts the motors: "
"it tumbles down, and you arm again to fly on.", get(Knob::kCrashSpeed) * 3.6f)
: std::string("Bumps bounce the drone off and knock it about, but never cut the motors.");
note(cut.c_str());
} else {
note("Off: the drone just stops against whatever it meets.");
}
slider(menu, callbacks, Knob::kPropwash, "Prop wash shake");
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 (::dingosdk::overlay::menu::slider_float("##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<std::size_t>(i)];
char value[16];
std::snprintf(value, sizeof value, "%+.2f", static_cast<double>(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<float>(c.arm_axis())); save(callbacks, Knob::kArmAxis);
set(Knob::kArmButton, static_cast<float>(c.arm_button())); save(callbacks, Knob::kArmButton);
set(Knob::kResetAxis, static_cast<float>(c.reset_axis())); save(callbacks, Knob::kResetAxis);
set(Knob::kResetButton, static_cast<float>(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<std::size_t>(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();
}
// A gamepad (drone_flight.h: gamepad_sticks): a fixed Mode 2 layout, so nothing to set up but feel.
void gamepad_tab(SkateMenu& menu, const CallbacksV3& callbacks) {
const auto pad = overlay::read_gamepad();
begin_card(menu, "drone-gamepad", "GAMEPAD");
if (!pad.valid) {
warn("No gamepad found. Plug in an Xbox or PlayStation controller.");
} else {
info(menu, "Connected", pad.playstation ? "PlayStation controller" : "Xbox controller");
const auto sticks = drone::read_gamepad(pad);
const auto bar = [](const char* label, float v) {
char value[16];
std::snprintf(value, sizeof value, "%+.2f", static_cast<double>(v));
ImGui::ProgressBar((v + 1.0f) * 0.5f, ImVec2(ImGui::GetContentRegionAvail().x * 0.75f, 0), value);
ImGui::SameLine();
ImGui::TextUnformatted(label);
};
bar("Throttle", sticks.climb);
bar("Yaw", sticks.yaw);
bar("Pitch", sticks.pitch);
bar("Roll", sticks.roll);
note("What the drone gets from each stick, after the dead zone and sensitivity below.");
}
note("Left stick: throttle up and down, turning left and right. Right stick: tilt (pitch, see Invert below) and roll. "
"A (cross) arms and disarms, Menu (Options) puts the drone back where it took off.");
end_card();
begin_card(menu, "drone-gamepad-feel", "STICKS");
slider(menu, callbacks, Knob::kPadRate, "Sensitivity");
slider(menu, callbacks, Knob::kPadExpo, "Softer middle");
slider(menu, callbacks, Knob::kDeadzone, "Stick dead zone");
toggle(menu, callbacks, Knob::kPadInvert, "Invert the right stick (up tips the nose up)");
note("Sensitivity scales how fast the drone can turn at full stick (your RATES still shape it). A softer middle "
"makes small corrections gentler without slowing full-stick flips. Gamepads use a dead zone of at least 0.08.");
end_card();
}
void controller_tab(SkateMenu& menu, const CallbacksV3& callbacks) {
if (drone::gamepad_mode()) {
options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"});
gamepad_tab(menu, callbacks);
return;
}
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;
}
options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"});
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<std::size_t>(i)]), 1, 6) - 1;
bool flip = map[static_cast<std::size_t>(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<std::size_t>(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.");
note("Drone model: FPV Drone by PinaPipe (CC BY 4.0), reduced and rescaled for the game.");
end_card();
}
} // namespace
void drone_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) {
category_tabs(menu, menu.drone_tab, {"FLY", "FLIGHT", "DISPLAY", "CONTROLLER"}, "drone-tabs");
ImGui::PushID(menu.drone_tab);
ImGui::BeginChild("drone-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened);
switch (menu.drone_tab) {
case 0: fly_tab(menu, model, callbacks); break;
case 1:
handling_tab(menu, callbacks);
rates_tab(menu, callbacks);
break;
case 2:
display_tab(menu, callbacks);
lobby_tab(menu, callbacks);
break;
default: controller_tab(menu, callbacks); break;
}
ImGui::EndChild();
ImGui::PopID();
}
} // namespace dingosdk::overlay::menu