From 57d98307a70c468a67804f67ab96658dd0ba5aa4 Mon Sep 17 00:00:00 2001 From: Aldo Date: Tue, 13 Oct 2026 21:59:49 +0100 Subject: [PATCH 1/3] Skater: FPV drone mode An FPV drone flown from the free camera: real quad physics (thrust, gravity, drag, Betaflight "Actual" rates), collisions with the world, and a goggle-style display. Open it with `dronemenu` (or the drone window's Start flying). Flying - Keyboard and mouse, gamepads, or USB RC radios (EdgeTX etc. in Joystick mode). "Set up controller" learns each stick and switch by asking for one at a time, reversed axes and springing gamepad throttles included. While a controller flies the drone, the game is kept off it, as freecam's controller mode does. - Presets (Tinywhoop, Toothpick, 5 inch), arm/disarm, back to takeoff, and every setting live through `dronetune`, saved in the profile. - Optional range: the picture breaks up and is lost with distance from the takeoff point; past it, failsafe cuts the motors. - OSD, on-screen sticks, an analogue video look, synthesised motor sound. - Goggles: the pilot's skater wears eyewear from the game's catalog while flying, and gets their own back on landing (Road Rash aware). - Start flying hides the game UI, Stop flying brings it back. Multiplayer - New packet `drone` (36): where a player's drone is and which way it faces, 15 times a second, unreliable, relayed like effects. Markers glide between updates. Protocol version 52. - FLYING DRONE above the name of a player who is flying. - Players choose whether to share their drone and whether to see others'. - Dedicated servers: `sync_drones` (players section) and `drones on|off`. Off: no drone packets are relayed, and players are told in the roster. Changes to existing code - `flyspeed` takes any speed from 0.6 to 1500, not only the seven listed (the drone's presets and top speed slider use speeds of their own). - Overlay: begin/end_input_access, so the drone's controller read sees the device while the game is kept off it. - Profile: local_surface_hit, the park editor's ray from one point to another, for the drone's collisions. Tests: Skater/Test/drone_radio_tests (controller setup), and the drone packet and roster rule in Multiplayer/Test/protocol_tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MArBL6UbYZPsp9rMnq2Ks5 --- CMakeLists.txt | 3 + Extension/Multiplayer/Hud/custom_nametags.cpp | 1 + Extension/Multiplayer/Hud/custom_nametags.h | 1 + Extension/Multiplayer/Net/protocol.cpp | 23 +- Extension/Multiplayer/Net/protocol.h | 29 +- Extension/Multiplayer/Session/session.cpp | 4 + .../Multiplayer/Session/session_internal.h | 1 + .../Multiplayer/Session/session_receive.cpp | 7 + .../Multiplayer/Session/session_send.cpp | 13 +- Extension/Multiplayer/Steam/steam_transport.h | 2 +- Extension/Multiplayer/Test/protocol_tests.cpp | 35 ++ Extension/Profile/local_profile_objects.cpp | 19 + Extension/Profile/local_profile_runtime.h | 4 + Extension/Skater/Test/drone_radio_tests.cpp | 71 +++ Extension/Skater/client_debug.cpp | 65 +- .../Skater/client_source_spawn_internal.h | 2 + Extension/Skater/console_commands.cpp | 206 ++++++- Extension/Skater/drone_audio.h | 120 ++++ Extension/Skater/drone_beacon.h | 217 +++++++ Extension/Skater/drone_flight.h | 556 ++++++++++++++++++ Extension/Skater/drone_font.h | 64 ++ Extension/Skater/drone_osd.h | 104 ++++ Extension/Skater/drone_outfit.cpp | 164 ++++++ Extension/Skater/drone_outfit.h | 31 + Extension/Skater/drone_radio.h | 336 +++++++++++ Extension/UI/Overlay/drone_hud_overlay.cpp | 350 +++++++++++ Extension/UI/Overlay/drone_menu_overlay.cpp | 466 +++++++++++++++ Extension/UI/Overlay/nametag_overlay.cpp | 13 +- Extension/UI/Overlay/overlay.h | 18 + Extension/UI/Overlay/overlay_input.cpp | 19 + Extension/UI/Overlay/overlay_internal.h | 5 + Extension/UI/Overlay/overlay_render.cpp | 5 +- Runtime/client_tick.cpp | 9 +- Server/README-roles.txt | 1 + Server/README.txt | 6 + Server/server_commands.cpp | 9 +- Server/server_config.cpp | 4 +- Server/server_config.h | 3 + Server/server_host.cpp | 5 +- Server/server_host.h | 1 + cmake/Runtime.cmake | 1 + cmake/UI.cmake | 2 + 42 files changed, 2972 insertions(+), 23 deletions(-) create mode 100644 Extension/Skater/Test/drone_radio_tests.cpp create mode 100644 Extension/Skater/drone_audio.h create mode 100644 Extension/Skater/drone_beacon.h create mode 100644 Extension/Skater/drone_flight.h create mode 100644 Extension/Skater/drone_font.h create mode 100644 Extension/Skater/drone_osd.h create mode 100644 Extension/Skater/drone_outfit.cpp create mode 100644 Extension/Skater/drone_outfit.h create mode 100644 Extension/Skater/drone_radio.h create mode 100644 Extension/UI/Overlay/drone_hud_overlay.cpp create mode 100644 Extension/UI/Overlay/drone_menu_overlay.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 007c40df..8e8dc725 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -165,6 +165,9 @@ endif() if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32) add_executable(dingosdk_velocity_boost_tests Engine/Game/Skater/Test/velocity_boost_tests.cpp) add_test(NAME velocity_boost COMMAND dingosdk_velocity_boost_tests) + add_executable(dingosdk_drone_radio_tests Extension/Skater/Test/drone_radio_tests.cpp) + target_include_directories(dingosdk_drone_radio_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME drone_radio COMMAND dingosdk_drone_radio_tests) if(EXISTS "${PROJECT_SOURCE_DIR}/tests/first_person_spring_tests.cpp") add_executable(dingosdk_first_person_spring_tests tests/first_person_spring_tests.cpp) target_include_directories(dingosdk_first_person_spring_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/Extension/Multiplayer/Hud/custom_nametags.cpp b/Extension/Multiplayer/Hud/custom_nametags.cpp index 0e1ac307..200b32af 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.cpp +++ b/Extension/Multiplayer/Hud/custom_nametags.cpp @@ -57,6 +57,7 @@ void publish_custom_nametags(std::uintptr_t base, std::vector pla tag.color = player.color; tag.tag = std::move(player.tag); tag.talking = player.talking; + tag.flying_drone = player.flying_drone; tag.bubbles.reserve(player.bubbles.size()); for (auto &line : player.bubbles) tag.bubbles.push_back({std::move(line.text), std::move(line.raw), line.appear, line.fade, line.hint}); diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index a7f84498..2ade0592 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -24,6 +24,7 @@ struct NametagPlayer { std::uint32_t color{0xffffffffU}; // IM_COL32 layout std::string tag; // role badge before the name, as in chat ("" for none) bool talking{}; + bool flying_drone{}; // flying ReSkate's FPV drone (Skater/drone_beacon.h) // The player's recent chat lines to show as bubbles above the head, oldest first. Empty // draws no bubbles. std::vector bubbles; diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index 1861d45f..705a87fe 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -402,6 +402,9 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid voice packet"); if (p.kind == PacketKind::effects && (!p.map || !p.source || !valid_impacts(p.impacts))) throw std::invalid_argument("Invalid effects packet"); + if (p.kind == PacketKind::drone && (!p.map || !p.source || !valid_drone_position(p.drone_position) || + !valid_drone_rotation(p.drone_rotation))) + throw std::invalid_argument("Invalid drone packet"); if (p.kind == PacketKind::chat && (!p.source || !valid_chat_text(p.text))) throw std::invalid_argument("Invalid chat message"); if (p.kind == PacketKind::typing && !p.source) @@ -477,7 +480,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning && p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras && p.kind != PacketKind::effects && p.kind != PacketKind::permissions && p.kind != PacketKind::words && - p.kind != PacketKind::next_maps && p.kind != PacketKind::typing) + p.kind != PacketKind::next_maps && p.kind != PacketKind::typing && + p.kind != PacketKind::drone) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -618,6 +622,11 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(impact.material, 2); } } + if (p.kind == PacketKind::drone) { + w.integer(p.drone_active ? 1 : 0, 1); + for (const auto v : p.drone_position) w.integer(std::bit_cast(v), 4); + for (const auto v : p.drone_rotation) w.integer(std::bit_cast(v), 4); + } if (p.kind == PacketKind::physics_extras) { w.integer(p.extras.size(), 2); w.bytes.insert(w.bytes.end(), p.extras.begin(), p.extras.end()); @@ -729,6 +738,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.object_limit, 2); w.integer(p.object_scaling ? 1 : 0, 1); w.integer(p.sync_effects ? 1 : 0, 1); + w.integer(p.sync_drones ? 1 : 0, 1); w.integer(p.force_world_layers, 1); // One mode per world-layer catalog row; both peers read the same catalog // from the same game build. @@ -991,6 +1001,14 @@ std::optional decode(std::span bytes) noexcept { impact.material = static_cast(r.integer(2)); } if (!valid_impacts(p.impacts)) return {}; + } else if (p.kind == PacketKind::drone) { + if (!p.map || !p.source || bytes.size() - r.at != drone_wire_size) return {}; + const auto flag = r.integer(1); + if (flag > 1) return {}; + p.drone_active = flag != 0; + for (auto &v : p.drone_position) v = r.number(); + for (auto &v : p.drone_rotation) v = r.number(); + if (!valid_drone_position(p.drone_position) || !valid_drone_rotation(p.drone_rotation)) return {}; } else if (p.kind == PacketKind::physics_extras) { const auto length = r.integer(2); if (!p.source || length > max_physics_extras || length != bytes.size() - r.at) @@ -1177,6 +1195,9 @@ std::optional decode(std::span bytes) noexcept { const auto shared_effects = r.integer(1); if (shared_effects > 1) return {}; p.sync_effects = shared_effects != 0; + const auto shared_drones = r.integer(1); + if (shared_drones > 1) return {}; + p.sync_drones = shared_drones != 0; const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 5739b9aa..daf0fa22 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -26,7 +26,7 @@ namespace dingosdk::multiplayer { constexpr std::size_t max_skater_bones = 512, max_board_bones = 64; constexpr std::size_t max_packet = 24576; constexpr std::size_t packet_header_size = 64; -constexpr std::uint16_t protocol_version = 51; +constexpr std::uint16_t protocol_version = 52; // How long a receiver keeps typing dots after the last "still typing" notice. The sender // clears them after three seconds without input; this only covers a lost "stopped" packet. constexpr std::uint64_t typing_hold_us = 4000000; @@ -149,8 +149,27 @@ enum class PacketKind : std::uint16_t { next_maps = 34, // The sender's chat box changed recently (typing true) or they stopped (false). // Relayed like chat. Not a chat line: receivers draw grey dots above that skater. - typing = 35 + typing = 35, + // Where a player's FPV drone is (drone_beacon.h), for the marker others see. Unreliable, + // relayed like effects. Only sent when the player turns `dronetune beacon` on. + drone = 36 }; +// A drone packet: a flag byte (1 = flying), the drone's position as three floats and which way +// it faces as a unit quaternion (four floats: x, y, z, w). +inline constexpr std::size_t drone_wire_size = 1 + 12 + 16; +inline bool valid_drone_rotation(const std::array &q) noexcept { + float length = 0.f; + for (const float v : q) { + if (!(v >= -1.01f && v <= 1.01f)) return false; // also false for NaN + length += v * v; + } + return length > 0.81f && length < 1.21f; +} +inline bool valid_drone_position(const std::array &p) noexcept { + for (const float v : p) + if (!(v > -1.0e6f && v < 1.0e6f)) return false; // also false for NaN + return true; +} // Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited // and withdrawn go from the host to the invitee (party_player = the inviter). The host @@ -331,6 +350,9 @@ struct Packet { // each other's costumes and skateboards). Off: a dedicated server relays none and games // neither send theirs nor show other players'. bool sync_effects{true}; + // Roster: players see each other's FPV drones (Skater/drone_beacon.h). Off: a dedicated server + // relays none, and games neither send theirs nor show other players'. + bool sync_drones{true}; // Bumped each time the host deletes all guest objects. Guests delete their // own session objects when it changes after their first roster. std::uint32_t object_clears{}; @@ -376,6 +398,9 @@ struct Packet { std::vector words; // words: a dedicated server's own filtered chat words std::vector next_maps; // next_maps: at most max_next_maps std::uint32_t role_permissions{}; + std::array drone_position{}; // drone: where the drone is (finite, within 1e6) + std::array drone_rotation{0.f, 0.f, 0.f, 1.f}; // drone: which way it faces (unit quaternion) + bool drone_active{}; // drone: flying now (false = it stopped) PartyAction party_action = PartyAction::leave; // party: what is asked or told std::uint64_t party_player{}; // party: the other player (see PartyAction) // scoring: the sender's scoring fingerprint, 0 for the game's own; `text` names the mods diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 9b811cfb..d166bf45 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,4 +1,5 @@ #include "session_internal.h" +#include "Extension/Skater/drone_beacon.h" #include "Engine/Core/Debug/trace.h" #include "Extension/Multiplayer/Hud/native_player_ui.h" #include "Extension/Multiplayer/Hud/native_indicators.h" @@ -82,6 +83,8 @@ void stop(Session &s, std::string reason) { s.object_limit = 0; s.object_scaling = true; s.sync_effects = true; + s.sync_drones = true; + drone::beacon::server_allows().store(true); set_lobby_object_limit(0); s.server_admin = false; s.role.clear(); @@ -513,6 +516,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui // Friends only: everyone else keeps their chat bubbles and loses the name and dot. tag.nameless = s.nametags_friends && !std::binary_search(s.friend_ids.begin(), s.friend_ids.end(), p.member.id); tag.talking = std::any_of(voices.begin(), voices.end(), [&](const auto &v) { return v.id == p.member.id && v.speaking; }); + tag.flying_drone = drone::beacon::flying(p.member.id); tag.bubbles = recent_bubbles(p.member.id); if (bubbles) if (auto hint = typing_bubble(p.typing_until); !hint.text.empty()) tag.bubbles.push_back(std::move(hint)); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 5b726972..58a2e802 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -168,6 +168,7 @@ struct Session { unsigned object_limit{}; // objects each player may have placed; 0: no limit bool object_scaling{true}; // the dedicated server lets players resize what they place bool sync_effects{true}; // the dedicated server has players see each other's skater effects + bool sync_drones{true}; // the dedicated server has players see each other's FPV drones // What guests may use: the host's choice, or the host's roster for a guest. bool guest_noclip = true, guest_no_bail = true, guest_boosts = true; // Host: guests skate with its physics tuning. Guest: the host's roster says so (a diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index ecb82eba..5997a303 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -1,5 +1,6 @@ #include "session_internal.h" #include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Skater/drone_beacon.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Engine/Game/Build/supported_build.h" #include @@ -280,6 +281,8 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) { apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin s.object_scaling = !dedicated_host(s) || p.object_scaling; s.sync_effects = !dedicated_host(s) || p.sync_effects; + s.sync_drones = !dedicated_host(s) || p.sync_drones; + drone::beacon::server_allows().store(s.sync_drones); apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts); s.enforce_tuning = p.enforce_tuning; s.server_votes = dedicated_host(s) ? p.server_votes : 0; @@ -370,6 +373,10 @@ bool accept_data(Peer &peer, const Packet &p, std::uint64_t now) { for (const auto &impact : p.impacts) peer.impacts.emplace_back(due, impact); while (peer.impacts.size() > 64) peer.impacts.pop_front(); } + } else if (p.kind == PacketKind::drone) { + accepted = drone::beacon::server_allows().load(std::memory_order_relaxed) && peer.effect_budget.accept(now); + if (accepted) + drone::beacon::receive(peer.member.id, peer.member.name, p.drone_position, p.drone_rotation, p.drone_active); } else if (p.kind == PacketKind::pose) accepted = peer.poses.push_validated(p, now); if (accepted) { diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 9fa1dc71..7674e570 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -8,6 +8,7 @@ #include "Engine/Game/Build/supported_build.h" #include "Engine/Core/Log/logging.h" #include "Extension/Skater/physics_tuning.h" +#include "Extension/Skater/drone_beacon.h" #include "Engine/Vfs/mod_scoring.h" #include "Extension/Multiplayer/Remote/remote_collision.h" #include @@ -359,7 +360,7 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: unsigned direct_sent{}; const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics || - packet.kind == PacketKind::effects; + packet.kind == PacketKind::effects || packet.kind == PacketKind::drone; for (auto &p : active_peers(s)) { if (!p.handshaken || p.member.id == except || (s.mode == Mode::host && gameplay && !p.world_ready) || @@ -368,7 +369,7 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: // Chat and throwdown messages always travel through the host, which relays // them once to everyone else; a direct copy as well would deliver them twice. if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown || packet.kind == PacketKind::effects || - packet.kind == PacketKind::typing) && s.mode == Mode::join && + packet.kind == PacketKind::typing || packet.kind == PacketKind::drone) && s.mode == Mode::join && p.member.id != s.host_id) continue; if (packet.kind == PacketKind::voice) { @@ -766,6 +767,14 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui e.impacts = std::move(impacts); deliver(e, false, true); } + // Where this player's FPV drone is, for the marker others see (drone_beacon.h). + if (const auto out = drone::beacon::next_outgoing()) { + auto d = packet(s, PacketKind::drone, now); + d.drone_active = out->active; + d.drone_position = out->position; + d.drone_rotation = out->rotation; + deliver(d, false, true); + } } } // namespace session_detail } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index 94ee58bf..99313a62 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -6,7 +6,7 @@ namespace dingosdk::multiplayer { enum class TrafficLane : std::uint16_t { gameplay = 0, control = 1, cosmetics = 2, voice = 3 }; constexpr TrafficLane traffic_lane(PacketKind kind) { return kind == PacketKind::voice ? TrafficLane::voice - : kind == PacketKind::pose || kind == PacketKind::audio || kind == PacketKind::effects ? TrafficLane::gameplay + : kind == PacketKind::pose || kind == PacketKind::audio || kind == PacketKind::effects || kind == PacketKind::drone ? TrafficLane::gameplay : kind == PacketKind::cosmetics ? TrafficLane::cosmetics : TrafficLane::control; } struct TransportPeer { diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 9927ac6c..1908a592 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -588,6 +588,10 @@ void object_codec() { served.sync_effects = false; const auto plain = decode(encode(served)); check(plain && !plain->sync_effects && listed && listed->sync_effects, "The roster's skater effects rule failed to round-trip"); + served.sync_drones = false; + const auto grounded = decode(encode(served)); + check(grounded && !grounded->sync_drones && grounded->sync_effects == served.sync_effects && listed && listed->sync_drones, + "The roster's drones rule failed to round-trip"); served.object_scaling = false; const auto fixed = decode(encode(served)); check(fixed && !fixed->object_scaling && listed && listed->object_scaling, "The roster's object scaling rule failed to round-trip"); @@ -982,6 +986,37 @@ void dedicated_server_codec() { try { encode(fx); } catch (const std::invalid_argument &) { refused = true; } check(refused, "A material past the game's table was encoded"); } + // A player's FPV drone position, for the marker others see. + { + Packet d; + d.kind = PacketKind::drone; d.session = 9; d.epoch = 10; d.map = 11; d.source = 12; + d.drone_active = true; d.drone_position = {594.5f, 199.15f, -1070.1f}; + d.drone_rotation = {0.f, 0.38268343f, 0.f, 0.92387953f}; // 45 degrees of yaw + const auto back = decode(encode(d)); + check(back && back->kind == PacketKind::drone && back->drone_active && back->drone_position == d.drone_position && + back->drone_rotation == d.drone_rotation, + "A drone position and facing failed to round-trip"); + auto tilted = d; + tilted.drone_rotation = {0.f, 0.f, 0.f, 0.f}; + bool turned_away{}; + try { encode(tilted); } catch (const std::invalid_argument &) { turned_away = true; } + check(turned_away, "A drone facing that is not a rotation was encoded"); + tilted.drone_rotation = {std::numeric_limits::quiet_NaN(), 0.f, 0.f, 1.f}; + turned_away = false; + try { encode(tilted); } catch (const std::invalid_argument &) { turned_away = true; } + check(turned_away, "A non-finite drone facing was encoded"); + d.drone_active = false; + const auto stopped = decode(encode(d)); + check(stopped && !stopped->drone_active, "A drone stop notice failed to round-trip"); + d.drone_position = {std::numeric_limits::quiet_NaN(), 0.f, 0.f}; + bool refused{}; + try { encode(d); } catch (const std::invalid_argument &) { refused = true; } + check(refused, "A non-finite drone position was encoded"); + d.drone_position = {2.0e6f, 0.f, 0.f}; + refused = false; + try { encode(d); } catch (const std::invalid_argument &) { refused = true; } + check(refused, "A drone position outside the world was encoded"); + } extras.kind = PacketKind::physics_extras; extras.session = 9; extras.epoch = 10; extras.map = 11; extras.source = player; check(decode(encode(extras)) && decode(encode(extras))->kind == PacketKind::physics_extras && decode(encode(extras))->extras.empty(), "Empty physics extras (the game's own) lost"); diff --git a/Extension/Profile/local_profile_objects.cpp b/Extension/Profile/local_profile_objects.cpp index 99ce3442..64087b28 100644 --- a/Extension/Profile/local_profile_objects.cpp +++ b/Extension/Profile/local_profile_objects.cpp @@ -82,6 +82,25 @@ std::optional local_ground_height(float x, float z, float top, float bott if (!hit) return {}; return (*hit)[1]; } +std::optional> local_surface_hit(const std::array& from, const std::array& to) { + std::lock_guard lock(local_runtime().native_mutex); + auto& r = placements_runtime(); + if (!local_runtime().active || !r.context) return {}; + for (const auto v : {from[0], from[1], from[2], to[0], to[1], to[2]}) + if (!std::isfinite(v) || std::abs(v) > 1e5f) return {}; + static std::uintptr_t api_base{}; + static editor::NativeSurfaceApi api; + if (api_base != local_runtime().base) { api = editor::surface_api(local_runtime().base); api_base = local_runtime().base; } + if (!api.ready) return {}; + std::array context{}; + r.context(context.data()); + if (!context[0]) return {}; + const auto world = api.world(context[0]); + if (!world) return {}; + const auto hit = editor::cast_surface(api, world, {from[0], from[1], from[2]}, {to[0], to[1], to[2]}); + if (!hit) return {}; + return std::array{(*hit)[0], (*hit)[1], (*hit)[2]}; +} bool teleport_to_local_placed_object(std::string_view map, std::uint64_t token) { std::lock_guard lock(local_runtime().native_mutex); auto& r = placements_runtime(); diff --git a/Extension/Profile/local_profile_runtime.h b/Extension/Profile/local_profile_runtime.h index cd24411a..f315da01 100644 --- a/Extension/Profile/local_profile_runtime.h +++ b/Extension/Profile/local_profile_runtime.h @@ -84,6 +84,10 @@ bool teleport_local_skater(const std::array& position); // `bottom`, from the client physics world (the park editor's native ray). Empty when nothing // is there yet (collision still streaming) or the query is unavailable. Client update thread. std::optional local_ground_height(float x, float z, float top, float bottom); +// The first collision surface along the line from `from` to `to` (world positions), from the same +// client physics world. Empty when the line is clear, collision is still streaming, or the query +// is unavailable. Client update thread. +std::optional> local_surface_hit(const std::array& from, const std::array& to); // Small allowlisted progression commands, executed on the game update thread. bool set_local_progression(const std::vector& arguments); // Small ReSkate-owned booleans saved beside the profile under a "ReSkate." diff --git a/Extension/Skater/Test/drone_radio_tests.cpp b/Extension/Skater/Test/drone_radio_tests.cpp new file mode 100644 index 00000000..c7e8eb29 --- /dev/null +++ b/Extension/Skater/Test/drone_radio_tests.cpp @@ -0,0 +1,71 @@ +// Guided controller setup (drone_radio.h: Calibration): which axis each stick is on, which way +// round, and the arm and reset switches, from a simulated radio. +#include "Extension/Skater/drone_radio.h" +#include +#include +using namespace dingosdk::drone::radio; +using Step = Calibration::Step; +static int fails = 0; +#define CHECK(c, msg) do { if (!(c)) { std::printf("FAIL: %s\n", msg); ++fails; } } while (0) +// Hold axes for `t` seconds at 60 fps. +static void hold(Calibration& c, std::array a, std::uint32_t b, float t) { for (float s = 0; s < t; s += 1.f/60) c.update(a, b, 1.f/60); } +int main() { + // A Mode 2 EdgeTX radio in AETR order: roll 1, pitch 2, throttle 3, yaw 4; switches on 5 and 6. + { Calibration c; c.start(); CHECK(c.step()==Step::rest, "starts at rest"); + std::array rest{0,0,-1,0,-1,-1}; + c.capture_rest(rest, 0); CHECK(c.step()==Step::throttle, "throttle after rest"); + auto a = rest; a[2] = 1; hold(c, a, 0, 0.1f); CHECK(c.step()==Step::throttle, "short push is not enough"); + hold(c, a, 0, 0.4f); CHECK(c.waiting_for_return(), "waits for throttle down"); + a[0] = 0.3f; hold(c, a, 0, 0.5f); // a small wobble elsewhere is ignored + hold(c, rest, 0, 0.1f); CHECK(c.step()==Step::yaw, "yaw next"); + a = rest; a[3] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); CHECK(c.step()==Step::pitch, "pitch next"); + a = rest; a[1] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); + a = rest; a[0] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); CHECK(c.step()==Step::arm, "arm next"); + a = rest; a[4] = 1; hold(c, a, 0, 0.4f); CHECK(c.step()==Step::reset, "arm stays on, reset next"); + auto b = a; b[5] = 1; hold(c, b, 0, 0.4f); CHECK(c.step()==Step::reset && c.waiting_for_return(), "reset must be let go"); + hold(c, a, 0, 0.1f); CHECK(c.step()==Step::done, "done"); + auto m = c.mapping(); CHECK(m==(std::array{1,2,3,4}), "AETR mapping"); + CHECK(c.arm_axis()==5 && c.reset_axis()==6, "switch axes"); + CHECK(pick_axis(a, c.arm_axis()) > 0.5f, "armed side reads high"); + } + // Shuffled and reversed: throttle on 1 reversed, yaw 2, pitch 4 reversed, roll 3; buttons for switches. + { Calibration c; c.start(); std::array rest{1,0,0,0,0,0}; c.capture_rest(rest, 0); + auto a = rest; a[0] = -1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); + a = rest; a[1] = 0.9f; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); + a = rest; a[3] = -0.95f; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); + a = rest; a[2] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); + auto m = c.mapping(); CHECK(m==(std::array{3,-4,-1,2}), "shuffled mapping"); + auto cmd = map_axes(std::array{-1,0,0,0,0,0}, m); CHECK(cmd.throttle > 0.99f, "reversed throttle reads full when pushed"); + cmd = map_axes(rest, m); CHECK(cmd.throttle < 0.01f, "reversed throttle reads zero at rest"); + hold(c, rest, 1u<<6, 0.4f); CHECK(c.arm_button()==7 && c.step()==Step::reset, "arm button"); + hold(c, rest, (1u<<6)|(1u<<1), 0.4f); CHECK(c.reset_button()==2, "reset button"); + hold(c, rest, 1u<<6, 0.1f); CHECK(c.step()==Step::done, "done after reset released"); + } + // Skips, and a button held at rest never counts. + { Calibration c; c.start(); std::array rest{0,0,-1,0,0,0}; c.capture_rest(rest, 1u); + for (int i : {2,3,1,0}) { auto a = rest; a[i] = 1.f; hold(c, a, 1u, 0.4f); hold(c, rest, 1u, 0.1f); } + hold(c, rest, 1u, 1.0f); CHECK(c.step()==Step::arm, "held-at-rest button ignored"); + c.skip(); c.skip(); CHECK(c.step()==Step::done && c.arm_axis()==0 && c.arm_button()==0, "skipped switches"); + c.cancel(); CHECK(!c.active() && c.step()==Step::idle, "cancel"); + } + // An Xbox pad through Windows' joystick API: sticks rest in the middle, triggers on 5 and 6, + // and up on a stick reads negative. The throttle springs back, so the middle has to hover. + { Calibration c; c.start(); std::array rest{0,0,0,0,-1,-1}; c.capture_rest(rest, 0); + auto a = rest; a[1] = -1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); // throttle: left stick up + a = rest; a[0] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); // yaw: left stick right + a = rest; a[3] = -1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); // pitch: right stick up + a = rest; a[2] = 1; hold(c, a, 0, 0.4f); hold(c, rest, 0, 0.1f); // roll: right stick right + c.skip(); c.skip(); + CHECK(c.throttle_centred(), "a pad's throttle is found to spring back"); + const auto m = c.mapping(); CHECK(m==(std::array{3,-4,-2,1}), "pad mapping"); + CHECK(std::abs(map_axes(rest, m, 0.12f, true, 0.35f).throttle - 0.35f) < 1e-4f, "middle of a spring throttle hovers"); + a = rest; a[1] = -1; CHECK(map_axes(a, m, 0.12f, true, 0.35f).throttle > 0.999f, "stick up is full throttle"); + a[1] = 1; CHECK(map_axes(a, m, 0.12f, true, 0.35f).throttle < 0.001f, "stick down is no throttle"); + a[1] = -0.05f; CHECK(std::abs(map_axes(a, m, 0.12f, true, 0.35f).throttle - 0.35f) < 1e-4f, "drift inside the dead zone still hovers"); + } + // A radio's throttle rests at the bottom: not a spring throttle. + { Calibration c; c.start(); std::array rest{0,0,-1,0,0,0}; c.capture_rest(rest, 0); + auto a = rest; a[2] = 1; hold(c, a, 0, 0.4f); CHECK(!c.throttle_centred(), "a radio throttle is not a spring throttle"); } + std::printf(fails ? "%d failure(s)\n" : "all calibration checks passed\n", fails); + return fails ? 1 : 0; +} diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index c89f5b3d..b921ef87 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -8,12 +8,16 @@ #include "Engine/Game/Multiplayer/session_tools.h" #include "free_flight.h" #include "Engine/Game/Skater/first_person_cycle.h" +#include "drone_flight.h" +#include "drone_audio.h" #include "Engine/Game/Skater/velocity_boost.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include "Extension/Profile/local_profile_runtime.h" #include #include +#include +#include #include #include @@ -31,7 +35,10 @@ double source_flight_now() { return static_cast(counter.QuadPart) / frequency; } namespace { -constexpr std::array debug_speeds{5.0f, 0.6f, 3.0f, 15.0f, 60.0f, 300.0f, 1500.0f}; +// Free-flight speed: any speed between the slowest and fastest the console offers (0.6 to 1500), +// so the FPV drone's presets and its top speed slider can set their own (drone_flight.h). +constexpr float min_debug_speed = 0.6f, max_debug_speed = 1500.0f; +bool valid_debug_speed(float speed) { return std::isfinite(speed) && speed >= min_debug_speed && speed <= max_debug_speed; } struct DebugUi { std::uintptr_t object{}; std::uint8_t draw{}; }; // The UI settings object last verified writable, rechecked once a second: its // VirtualQuery otherwise ran on every debug tick. Callers hold SourceState::busy. @@ -220,7 +227,38 @@ void debug_flight_tick(SourceTrial& trial, std::uintptr_t client, bool ready, bo : "First person on. The camera follows the skater's head."; } } else { - next = step_free_flight(debug.flight_matrix, input ? *input : idle, seconds, debug.flight_speed); + if (drone::is_enabled()) { + drone::audio::ensure_started(); + // The game's own collision query (the Park Editor's ray), so the drone hits ground, walls and ramps. + static const drone::World world{[](const std::array& from, const std::array& to, + std::array& hit) { + const auto found = local_surface_hit(from, to); + if (!found) return false; + hit = *found; + return true; + }}; + bool flown_by_radio = false; + if (drone::radio::calibrating()) { + // The controller setup is moving the sticks and switches: hold the drone where it is. + next = debug.flight_matrix; + flown_by_radio = true; + } else if (drone::radio::config().enabled.load(std::memory_order_acquire) && drone::radio::game_in_foreground()) { + const auto sample = drone::radio::poll(); + if (sample.valid) { + drone::apply_switches(sample.axes, sample.buttons); + next = drone::step_drone_radio(debug.flight_matrix, debug.drone_state, + drone::read_sticks(sample.axes), seconds, debug.flight_speed, + drone::live_settings(), &world); + flown_by_radio = true; + } + } + if (!flown_by_radio) + next = drone::step_drone_flight(debug.flight_matrix, debug.drone_state, input ? *input : idle, seconds, debug.flight_speed, + drone::live_settings(), &world); + } else { + debug.drone_state.valid = false; // restart from the current view next time drone mode turns on + next = step_free_flight(debug.flight_matrix, input ? *input : idle, seconds, debug.flight_speed); + } } // Runs after the native client update on the same engine thread. Publishing // our owned pose each tick also prevents native helper input from drifting it. @@ -312,7 +350,7 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { debug.no_bail_restore_after = GetTickCount64() + 2000; } if (const auto speed = saved::number(saved::flight_speed); - speed && std::find(debug_speeds.begin(), debug_speeds.end(), *speed) != debug_speeds.end()) + speed && valid_debug_speed(*speed)) debug.flight_speed = *speed; if (const auto speed = saved::number(saved::forward_velocity); speed && *speed >= 1.0f && *speed <= 300.0f) debug.forward_velocity_speed = *speed; @@ -328,6 +366,18 @@ void load_saved_debug(InteractiveDebug& debug) noexcept { if (const auto fov = saved::number(saved::free_camera_fov); fov && (*fov == 0.0f || (*fov >= first_person_fov_min && *fov <= first_person_fov_max))) debug.free_camera_fov = *fov; + // Drone tuning saved by `dronetune`. + for (std::size_t i = 0; i < drone::knob_count; ++i) { + const auto tuned = saved::number(std::format("Drone.Tune.{}", drone::knob_info[i].name).c_str()); + if (tuned && *tuned >= drone::knob_info[i].lo && *tuned <= drone::knob_info[i].hi) drone::knobs().value[i] = *tuned; + } + // Drone radio: on/off and which axis is which (signed 1..6), saved by the console commands. + if (const auto radio_on = saved::number("Drone.Radio")) drone::radio::config().enabled = *radio_on != 0.0f; + for (int i = 0; i < 4; ++i) { + const auto axis = saved::number(std::format("Drone.Axis{}", i).c_str()); + const int signed_axis = axis ? static_cast(std::lround(*axis)) : 0; + if (signed_axis != 0 && std::abs(signed_axis) <= 6) drone::radio::config().map[i] = signed_axis; + } auto arm = debug.first_person_settings; if (const auto enabled = profile_runtime::local_preference(saved::spring)) arm.enabled = *enabled; for (std::size_t i = 0; i < 3; ++i) { @@ -460,9 +510,8 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b return; } if (request.action == overlay::DebugAction::set_camera_speed) { - const auto choice = std::find(debug_speeds.begin(), debug_speeds.end(), request.value); - source_require(choice != debug_speeds.end(), "Choose one of the flight speed presets."); - debug.flight_speed = *choice; + source_require(valid_debug_speed(request.value), "Flight speed must be between 0.6 and 1500."); + debug.flight_speed = request.value; mark_debug_changed(debug); debug.status = "Flight speed updated. Mouse sensitivity is unchanged."; return; @@ -744,7 +793,9 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b debug.flight_matrix = pose; debug.flight_time = source_flight_now(); debug.flight_ready = true; - debug.status = "Freecam enabled. WASD / Q E move; hold RMB to look; Shift boosts."; + debug.drone_state.valid = false; // a fresh Freecam starts the drone at rest + debug.status = drone::is_enabled() ? "Freecam enabled in drone mode. WASD pitch/roll, Q E throttle, hold RMB + mouse to yaw." + : "Freecam enabled. WASD / Q E move; hold RMB to look; Shift boosts."; return; } source_require(false, "Unknown debug request."); diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index 4282cfde..50cd675a 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -5,6 +5,7 @@ #include "Engine/Game/Build/20260929/client_source_spawn.h" #include "Engine/Game/Skater/first_person_spring.h" #include "Extension/UI/Overlay/overlay.h" +#include "Extension/Skater/drone_flight.h" #include #include #include @@ -122,6 +123,7 @@ struct InteractiveDebug { std::uint8_t ui_original{}, ui_applied{}; DWORD thread{}; std::array flight_matrix{}; + drone::State drone_state; // FPV drone flight state (used while dronemode is on) float flight_speed = 15.0f; double flight_time{}; bool flight_ready{}, noclip{}, no_bail{}; diff --git a/Extension/Skater/console_commands.cpp b/Extension/Skater/console_commands.cpp index 6e5de873..af9dab03 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -1,7 +1,13 @@ #include "Extension/Console/commands.h" #include "Extension/Profile/local_profile_runtime.h" #include "ai_skaters.h" +#include "drone_flight.h" +#include "drone_outfit.h" +#include +#include #include +#include +#include namespace dingosdk::console { void register_movement_commands(Commands ®istry) { using Debug = overlay::DebugAction; @@ -55,9 +61,205 @@ 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", + 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.", + "Flies while freecam is on"); + }; + drone_mode.run = [](const Model &m, const Values &args, const Output &out) { + const bool on = std::get(args[0]); + drone::set_enabled(on); + if (on) { drone::set_armed(true); drone::reset_flag().store(false); } + if (!on) out("Drone mode off. Freecam flies normally again (freecam 0 takes you back to your skater)."); + else if (!m.debug.free_camera) out("Drone mode on. Turn on freecam (freecam 1) to take off."); + else out("Drone mode on. W/S: tilt forward/back. A/D: roll left/right. E/Q: throttle up/down. Mouse: turn."); + }; + drone_mode.reset = [](const Model &, const Output &) { drone::set_enabled(false); }; + registry.add(std::move(drone_mode)); + auto tune_name = argument("setting", Type::text, true); + for (const auto &knob : drone::knob_info) tune_name.choices.emplace_back(knob.name); + tune_name.choices.emplace_back("reset"); + auto tune_value = argument("value", Type::number, true); + auto tune = action("dronetune", + "Change a drone setting: dronetune . On its own it lists every setting; 'dronetune reset' puts them all back", + Group::movement, {tune_name, tune_value}); + tune.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + tune.run = [](const Model &, const Values &args, const Output &out) { + const auto show = [&out](std::size_t i) { + out(std::format("{:<9} {:<8g} {} (default {:g}, range {:g}-{:g})", drone::knob_info[i].name, + drone::knobs().value[i].load(), drone::knob_info[i].help, drone::knob_info[i].def, + drone::knob_info[i].lo, drone::knob_info[i].hi)); + }; + if (args.empty()) { + for (std::size_t i = 0; i < drone::knob_count; ++i) show(i); + return; + } + const auto name = std::get(args[0]); + if (name == "reset") { + std::vector> saved; + for (std::size_t i = 0; i < drone::knob_count; ++i) { + drone::knobs().value[i] = drone::knob_info[i].def; + saved.emplace_back(std::format("Drone.Tune.{}", drone::knob_info[i].name), + static_cast(drone::knob_info[i].def)); + } + profile_runtime::set_local_values(saved); + out("Every drone setting is back to its default."); + return; + } + const int index = drone::find_knob(name); + if (index < 0) { + out(std::format("error: There is no setting called '{}'. Type dronetune on its own to see them all.", name)); + return; + } + const auto i = static_cast(index); + if (args.size() < 2) { + show(i); + return; + } + const auto wanted = static_cast(std::get(args[1])); + const auto value = std::clamp(wanted, drone::knob_info[i].lo, drone::knob_info[i].hi); + drone::knobs().value[i] = value; + profile_runtime::set_local_values({{std::format("Drone.Tune.{}", name), static_cast(value)}}); + out(value == wanted ? std::format("{} = {:g}", name, value) + : 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); + 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."); + }; + registry.add(std::move(menu_command)); + auto arm_command = action("dronearm", "Arm the drone: motors on, ready to fly", Group::movement); + arm_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + arm_command.run = [](const Model &, const Values &, const Output &out) { + drone::set_armed(true); + out("Armed."); + }; + registry.add(std::move(arm_command)); + auto disarm_command = action("dronedisarm", + "Disarm the drone: motors off and the sticks do nothing. It falls (or hangs in the air with dronetune disarmfreeze 1)", Group::movement); + disarm_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + disarm_command.run = [](const Model &, const Values &, const Output &out) { + drone::set_armed(false); + out("Disarmed."); + }; + registry.add(std::move(disarm_command)); + auto reset_command = action("dronereset", + "Put the drone back where it took off, with a fresh battery and timer", Group::movement); + reset_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + reset_command.run = [](const Model &, const Values &, const Output &out) { + drone::request_reset(); + out("Back to the takeoff point."); + }; + registry.add(std::move(reset_command)); + auto radio_mode = variable("droneradio", + "Fly the drone with a controller: a gamepad, or an RC radio in USB Joystick mode", + 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)"); + }; + 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)." + : "Controller off. The drone uses the keyboard and mouse."); + }; + radio_mode.reset = [](const Model &, const Output &) { + drone::radio::config().enabled = false; + profile_runtime::set_local_values({{"Drone.Radio", 0.0}}); + }; + registry.add(std::move(radio_mode)); + auto radio_check = action("droneradiocheck", "Show what your controller is sending right now, and how the drone reads it", Group::movement); + radio_check.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + radio_check.run = [](const Model &, const Values &, const Output &out) { + const auto sample = drone::radio::poll(); + if (!sample.valid) { + out("No controller found. Plug in a gamepad, or an RC radio with its USB mode set to Joystick, then try again."); + return; + } + out(std::format("Controller: {}", sample.name)); + out(std::format("Raw axes 1-6: {:+.2f} {:+.2f} {:+.2f} {:+.2f} {:+.2f} {:+.2f}", sample.axes[0], sample.axes[1], + sample.axes[2], sample.axes[3], sample.axes[4], sample.axes[5])); + std::string pressed; + for (int n = 1; n <= 32; ++n) + if (sample.buttons & (1u << (n - 1))) pressed += (pressed.empty() ? "" : " ") + std::to_string(n); + out(std::format("Buttons pressed: {}", pressed.empty() ? "none" : pressed)); + const auto map = drone::radio::mapping_snapshot(); + const auto cmd = drone::read_sticks(sample.axes); + 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."); + }; + registry.add(std::move(radio_check)); + const auto axis_argument = [](const char *name) { + auto a = argument(name, Type::number); + a.minimum = -6; + a.maximum = 6; + return a; + }; + // Finding the cosmetics the drone could put on the pilot (goggles while flying): see drone_outfit.h. + auto items_command = action("droneitems", + "List installed cosmetics whose name contains a word (droneitems goggle), or your skater's outfit slots (droneitems slots)", + Group::movement, {argument("word", Type::text)}); + items_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; + items_command.run = [](const Model &, const Values &args, const Output &out) { + const auto word = std::get(args[0]); + try { + if (word == "slots") { + out("Your skater's outfit slots (number, slot, what it holds now):"); + for (const auto &line : drone::outfit::slots()) out(line); + return; + } + std::size_t total{}; + const auto found = drone::outfit::find_items(word, 80, total); + out(std::format("{} installed item(s) contain '{}'{}", total, word, total > found.size() ? " (the first 80):" : ":")); + for (const auto &name : found) out(" " + name); + } catch (const std::exception &error) { + out(std::format("error: {}", error.what())); + } + }; + registry.add(std::move(items_command)); + auto radio_axes = action("droneaxes", "Choose which controller axis is roll, pitch, throttle and yaw (1-6 each; a minus reverses one). Set up controller does this for you", + Group::movement, {axis_argument("roll"), axis_argument("pitch"), axis_argument("throttle"), axis_argument("yaw")}); + radio_axes.inspect = [](const Model &) { + const auto map = drone::radio::mapping_snapshot(); + return State{true, std::format("{} {} {} {}", map[0], map[1], map[2], map[3]), {}, {}, false}; + }; + radio_axes.run = [](const Model &, const Values &args, const Output &out) { + std::array next{}; + for (std::size_t i = 0; i < 4; ++i) { + next[i] = static_cast(std::lround(std::get(args[i]))); + if (next[i] == 0 || std::abs(next[i]) > 6) { + out("error: Each axis must be 1 to 6, or -1 to -6 to reverse it."); + return; + } + } + std::vector> saved; + for (std::size_t i = 0; i < 4; ++i) { + drone::radio::config().map[i] = next[i]; + saved.emplace_back(std::format("Drone.Axis{}", i), static_cast(next[i])); + } + profile_runtime::set_local_values(saved); + out(std::format("Axes saved: roll {} pitch {} throttle {} yaw {}", next[0], next[1], next[2], next[3])); + }; + registry.add(std::move(radio_axes)); auto speed = argument("speed", Type::number); - speed.choices = {"0.6", "3", "5", "15", "60", "300", "1500"}; - auto flight = variable("flyspeed", "Free-flight speed in world units per second", Group::movement, speed); + // Any speed in range, so the drone's presets and top speed slider can set theirs; the usual + // ones are still offered as completions. + speed.minimum = 0.6; + speed.maximum = 1500.0; + speed.complete = [](const Model &, auto) { + return std::vector{"0.6", "3", "5", "15", "60", "300", "1500"}; + }; + auto flight = variable("flyspeed", "Free-flight speed in world units per second (0.6-1500)", Group::movement, speed); flight.aliases = {"debug.camera_speed"}; flight.inspect = [](const Model &m) { return State{m.debug.available, diff --git a/Extension/Skater/drone_audio.h b/Extension/Skater/drone_audio.h new file mode 100644 index 00000000..65cc4f85 --- /dev/null +++ b/Extension/Skater/drone_audio.h @@ -0,0 +1,120 @@ +#pragma once +// Synthesised FPV motor sound. The maths (MotorSynth) is plain C++ and testable anywhere; +// only the Windows output thread (winmm waveOut, already linked for the radio) is platform specific. +// Pitch follows the throttle, with a little lag like real motors, plus a rush of wind with speed. +#include "drone_flight.h" +#include +#include +#include +#ifdef _WIN32 +#include +#include +#include +#endif + +namespace dingosdk::drone::audio { + +class MotorSynth { +public: + static constexpr int rate = 44100; + // Fills `count` mono 16-bit samples. throttle 0..1, speed in m/s, volume 0..1; pitch scales the + // motor note (1 = normal; small drones spin faster and sound higher). + void render(std::int16_t* out, int count, float throttle, float speed, float volume, float pitch = 1.0f) { + const float note = std::isfinite(pitch) ? std::clamp(pitch, 0.25f, 4.0f) : 1.0f; + const float target = std::isfinite(throttle) ? std::clamp(throttle, 0.0f, 1.0f) : 0.0f; + const float vol = std::isfinite(volume) ? std::clamp(volume, 0.0f, 1.0f) : 0.0f; + const float wind = std::isfinite(speed) ? std::clamp(speed / 60.0f, 0.0f, 1.0f) : 0.0f; + constexpr float tau = 0.08f; // motors spin up/down over ~80 ms + const float k = 1.0f - std::exp(-1.0f / (tau * rate)); + constexpr float detune[4] = {1.0f, 1.013f, 0.989f, 1.027f}; + constexpr float tpi = 6.283185307179586f; + for (int i = 0; i < count; ++i) { + rpm_ += (target - rpm_) * k; + const float base = (110.0f + 520.0f * rpm_) * note; // fundamental, Hz + float sample = 0.0f; + for (int m = 0; m < 4; ++m) { + phase_[m] += base * detune[m] / rate; + phase_[m] -= std::floor(phase_[m]); + const float p = phase_[m] * tpi; + sample += std::sin(p) + 0.55f * std::sin(2 * p) + 0.35f * std::sin(3 * p) + 0.2f * std::sin(5 * p); + } + // cheap noise, low-passed more for the wind, brighter for the props + seed_ = seed_ * 1664525u + 1013904223u; + const float white = static_cast(static_cast(seed_)) / 2147483648.0f; + lp_ += (white - lp_) * 0.12f; + const float noise = (0.35f * rpm_ + 0.5f * wind) * lp_ + 0.12f * rpm_ * white; + const float level = 0.03f + 0.07f * rpm_; // idle whine is quieter than full power + const float v = std::tanh((sample * level + noise * 0.25f) * 1.6f) * 0.85f * vol; + out[i] = static_cast(std::clamp(v, -1.0f, 1.0f) * 32000.0f); + } + } + float rpm() const { return rpm_; } +private: + float rpm_ = 0, lp_ = 0; + float phase_[4] = {0, 0.25f, 0.5f, 0.75f}; + std::uint32_t seed_ = 12345u; +}; + +#ifdef _WIN32 +namespace detail { +inline void run() { + constexpr int frames = 1024, buffers = 4; + MotorSynth synth; + std::int16_t data[buffers][frames]{}; + WAVEHDR header[buffers]{}; + HWAVEOUT device{}; + bool open = false; + long long idle_since = 0; + for (;;) { + const bool active = flying() && telemetry().armed.load(std::memory_order_relaxed) && knobs().value[kVolume].load(std::memory_order_relaxed) > 0.001f; + const long long now = now_ms(); + if (!active) { + if (open && now - idle_since > 600) { + waveOutReset(device); + for (auto& h : header) if (h.dwFlags & WHDR_PREPARED) waveOutUnprepareHeader(device, &h, sizeof(h)); + waveOutClose(device); + open = false; + } + Sleep(40); + continue; + } + idle_since = now; + if (!open) { + WAVEFORMATEX format{}; + format.wFormatTag = WAVE_FORMAT_PCM; + format.nChannels = 1; + format.nSamplesPerSec = MotorSynth::rate; + format.wBitsPerSample = 16; + format.nBlockAlign = 2; + format.nAvgBytesPerSec = MotorSynth::rate * 2; + if (waveOutOpen(&device, WAVE_MAPPER, &format, 0, 0, CALLBACK_NULL) != MMSYSERR_NOERROR) { Sleep(1000); continue; } + for (int b = 0; b < buffers; ++b) { + header[b] = {}; + header[b].lpData = reinterpret_cast(data[b]); + header[b].dwBufferLength = frames * 2; + waveOutPrepareHeader(device, &header[b], sizeof(header[b])); + } + open = true; + } + for (int b = 0; b < buffers; ++b) { + if (header[b].dwFlags & WHDR_INQUEUE) continue; + auto& t = telemetry(); + synth.render(data[b], frames, t.throttle.load(std::memory_order_relaxed), t.speed.load(std::memory_order_relaxed), + knobs().value[kVolume].load(std::memory_order_relaxed), + knobs().value[kMotorPitch].load(std::memory_order_relaxed)); + waveOutWrite(device, &header[b], sizeof(header[b])); + } + Sleep(4); + } +} +} // namespace detail + +// Starts the sound thread once. Safe to call every tick. +inline void ensure_started() { + static const bool started = [] { std::thread(detail::run).detach(); return true; }(); + (void)started; +} +#else +inline void ensure_started() {} +#endif +} // namespace dingosdk::drone::audio diff --git a/Extension/Skater/drone_beacon.h b/Extension/Skater/drone_beacon.h new file mode 100644 index 00000000..9e69c490 --- /dev/null +++ b/Extension/Skater/drone_beacon.h @@ -0,0 +1,217 @@ +#pragma once +// Letting other players in a lobby see where your FPV drone is (they need this build too). +// The flight tick publishes the drone's position; the session sends it about 15 times a second as +// a small `drone` packet (Multiplayer/Net/protocol.h); the receiver glides each player's marker from +// one position to the next, and the overlay draws it (UI/Overlay/drone_hud_overlay.cpp). +// Header-only and free of game code, so the networking can use it without pulling in the flight model. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::drone::beacon { +using Clock = std::chrono::steady_clock; +using Position = std::array; +// Which way a drone faces: a unit quaternion (x, y, z, w) of its body, nose and all, without +// the camera's tilt. For drawing a model later; markers only need the position. +using Rotation = std::array; +inline constexpr Rotation no_rotation{0.0f, 0.0f, 0.0f, 1.0f}; + +inline bool valid_position(const Position& p) { + for (const float v : p) if (!std::isfinite(v) || std::abs(v) > 1.0e6f) return false; + return true; +} +inline bool valid_rotation(const Rotation& q) { + float length = 0.0f; + for (const float v : q) { if (!std::isfinite(v)) return false; length += v * v; } + return length > 0.81f && length < 1.21f; // a unit quaternion, give or take rounding +} +// From a body matrix (rows: right, up, back, as the flight model keeps them) and back. +inline Rotation rotation_from_basis(const std::array& m) { + const float m00 = m[0], m01 = m[1], m02 = m[2], m10 = m[4], m11 = m[5], m12 = m[6], m20 = m[8], m21 = m[9], m22 = m[10]; + Rotation q{}; + const float trace = m00 + m11 + m22; + if (trace > 0.0f) { + const float s = std::sqrt(trace + 1.0f) * 2.0f; + q = {(m12 - m21) / s, (m20 - m02) / s, (m01 - m10) / s, 0.25f * s}; + } else if (m00 > m11 && m00 > m22) { + const float s = std::sqrt(1.0f + m00 - m11 - m22) * 2.0f; + q = {0.25f * s, (m01 + m10) / s, (m20 + m02) / s, (m12 - m21) / s}; + } else if (m11 > m22) { + const float s = std::sqrt(1.0f + m11 - m00 - m22) * 2.0f; + q = {(m01 + m10) / s, 0.25f * s, (m12 + m21) / s, (m20 - m02) / s}; + } else { + const float s = std::sqrt(1.0f + m22 - m00 - m11) * 2.0f; + q = {(m20 + m02) / s, (m12 + m21) / s, 0.25f * s, (m01 - m10) / s}; + } + float length = 0.0f; + for (const float v : q) length += v * v; + length = std::sqrt(length); + if (!(length > 1e-6f) || !std::isfinite(length)) return no_rotation; + for (auto& v : q) v /= length; + return q; +} +// The body's right, up and back rows for a rotation. +inline std::array basis_from_rotation(const Rotation& q) { + const float x = q[0], y = q[1], z = q[2], w = q[3]; + return {{{1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w)}, + {2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w)}, + {2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y)}}}; +} +// Part way from one rotation to another (normalised linear blend, the short way round). +inline Rotation blend_rotation(const Rotation& a, Rotation b, float t) { + if (a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3] < 0.0f) for (auto& v : b) v = -v; + Rotation q{}; + float length = 0.0f; + for (std::size_t i = 0; i < 4; ++i) { q[i] = a[i] + (b[i] - a[i]) * t; length += q[i] * q[i]; } + length = std::sqrt(length); + if (!(length > 1e-6f)) return b; + for (auto& v : q) v /= length; + return q; +} + +// Whether the session's dedicated server lets drones be seen (its `sync_drones` setting). A +// lobby always does. Off: this game neither sends its drone nor shows anyone else's. +inline std::atomic& server_allows() { static std::atomic value{true}; return value; } + +// ---- Sending: what this player's drone is doing. +struct Local { + std::mutex mutex; + bool share = false; // the `beacon` setting + Position position{}; + Rotation rotation = no_rotation; + Clock::time_point updated{}; + Clock::time_point last_sent{}; + bool announced = false; // the others were last told "flying" +}; +inline Local& local() { static Local value; return value; } + +// Called every flight tick. +inline void publish_local(const Position& position, const Rotation& rotation, bool share) { + auto& l = local(); + std::lock_guard lock(l.mutex); + l.share = share; + l.position = position; + l.rotation = valid_rotation(rotation) ? rotation : no_rotation; + l.updated = Clock::now(); +} + +struct Outgoing { bool active; Position position; Rotation rotation; }; +// How often a flying drone's position goes out. +inline constexpr auto send_interval = std::chrono::milliseconds(66); +// Asked by the network every tick: a position to send now (about 15 times a second while +// flying, and once to say it stopped), or nothing. +inline std::optional next_outgoing(Clock::time_point now = Clock::now()) { + auto& l = local(); + std::lock_guard lock(l.mutex); + const bool flying = l.share && server_allows().load(std::memory_order_relaxed) && l.updated != Clock::time_point{} && now - l.updated < std::chrono::milliseconds(300) && + valid_position(l.position); + if (flying) { + if (l.announced && now - l.last_sent < send_interval) return std::nullopt; + l.announced = true; + l.last_sent = now; + return Outgoing{true, l.position, l.rotation}; + } + if (l.announced) { + l.announced = false; + l.last_sent = now; + return Outgoing{false, l.position, l.rotation}; + } + return std::nullopt; +} + +// ---- Receiving: other players' drones. +// A marker glides from where it was shown when a position arrived to that new position, over the +// time the last update took to come. So it moves smoothly, about one update behind the drone, +// instead of jumping a few times a second. +struct Remote { + std::uint64_t id{}; + std::string name; + Position position{}; // in a snapshot: where to draw the marker now + Clock::time_point seen{}; // when the latest position arrived + Position from{}, to{}; // the glide: from the shown position to the latest one + Clock::duration glide{}; // how long the glide takes + Rotation rotation = no_rotation; // in a snapshot: which way it faces now + Rotation from_rotation = no_rotation, to_rotation = no_rotation; +}; +inline float glide_fraction(const Remote& r, Clock::time_point now) { + if (r.glide <= Clock::duration::zero()) return 1.0f; + return std::clamp(std::chrono::duration(now - r.seen) / std::chrono::duration(r.glide), 0.0f, 1.0f); +} +inline Position glide_position(const Remote& r, Clock::time_point now) { + const float t = glide_fraction(r, now); + return {r.from[0] + (r.to[0] - r.from[0]) * t, r.from[1] + (r.to[1] - r.from[1]) * t, r.from[2] + (r.to[2] - r.from[2]) * t}; +} +// Far jumps (a reset, a teleport) are shown at once rather than slid across the map. +inline constexpr float snap_distance = 50.0f; +struct Remotes { + std::mutex mutex; + std::vector list; +}; +inline Remotes& remotes() { static Remotes value; return value; } +constexpr std::size_t max_remote = 32; +inline constexpr auto stale_after = std::chrono::milliseconds(1500); + +inline void receive(std::uint64_t id, std::string name, const Position& position, const Rotation& rotation, bool active, + Clock::time_point now = Clock::now()) { + if (!id || !valid_position(position)) return; + const Rotation facing = valid_rotation(rotation) ? rotation : no_rotation; + auto& r = remotes(); + std::lock_guard lock(r.mutex); + for (auto it = r.list.begin(); it != r.list.end(); ++it) + if (it->id == id) { + if (!active) { r.list.erase(it); return; } + if (!name.empty()) it->name = std::move(name); + const auto shown = glide_position(*it, now); + float jump = 0.0f; + for (std::size_t i = 0; i < 3; ++i) jump += (position[i] - shown[i]) * (position[i] - shown[i]); + // The glide lasts as long as this update took to arrive, within sensible bounds. + const auto gap = std::clamp(now - it->seen, std::chrono::milliseconds(20), + std::chrono::milliseconds(250)); + const bool snap = jump > snap_distance * snap_distance; + it->from_rotation = snap ? facing : blend_rotation(it->from_rotation, it->to_rotation, glide_fraction(*it, now)); + it->to_rotation = facing; + it->from = snap ? position : shown; + it->to = position; + it->position = position; + it->glide = gap; + it->seen = now; + return; + } + if (!active || r.list.size() >= max_remote) return; + r.list.push_back({id, std::move(name), position, now, position, position, {}, facing, facing, facing}); +} +// Someone left the session. +inline void forget(std::uint64_t id) { + auto& r = remotes(); + std::lock_guard lock(r.mutex); + std::erase_if(r.list, [id](const Remote& x) { return x.id == id; }); +} +// Whether the player `id` is flying a drone now, by their beacon. +inline bool flying(std::uint64_t id, Clock::time_point now = Clock::now()) { + auto& r = remotes(); + std::lock_guard lock(r.mutex); + for (const auto& x : r.list) + if (x.id == id) return now - x.seen <= stale_after; + return false; +} +// The drones to show now (stale ones are dropped). +inline std::vector snapshot(Clock::time_point now = Clock::now()) { + auto& r = remotes(); + std::lock_guard lock(r.mutex); + std::erase_if(r.list, [now](const Remote& x) { return now - x.seen > stale_after; }); + auto out = r.list; + for (auto& x : out) { + x.position = glide_position(x, now); + x.rotation = blend_rotation(x.from_rotation, x.to_rotation, glide_fraction(x, now)); + } + return out; +} +} // namespace dingosdk::drone::beacon diff --git a/Extension/Skater/drone_flight.h b/Extension/Skater/drone_flight.h new file mode 100644 index 00000000..4f160352 --- /dev/null +++ b/Extension/Skater/drone_flight.h @@ -0,0 +1,556 @@ +#pragma once +// FPV drone flight model for the Freecam. Replaces step_free_flight() while drone mode is on. +// +// Matrix convention (same as free_flight.h): row-major 4x4, rows are right / up / backward, +// translation in elements 12..14. The camera looks along -backward. World up is +Y. +// +// Controls (all come from the existing FlightInput, so keyboard, mouse and controller work): +// input.forward -> pitch (forward = nose down) +// input.right -> roll (right = roll right) +// input.up -> throttle (0 = hover, + = climb, - = descend) +// input.look_x -> yaw (mouse look delta, right = turn right) +// input.look_y -> extra pitch (mouse look delta) +// input.boost -> doubles the speed cap +#include "free_flight.h" +#include "drone_radio.h" +#include "drone_beacon.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk::drone { + +// Betaflight "Actual" rates for one axis: deg/s near the stick centre, deg/s at full stick, expo 0-1. +struct Rate { float center, max, expo; }; + +struct Settings { + Rate roll{80.0f, 700.0f, 0.15f}; + Rate pitch{80.0f, 700.0f, 0.15f}; + Rate yaw{130.0f, 780.0f, 0.30f}; + float yaw_look_scale = 0.0025f; // rad per unit of mouse delta (matches freecam look) + float hover_throttle = 0.35f; // throttle at which thrust equals gravity + float gravity = 9.81f; + float lin_drag = 0.15f; // 1/s + float quad_drag = 0.02f; // 1/unit + float camera_uptilt_deg = 25.0f; + bool angle_mode = false; // true = self-levelling (easier), false = acro (full flips) + float max_angle_deg = 55.0f; + float angle_response = 8.0f; + bool collide = true; // collide with the world (needs the game's collision query) + float body_radius = 0.3f; // how close it gets to a surface before stopping + float floor_depth = 1.5f; // invisible floor this far below the start height (only without collisions); 0 = none + float range = 0.0f; // radio and video reach from the takeoff point, metres; 0 = unlimited + bool armed = true; // false = motors off: no control, no thrust (the drone falls) + bool disarm_freeze = false; // true = a disarmed drone hangs in place instead of falling + bool beacon = false; // tell other players in the lobby where the drone is +}; + +struct State { + bool valid = false; + std::array body{}; // body orientation (translation unused); camera = body tilted up + std::array velocity{}; + float start_y = 0; // height the flight began at; the invisible floor hangs below it + float flight_time = 0; // seconds the motors have been armed since the flight began (the OSD timer) + std::array start_pos{}; // where the flight began (`dronereset` returns here) + std::array start_body{}; + bool failsafe = false; // flew out of range: motors cut until the drone is reset + float signal_lost = 0; // seconds in a row without a picture (range) +}; + +// What the OSD and the motor sound read (written by the flight tick, read from other threads). +struct Telemetry { + std::atomic throttle{0}, speed{0}, altitude{0}, seconds{0}; + std::atomic armed{true}; + std::atomic signal{1.0f}; // radio and video link, 1 = perfect, 0 = lost (range) + std::atomic failsafe{false}; // out of range for too long: motors cut until reset + std::atomic home_distance{0.0f}; // metres from the takeoff point + std::atomic signal_lost{0.0f}; // seconds in a row without a picture + std::atomic stick_roll{0}, stick_pitch{0}, stick_yaw{0}; // -1..1, + = right / forward / right + std::atomic last_ms{0}; // steady-clock milliseconds of the last flight tick +}; +inline Telemetry& telemetry() { static Telemetry value; return value; } +inline long long now_ms() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +// True while the drone is being flown (a flight tick ran in the last quarter second). +inline bool flying() { return now_ms() - telemetry().last_ms.load(std::memory_order_relaxed) < 250; } + +inline std::atomic& enabled() { + static std::atomic value{false}; + return value; +} +inline bool is_enabled() { return enabled().load(std::memory_order_acquire); } +// While the drone flies with a controller (RC radio or gamepad), the game must not read that +// controller too, or the skater moves with the sticks: ReSkate keeps it off the controller, as +// its freecam controller mode does (Runtime/client_tick.cpp), and the drone reads it regardless. +inline bool controller_capture() { return is_enabled() && radio::config().enabled.load(std::memory_order_acquire); } +inline std::atomic& armed_flag() { + static std::atomic value{true}; + return value; +} +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. +inline std::atomic& menu_flag() { + static std::atomic value{true}; + return value; +} +// Asks the flight tick to put the drone back at its starting point and restart the timer and battery. +inline std::atomic& reset_flag() { + static std::atomic value{false}; + return value; +} +inline void request_reset() { reset_flag().store(true, std::memory_order_release); } +inline void set_enabled(bool on) { enabled().store(on, std::memory_order_release); } + +// Settings you can change while flying (`dronetune`). The index order matters: live_settings() uses it. +struct KnobInfo { const char* name; const char* help; float lo, hi, def; }; +enum Knob : std::size_t { kGravity, kDrag, kHover, kRollCenter, kRollMax, kRollExpo, kPitchCenter, kPitchMax, kPitchExpo, + kYawCenter, kYawMax, kYawExpo, kUptilt, kFloor, kCollide, kRadius, kOsd, kVolume, + kOsdScale, kOsdColor, kOsdFont, kOsdMargin, kCrosshair, kShowArmed, kShowBattery, kShowTimer, kShowThrottle, kShowAlt, + kShowSpeed, kSticks, kStickSize, kArmAxis, kResetAxis, kDisarmFreeze, kBeacon, kShowBeacons, kArmButton, kResetButton, + kAnalog, kAnalogNoise, kAnalogScan, kAnalogVignette, kAnalogGlitch, kRange, kMotorPitch, kThrottleCentre, kDeadzone, kGoggles, kHideUi }; +inline constexpr KnobInfo knob_info[] = { + {"gravity", "How hard the drone is pulled down. 9.8 is Earth; higher feels heavier and snappier", 0.0f, 50.0f, 20.0f}, + {"drag", "Air resistance. Higher slows the drone down sooner when you let off", 0.0f, 3.0f, 0.15f}, + {"hover", "The throttle that keeps the drone level in the air (0.1-0.9). Lower means more power to spare", 0.1f, 0.9f, 0.35f}, + {"rollcenter", "Roll speed near the middle of the stick, in degrees per second (Betaflight \"Actual\" rates)", 1.0f, 1000.0f, 80.0f}, + {"rollmax", "Roll speed with the stick fully over, in degrees per second", 30.0f, 2000.0f, 700.0f}, + {"rollexpo", "How much softer roll is near the middle of the stick (0 = none, 1 = most)", 0.0f, 1.0f, 0.15f}, + {"pitchcenter", "Pitch speed near the middle of the stick, in degrees per second", 1.0f, 1000.0f, 80.0f}, + {"pitchmax", "Pitch speed with the stick fully over, in degrees per second", 30.0f, 2000.0f, 700.0f}, + {"pitchexpo", "How much softer pitch is near the middle of the stick (0 = none, 1 = most)", 0.0f, 1.0f, 0.15f}, + {"yawcenter", "Yaw (turning) speed near the middle of the stick, in degrees per second", 1.0f, 1000.0f, 130.0f}, + {"yawmax", "Yaw speed with the stick fully over, in degrees per second", 30.0f, 2000.0f, 780.0f}, + {"yawexpo", "How much softer yaw is near the middle of the stick (0 = none, 1 = most)", 0.0f, 1.0f, 0.30f}, + {"uptilt", "How far the camera points up, in degrees. Real FPV cameras sit at about 25-40; faster flying needs more", -30.0f, 70.0f, 25.0f}, + {"floor", "With collisions off: an invisible floor this many metres below where you took off (0 = none)", 0.0f, 1000.0f, 1.5f}, + {"collide", "Hit the ground, walls and ramps (1), or fly through everything (0)", 0.0f, 1.0f, 1.0f}, + {"radius", "How big the drone is for collisions, in metres: how close it gets to things", 0.05f, 3.0f, 0.3f}, + {"osd", "Show the goggle display: battery, timer, throttle, altitude and speed", 0.0f, 1.0f, 1.0f}, + {"volume", "Motor sound volume (0 = silent)", 0.0f, 1.0f, 0.5f}, + {"osdscale", "Size of the goggle display's text (1 = normal, 2 = double)", 0.4f, 5.0f, 1.5f}, + {"osdcolor", "Goggle display colour: 0 white, 1 green, 2 yellow, 3 cyan, 4 orange, 5 pink", 0.0f, 5.0f, 0.0f}, + {"osdfont", "Goggle display font: 0 = pixel, like real goggles; 1 = smooth", 0.0f, 1.0f, 0.0f}, + {"osdmargin", "How far the goggle display sits in from the screen edges (0-0.3 of the screen)", 0.0f, 0.3f, 0.06f}, + {"crosshair", "Show a crosshair in the middle of the screen", 0.0f, 1.0f, 0.0f}, + {"osdarmed", "Show ARMED / DISARMED", 0.0f, 1.0f, 1.0f}, + {"osdbattery", "Show the battery voltage", 0.0f, 1.0f, 1.0f}, + {"osdtimer", "Show the flight timer", 0.0f, 1.0f, 1.0f}, + {"osdthrottle", "Show the throttle", 0.0f, 1.0f, 1.0f}, + {"osdalt", "Show the altitude", 0.0f, 1.0f, 1.0f}, + {"osdspeed", "Show the speed", 0.0f, 1.0f, 1.0f}, + {"sticks", "Show your sticks on screen (left: throttle and yaw, right: pitch and roll)", 0.0f, 1.0f, 1.0f}, + {"sticksize", "Size of the on-screen sticks (1 = normal)", 0.4f, 3.0f, 1.0f}, + {"armaxis", "Controller axis (1-6) for an arm switch: switch on = armed. Minus reverses it; 0 = none. Use one no stick is on", -6.0f, 6.0f, 0.0f}, + {"resetaxis", "Controller axis (1-6) for a reset switch: flick it on to put the drone back. 0 = none. Use one no stick is on", -6.0f, 6.0f, 0.0f}, + {"disarmfreeze", "When disarmed, the drone hangs in the air (1) instead of falling (0)", 0.0f, 1.0f, 0.0f}, + {"beacon", "Let other players see your drone (they need this version of ReSkate too)", 0.0f, 1.0f, 1.0f}, + {"showbeacons", "Show other players' drones (0 hides them)", 0.0f, 1.0f, 1.0f}, + {"armbutton", "Controller button (1-32) to arm with: held = armed. 0 = none", 0.0f, 32.0f, 0.0f}, + {"resetbutton", "Controller button (1-32) that puts the drone back where it took off. 0 = none", 0.0f, 32.0f, 0.0f}, + {"analog", "Make the picture look like analogue FPV video: scanlines, static, dark corners and glitches", 0.0f, 1.0f, 0.0f}, + {"analognoise", "Analogue look: how much static (0-1)", 0.0f, 1.0f, 0.35f}, + {"analogscan", "Analogue look: how strong the scanlines are (0-1)", 0.0f, 1.0f, 0.5f}, + {"analogvignette", "Analogue look: how dark the corners are (0-1)", 0.0f, 1.0f, 0.5f}, + {"analogglitch", "Analogue look: how often the picture rolls and tears (0-1)", 0.0f, 1.0f, 0.3f}, + {"range", "How far your signal reaches from where you took off, in metres. 0 = unlimited", 0.0f, 5000.0f, 0.0f}, + {"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}, + {"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); +static_assert(knob_count == kHideUi + 1, "knob enum and table must match"); +struct KnobValues { + std::atomic value[knob_count]; + KnobValues() { for (std::size_t i = 0; i < knob_count; ++i) value[i] = knob_info[i].def; } +}; +inline KnobValues& knobs() { static KnobValues values; return values; } +inline int find_knob(std::string_view name) { + for (std::size_t i = 0; i < knob_count; ++i) if (name == knob_info[i].name) return static_cast(i); + return -1; +} +// The controller's sticks as the drone reads them, with the saved axes, dead zone and throttle kind. +inline radio::Command read_sticks(const std::array& axes) { + const auto get = [](Knob k) { return knobs().value[k].load(std::memory_order_relaxed); }; + return radio::map_axes(axes, radio::mapping_snapshot(), get(kDeadzone), get(kThrottleCentre) >= 0.5f, get(kHover)); +} +inline Settings live_settings() { + Settings s; + auto get = [](Knob k) { return knobs().value[k].load(std::memory_order_relaxed); }; + s.gravity = get(kGravity); s.lin_drag = get(kDrag); s.hover_throttle = get(kHover); + s.roll = {get(kRollCenter), get(kRollMax), get(kRollExpo)}; + s.pitch = {get(kPitchCenter), get(kPitchMax), get(kPitchExpo)}; + s.yaw = {get(kYawCenter), get(kYawMax), get(kYawExpo)}; + s.camera_uptilt_deg = get(kUptilt); s.floor_depth = get(kFloor); + s.collide = get(kCollide) >= 0.5f; s.body_radius = get(kRadius); s.range = get(kRange); + s.armed = armed(); s.disarm_freeze = get(kDisarmFreeze) >= 0.5f; s.beacon = get(kBeacon) >= 0.5f; + return s; +} + +// Range. A real video link stays clean most of the way out, breaks up as it nears its limit and +// is lost past it. The radio link holds on a little longer: only after some seconds without a +// picture, or far past the limit, do the motors cut (failsafe), so there is time to turn back. +inline constexpr float range_clean = 0.5f; // up to this fraction of the range the picture is clean +inline constexpr float range_failsafe = 1.5f; // past this fraction the motors cut at once +inline constexpr float failsafe_after_seconds = 5.0f; // this long without a picture and the motors cut +inline float signal_strength(float distance, float range) { + if (!(range > 0.0f) || !std::isfinite(distance)) return 1.0f; + const float q = distance / range; + if (q <= range_clean) return 1.0f; + if (q >= 1.0f) return 0.0f; + const float t = (q - range_clean) / (1.0f - range_clean); // 0 at the clean edge, 1 at the limit + return std::clamp(1.0f - t * t * (3.0f - 2.0f * t), 0.0f, 1.0f); // smooth fade +} +inline bool out_of_failsafe_range(float distance, float range) { + return range > 0.0f && std::isfinite(distance) && distance > range * range_failsafe; +} +// Whether the motors cut now: too long without a picture, or far past the limit. +inline bool failsafe_due(float distance, float range, float seconds_lost) { + return out_of_failsafe_range(distance, range) || (range > 0.0f && seconds_lost >= failsafe_after_seconds); +} + +// 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 +// 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 { + const char* name; + const char* description; + float top_speed; // flyspeed + std::array values; +}; +inline constexpr Preset presets[] = { + {"Tinywhoop", "A 65 mm ducted whoop: small, slow and forgiving, made for indoors and tight lines.", 3.5f, + {{{kHover, 0.45f}, {kDrag, 0.60f}, {kRadius, 0.06f}, {kUptilt, 15.0f}, + {kRollCenter, 70.0f}, {kRollMax, 600.0f}, {kRollExpo, 0.30f}, {kPitchCenter, 70.0f}, {kPitchMax, 600.0f}, {kPitchExpo, 0.30f}, + {kYawCenter, 100.0f}, {kYawMax, 500.0f}, {kYawExpo, 0.30f}, {kMotorPitch, 2.0f}}}}, + {"Toothpick", "A light 3-inch: quick and nimble, faster than a whoop without the punch of a 5-inch.", 6.0f, + {{{kHover, 0.30f}, {kDrag, 0.30f}, {kRadius, 0.10f}, {kUptilt, 25.0f}, + {kRollCenter, 80.0f}, {kRollMax, 700.0f}, {kRollExpo, 0.20f}, {kPitchCenter, 80.0f}, {kPitchMax, 700.0f}, {kPitchExpo, 0.20f}, + {kYawCenter, 130.0f}, {kYawMax, 650.0f}, {kYawExpo, 0.30f}, {kMotorPitch, 1.5f}}}}, + {"5 inch", "A 5-inch freestyle quad: lots of power and speed, for big lines across the map.", 10.0f, + {{{kHover, 0.22f}, {kDrag, 0.12f}, {kRadius, 0.25f}, {kUptilt, 30.0f}, + {kRollCenter, 80.0f}, {kRollMax, 720.0f}, {kRollExpo, 0.15f}, {kPitchCenter, 80.0f}, {kPitchMax, 720.0f}, {kPitchExpo, 0.15f}, + {kYawCenter, 130.0f}, {kYawMax, 780.0f}, {kYawExpo, 0.30f}, {kMotorPitch, 0.85f}}}}, +}; +inline constexpr std::size_t preset_count = std::size(presets); +// The preset the current settings match, or -1 (custom). +inline int matching_preset(float top_speed) { + for (std::size_t p = 0; p < preset_count; ++p) { + bool same = std::abs(top_speed - presets[p].top_speed) < 0.05f; + for (const auto& v : presets[p].values) + same = same && std::abs(knobs().value[v.knob].load(std::memory_order_relaxed) - v.value) < 0.005f * std::max(1.0f, std::abs(v.value)); + if (same) return static_cast(p); + } + return -1; +} + +namespace detail { +using Vec3 = std::array; +constexpr float kDegToRad = 0.017453292519943295f; + +inline Vec3 row(const std::array& m, int r) { return {m[r * 4], m[r * 4 + 1], m[r * 4 + 2]}; } +inline void set_row(std::array& m, int r, const Vec3& v) { + m[r * 4] = v[0]; m[r * 4 + 1] = v[1]; m[r * 4 + 2] = v[2]; +} +inline float dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +inline Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} +inline Vec3 add(const Vec3& a, const Vec3& b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; } +inline Vec3 scale(const Vec3& a, float s) { return {a[0] * s, a[1] * s, a[2] * s}; } +inline Vec3 normalize(const Vec3& a) { + const float len = std::sqrt(dot(a, a)); + return len > 1e-8f ? scale(a, 1.0f / len) : Vec3{0, 0, 0}; +} +// Rodrigues rotation of v about unit axis by angle (right-handed). +inline Vec3 rotate(const Vec3& v, const Vec3& axis, float angle) { + const float c = std::cos(angle), s = std::sin(angle); + return add(add(scale(v, c), scale(cross(axis, v), s)), scale(axis, dot(axis, v) * (1 - c))); +} +// Rotate the whole basis about a world-space axis, then re-orthonormalise. +inline void rotate_basis(std::array& m, const Vec3& axis, float angle) { + for (int r = 0; r < 3; ++r) set_row(m, r, rotate(row(m, r), axis, angle)); + Vec3 right = normalize(row(m, 0)); + Vec3 back = normalize(cross(right, row(m, 1))); // right x up = backward + Vec3 up = cross(back, right); + set_row(m, 0, right); set_row(m, 1, up); set_row(m, 2, back); +} +// Betaflight's actual-rates curve: deg/s for a stick position -1..1. +inline float actual_rate(float stick, const Rate& r) { + const float a = std::min(std::abs(stick), 1.0f); + const float expo = std::clamp(r.expo, 0.0f, 1.0f); + const float curve = a * (a * a * a * a * a * expo + a * (1.0f - expo)); + const float movement = std::max(0.0f, r.max - r.center); + return std::copysign(r.center * a + movement * curve, stick); +} +inline float clamp1(float v) { return std::clamp(std::isfinite(v) ? v : 0.0f, -1.0f, 1.0f); } +inline std::array camera_from_body(const std::array& body, const std::array& position, + float uptilt_rad) { + auto cam = body; + rotate_basis(cam, row(body, 0), uptilt_rad); // + about the right axis = look up + cam[12] = position[0]; cam[13] = position[1]; cam[14] = position[2]; cam[15] = body[15]; + return cam; +} +} + +// Radio switches: an arm switch and a reset switch, each an axis (-6..6) or a button (1..32), if mapped. +// Call once per tick with the raw axes (-1..1) and the pressed buttons. An axis has to go well past +// the middle to count (above +0.5 = on, below -0.5 = off for arming; +0.5 then back under +0.2 for +// reset), so a stick that drifts a little never triggers them. Do not use an axis a stick is on. +inline void apply_switches(const std::array& axes, std::uint32_t buttons = 0) { + static bool reset_axis_high = false, reset_button_down = false; + const auto number = [](Knob k) { + return static_cast(std::lround(knobs().value[k].load(std::memory_order_relaxed))); + }; + const auto pressed = [buttons](int n) { return n >= 1 && n <= 32 && (buttons & (1u << (n - 1))) != 0; }; + if (const int a = number(kArmAxis); a != 0 && std::abs(a) <= 6) { + const float v = radio::pick_axis(axes, a); + if (v > 0.5f) set_armed(true); + else if (v < -0.5f) set_armed(false); + } + if (const int n = number(kArmButton); n != 0) set_armed(pressed(n)); + if (const int a = number(kResetAxis); a != 0 && std::abs(a) <= 6) { + const float v = radio::pick_axis(axes, a); + if (v > 0.5f && !reset_axis_high) { request_reset(); reset_axis_high = true; } + else if (v < 0.2f) reset_axis_high = false; + } + if (const int n = number(kResetButton); n != 0) { + const bool down = pressed(n); + if (down && !reset_button_down) request_reset(); + reset_button_down = down; + } +} + +// The world the drone can bump into. `cast(from, to, hit)` returns true and the hit point when +// something solid lies on the line. Without it (or with collide off) the drone flies through everything. +struct World { + std::function& from, const std::array& to, std::array& hit)> cast; +}; + +// What the pilot is asking for, whatever the input device. +struct Cmd { + float pitch = 0; // + = nose up + float roll = 0; // + = roll right + float yaw_rad = 0; // radians this step; negative = turn right + float look_pitch_rad = 0; // extra pitch from the mouse (radians, + = look down) + float throttle = 0.35f; // 0..1 absolute + bool boost = false; + bool park = false; // motors off: hold position +}; + +// One step of the flight model. `camera` is the pose published last tick (its translation is +// the drone position). +inline std::array step_drone_cmd(const std::array& camera, State& state, const Cmd& cmd_in, + float seconds, float speed, const Settings& cfg = {}, const World* world = nullptr) { + using namespace detail; + if (!valid_flight_transform(camera) || !std::isfinite(seconds) || !std::isfinite(speed)) return camera; + const float dt = std::clamp(seconds, 0.0f, 0.05f); + const float uptilt = cfg.camera_uptilt_deg * kDegToRad; + Vec3 position{camera[12], camera[13], camera[14]}; + + if (!state.valid) { + // Start from the current view with the FPV tilt removed, at rest. + state.body = camera; + rotate_basis(state.body, row(camera, 0), -uptilt); + state.velocity = {0, 0, 0}; + state.start_y = position[1]; + state.flight_time = 0; + state.start_pos = position; + state.start_body = state.body; + state.valid = true; + state.failsafe = false; + reset_flag().store(false, std::memory_order_relaxed); + } + if (reset_flag().exchange(false, std::memory_order_acq_rel)) { + state.body = state.start_body; + state.velocity = {0, 0, 0}; + state.flight_time = 0; + position = state.start_pos; + state.failsafe = false; + state.signal_lost = 0; + } + // Disarmed: the pilot's inputs and the motors are off. + Cmd cmd = cmd_in; + // Range: the link weakens with distance from the takeoff point, and past the failsafe + // distance the motors cut until the drone is reset (signal_strength above). + const float home = std::sqrt(dot(add(position, scale(state.start_pos, -1.0f)), add(position, scale(state.start_pos, -1.0f)))); + const float quality = signal_strength(home, cfg.range); + state.signal_lost = quality <= 0.0f ? state.signal_lost + dt : 0.0f; + if (failsafe_due(home, cfg.range, state.signal_lost)) state.failsafe = true; + { + auto& link = telemetry(); + link.signal.store(quality, std::memory_order_relaxed); + link.signal_lost.store(state.signal_lost, std::memory_order_relaxed); + link.failsafe.store(state.failsafe, std::memory_order_relaxed); + link.home_distance.store(home, std::memory_order_relaxed); + } + if (!cfg.armed || state.failsafe) { + cmd = Cmd{}; + cmd.throttle = 0.0f; + cmd.park = cfg.disarm_freeze; + } + + const float pitch_in = clamp1(cmd.pitch); + const float roll_in = clamp1(cmd.roll); + const float yaw_delta = std::isfinite(cmd.yaw_rad) ? cmd.yaw_rad : 0.0f; + const float look_pitch = std::isfinite(cmd.look_pitch_rad) ? cmd.look_pitch_rad : 0.0f; + + // Attitude + if (!cfg.angle_mode) { + const float dp = (actual_rate(pitch_in, cfg.pitch) * kDegToRad) * dt - look_pitch; + const float dr = (actual_rate(roll_in, cfg.roll) * kDegToRad) * dt; + rotate_basis(state.body, row(state.body, 1), yaw_delta); // yaw about body up + rotate_basis(state.body, row(state.body, 0), dp); // pitch about body right + rotate_basis(state.body, scale(row(state.body, 2), -1.0f), dr); // roll about body forward + } else { + // Heading from yaw input; tilt eases toward the commanded angle. + const Vec3 fwd = scale(row(state.body, 2), -1.0f); + const float heading = std::atan2(fwd[0], -fwd[2]); + const float new_heading = heading - yaw_delta; + const float max_a = cfg.max_angle_deg * kDegToRad; + const float k = 1 - std::exp(-cfg.angle_response * dt); + const float cur_pitch = std::asin(std::clamp(fwd[1], -1.0f, 1.0f)); + const float cur_roll = std::asin(std::clamp(row(state.body, 0)[1], -1.0f, 1.0f)) * -1.0f; + const float want_pitch = pitch_in * max_a, want_roll = roll_in * max_a; + const float p = cur_pitch + (want_pitch - cur_pitch) * k - look_pitch; + const float r = cur_roll + (want_roll - cur_roll) * k; + Vec3 f{std::sin(new_heading), 0, -std::cos(new_heading)}; + Vec3 right{-f[2], 0, f[0]}; + Vec3 up{0, 1, 0}; + std::array m = state.body; + set_row(m, 0, right); set_row(m, 1, up); set_row(m, 2, scale(f, -1.0f)); + rotate_basis(m, right, p); + rotate_basis(m, scale(row(m, 2), -1.0f), r); + state.body = m; + } + + // Translation + Vec3 next_pos = position; + if (cmd.park) { + state.velocity = {0, 0, 0}; + } else { + const Vec3 up = row(state.body, 1); + const float thrust = std::clamp(cmd.throttle, 0.0f, 1.0f) * (cfg.gravity / cfg.hover_throttle); + const float spd = std::sqrt(dot(state.velocity, state.velocity)); + const float drag = cfg.lin_drag + cfg.quad_drag * spd; + Vec3 accel{up[0] * thrust - state.velocity[0] * drag, + up[1] * thrust - cfg.gravity - state.velocity[1] * drag, + up[2] * thrust - state.velocity[2] * drag}; + for (int i = 0; i < 3; ++i) state.velocity[i] += accel[i] * dt; + const float cap = std::clamp(speed, 0.6f, 1500.0f) * 4.0f * (cmd.boost ? 2.0f : 1.0f); + const float now = std::sqrt(dot(state.velocity, state.velocity)); + if (now > cap) for (auto& v : state.velocity) v *= cap / now; + next_pos = {position[0] + state.velocity[0] * dt, position[1] + state.velocity[1] * dt, + position[2] + state.velocity[2] * dt}; + if (world && world->cast && cfg.collide) { + // Swept move, one axis at a time (up/down first so landing works), so it slides along + // walls and rests on the ground. The game's query gives hit points, not surface normals. + Vec3 p = position; + const Vec3 d{next_pos[0] - position[0], next_pos[1] - position[1], next_pos[2] - position[2]}; + const float r = std::max(cfg.body_radius, 0.01f); + for (const int axis : {1, 0, 2}) { + const float move = d[static_cast(axis)]; + if (move == 0.0f) continue; + const auto a = static_cast(axis); + const float sign = move > 0 ? 1.0f : -1.0f; + Vec3 to = p; + to[a] += move + sign * r; + Vec3 hit{}; + if (world->cast(p, to, hit)) { + const float allowed = (hit[a] - p[a]) - sign * r; + p[a] += sign > 0 ? std::clamp(allowed, 0.0f, move) : std::clamp(allowed, move, 0.0f); + state.velocity[a] = 0; + if (axis == 1 && sign < 0) { // landed: skid to a stop + const float grip = std::exp(-6.0f * dt); + state.velocity[0] *= grip; + state.velocity[2] *= grip; + } + } else { + p[a] += move; + } + } + next_pos = p; + // Safety net if the collision world is not streamed in yet: do not fall forever. + const float deep = state.start_y - 300.0f; + if (next_pos[1] < deep) { next_pos[1] = deep; if (state.velocity[1] < 0) state.velocity[1] = 0; } + } else if (cfg.floor_depth > 0.0f) { + // No collisions: an invisible floor so it lands instead of falling through the map. + const float floor_y = state.start_y - cfg.floor_depth; + if (next_pos[1] < floor_y) { + next_pos[1] = floor_y; + if (state.velocity[1] < 0) state.velocity[1] = 0; + const float grip = std::exp(-6.0f * dt); + state.velocity[0] *= grip; + state.velocity[2] *= grip; + } + } + } + + const auto out = camera_from_body(state.body, next_pos, uptilt); + if (!valid_flight_transform(out)) { state.valid = false; return camera; } + if (cfg.armed) state.flight_time += dt; + auto& t = telemetry(); + t.throttle.store(cmd.park ? 0.0f : std::clamp(cmd.throttle, 0.0f, 1.0f), std::memory_order_relaxed); + t.speed.store(std::sqrt(dot(state.velocity, state.velocity)), std::memory_order_relaxed); + t.altitude.store(next_pos[1] - state.start_y, std::memory_order_relaxed); + t.seconds.store(state.flight_time, std::memory_order_relaxed); + t.armed.store(cfg.armed, std::memory_order_relaxed); + beacon::publish_local(next_pos, beacon::rotation_from_basis(state.body), cfg.beacon); + t.last_ms.store(now_ms(), std::memory_order_relaxed); + return out; +} + +// Keyboard / mouse / game-controller input (the Freecam's own FlightInput). +inline std::array step_drone_flight(const std::array& camera, State& state, + const overlay::FlightInput& input, float seconds, float speed, const Settings& cfg = {}, + const World* world = nullptr) { + using namespace detail; + if (!input.active || !std::isfinite(input.look_x) || !std::isfinite(input.look_y)) return camera; + Cmd cmd; + cmd.pitch = clamp1(-input.forward); // forward stick = nose down + cmd.roll = clamp1(input.right); + cmd.yaw_rad = -std::clamp(input.look_x, -250.0f, 250.0f) * cfg.yaw_look_scale; // right = negative about up + cmd.look_pitch_rad = std::clamp(input.look_y, -250.0f, 250.0f) * cfg.yaw_look_scale; + // Keyboard has no absolute throttle: neutral hovers, Q/E climb or descend. + const float up_in = clamp1(input.up); + cmd.throttle = up_in >= 0 ? cfg.hover_throttle + up_in * (1.0f - cfg.hover_throttle) + : cfg.hover_throttle + up_in * cfg.hover_throttle; + cmd.boost = input.boost; + { + auto& t = telemetry(); + t.stick_roll.store(clamp1(input.right), std::memory_order_relaxed); + t.stick_pitch.store(clamp1(input.forward), std::memory_order_relaxed); + t.stick_yaw.store(clamp1(input.look_x / 40.0f), std::memory_order_relaxed); + } + return step_drone_cmd(camera, state, cmd, seconds, speed, cfg, world); +} + +// Radio controller: sticks drive rates directly and the throttle stick is absolute thrust. A throttle +// at the bottom lets the drone fall, as a real one would. +inline std::array step_drone_radio(const std::array& camera, State& state, + const radio::Command& sticks, float seconds, float speed, const Settings& cfg = {}, const World* world = nullptr) { + using namespace detail; + if (!sticks.valid) return camera; + const float dt = std::clamp(std::isfinite(seconds) ? seconds : 0.0f, 0.0f, 0.05f); + Cmd cmd; + cmd.pitch = clamp1(-sticks.pitch); // stick forward = nose down + cmd.roll = clamp1(sticks.roll); + cmd.yaw_rad = -actual_rate(clamp1(sticks.yaw), cfg.yaw) * kDegToRad * dt; + cmd.throttle = std::clamp(sticks.throttle, 0.0f, 1.0f); + { + auto& t = telemetry(); + t.stick_roll.store(clamp1(sticks.roll), std::memory_order_relaxed); + t.stick_pitch.store(clamp1(sticks.pitch), std::memory_order_relaxed); + t.stick_yaw.store(clamp1(sticks.yaw), std::memory_order_relaxed); + } + return step_drone_cmd(camera, state, cmd, seconds, speed, cfg, world); +} +} diff --git a/Extension/Skater/drone_font.h b/Extension/Skater/drone_font.h new file mode 100644 index 00000000..2d2e4a5c --- /dev/null +++ b/Extension/Skater/drone_font.h @@ -0,0 +1,64 @@ +#pragma once +// A 5x7 pixel font for the drone OSD (goggle-style text), drawn pixel by pixel so no font file is needed. +// Each glyph is 7 rows; in a row, bit 4 is the leftmost pixel. +#include +#include + +namespace dingosdk::drone::font { + +struct Glyph { char ch; std::array rows; }; +inline constexpr Glyph glyphs[] = { + {'A', {0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11}}, + {'B', {0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e}}, + {'C', {0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e}}, + {'D', {0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e}}, + {'E', {0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f}}, + {'F', {0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10}}, + {'G', {0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0e}}, + {'H', {0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11}}, + {'I', {0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e}}, + {'J', {0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c}}, + {'K', {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11}}, + {'L', {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f}}, + {'M', {0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11}}, + {'N', {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11}}, + {'O', {0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e}}, + {'P', {0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10}}, + {'Q', {0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d}}, + {'R', {0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11}}, + {'S', {0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e}}, + {'T', {0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}}, + {'U', {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e}}, + {'V', {0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04}}, + {'W', {0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11}}, + {'X', {0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11}}, + {'Y', {0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04}}, + {'Z', {0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f}}, + {'0', {0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e}}, + {'1', {0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e}}, + {'2', {0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f}}, + {'3', {0x1e, 0x01, 0x01, 0x0e, 0x01, 0x01, 0x1e}}, + {'4', {0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02}}, + {'5', {0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e}}, + {'6', {0x0e, 0x10, 0x10, 0x1e, 0x11, 0x11, 0x0e}}, + {'7', {0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}}, + {'8', {0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e}}, + {'9', {0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c}}, + {'.', {0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c}}, + {':', {0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00}}, + {'%', {0x19, 0x19, 0x02, 0x04, 0x08, 0x13, 0x13}}, + {'/', {0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10}}, + {'-', {0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00}}, + {'+', {0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00}}, + {' ', {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, +}; + +// Rows for a character (lower case is shown as capitals); blank for anything unknown. +inline std::array glyph(char c) { + if (c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); + for (const auto& g : glyphs) if (g.ch == c) return g.rows; + return {}; +} + +constexpr int columns = 5, rows = 7; +} // namespace dingosdk::drone::font diff --git a/Extension/Skater/drone_osd.h b/Extension/Skater/drone_osd.h new file mode 100644 index 00000000..90992ab2 --- /dev/null +++ b/Extension/Skater/drone_osd.h @@ -0,0 +1,104 @@ +#pragma once +// Goggle-style on-screen display and stick overlay for the drone. This file only decides WHAT to +// show and where (so it can be tested anywhere); the overlay draws it (UI/Overlay/drone_hud_overlay.cpp). +#include "drone_flight.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::drone::osd { + +// A pretend 4S pack: 16.8 V full, sagging a little with throttle and draining over the flight. +inline float battery_volts(float seconds, float throttle) { + return std::max(13.2f, 16.8f - 0.0045f * seconds - 0.7f * throttle); +} + +// RGB for the `osdcolor` presets: white, green, yellow, cyan, orange, pink. +inline std::array preset_colour(int index) { + static constexpr std::array, 6> table{{ + {255, 255, 255}, {90, 255, 90}, {255, 232, 70}, {90, 232, 255}, {255, 150, 50}, {255, 100, 220}}}; + return table[static_cast(std::clamp(index, 0, 5))]; +} + +enum class Align { left, centre, right }; +struct Item { + float x = 0, y = 0; // fractions of the screen; y is the top of the text, or its bottom when `bottom` + Align align = Align::left; + bool bottom = false; + bool warn = false; // drawn red + std::string text; +}; +struct Sticks { + bool show = false; + float size = 1; + float yaw = 0, throttle = 0, roll = 0, pitch = 0; // each -1..1 (throttle: -1 bottom, 1 top; pitch: + = forward = up) +}; +struct Layout { + bool active = false; + float scale = 1.5f; + int font = 0; + int colour = 0; + float margin = 0.06f; + bool crosshair = false; + std::vector items; + Sticks sticks; +}; + +inline Layout build() { + Layout out; + if (!flying()) return out; + out.active = true; + auto knob = [](Knob k) { return knobs().value[k].load(std::memory_order_relaxed); }; + const auto& t = telemetry(); + const float throttle = t.throttle.load(std::memory_order_relaxed); + const float speed = t.speed.load(std::memory_order_relaxed); + const float altitude = t.altitude.load(std::memory_order_relaxed); + const float seconds = t.seconds.load(std::memory_order_relaxed); + out.scale = knob(kOsdScale); + out.font = knob(kOsdFont) >= 0.5f ? 1 : 0; + out.colour = static_cast(knob(kOsdColor) + 0.5f); + out.margin = std::clamp(knob(kOsdMargin), 0.0f, 0.3f); + out.crosshair = knob(kCrosshair) >= 0.5f; + const float m = out.margin; + auto add = [&](bool on, float x, float y, Align a, bool bottom, std::string text, bool warn = false) { + if (!on) return; + Item item; + item.x = x; item.y = y; item.align = a; item.bottom = bottom; item.warn = warn; item.text = std::move(text); + out.items.push_back(std::move(item)); + }; + const float volts = battery_volts(seconds, throttle); + const bool low = volts < 14.0f; + const bool blink = (static_cast(seconds * 2.0f) & 1) != 0; + const int total = static_cast(seconds); + add(knob(kShowBattery) >= 0.5f, m, m, Align::left, false, std::format("{:.1f}V", volts), low && blink); + const bool armed_now = t.armed.load(std::memory_order_relaxed); + add(knob(kShowArmed) >= 0.5f, 0.5f, m, Align::centre, false, armed_now ? "ARMED" : "DISARMED", !armed_now); + add(knob(kShowTimer) >= 0.5f, 1.0f - m, m, Align::right, false, std::format("{:02}:{:02}", total / 60, total % 60)); + add(knob(kShowThrottle) >= 0.5f, m, 1.0f - m, Align::left, true, std::format("THR {:.0f}%", throttle * 100.0f)); + add(knob(kShowAlt) >= 0.5f, 0.5f, 1.0f - m, Align::centre, true, std::format("ALT {:.1f}M", altitude)); + add(knob(kShowSpeed) >= 0.5f, 1.0f - m, 1.0f - m, Align::right, true, std::format("{:.0f}KM/H", speed * 3.6f)); + // Range: link quality and the way home, and what happens at the edge. + if (knob(kRange) > 0.0f) { + const float signal = t.signal.load(std::memory_order_relaxed); + add(true, 1.0f - m, m + 0.05f, Align::right, false, + std::format("RSSI {:.0f}% {:.0f}M", signal * 100.0f, t.home_distance.load(std::memory_order_relaxed)), signal < 0.35f && blink); + if (t.failsafe.load(std::memory_order_relaxed)) add(true, 0.5f, 0.42f, Align::centre, false, "FAILSAFE - RESET TO FLY AGAIN", true); + else if (signal <= 0.0f) { + const float left = std::max(0.0f, failsafe_after_seconds - t.signal_lost.load(std::memory_order_relaxed)); + add(true, 0.5f, 0.42f, Align::centre, false, std::format("NO SIGNAL - TURN BACK ({:.0f})", std::ceil(left)), true); + } + } + out.sticks.show = knob(kSticks) >= 0.5f; + out.sticks.size = knob(kStickSize); + out.sticks.yaw = t.stick_yaw.load(std::memory_order_relaxed); + out.sticks.roll = t.stick_roll.load(std::memory_order_relaxed); + out.sticks.pitch = t.stick_pitch.load(std::memory_order_relaxed); + out.sticks.throttle = std::clamp(throttle, 0.0f, 1.0f) * 2.0f - 1.0f; + return out; +} +} // namespace dingosdk::drone::osd diff --git a/Extension/Skater/drone_outfit.cpp b/Extension/Skater/drone_outfit.cpp new file mode 100644 index 00000000..c29cba2b --- /dev/null +++ b/Extension/Skater/drone_outfit.cpp @@ -0,0 +1,164 @@ +#include "drone_outfit.h" +#include "Extension/RoadRash/skater_items.h" +#include "Extension/Skater/no_bail.h" +#include "Extension/Skater/drone_flight.h" +#include "Extension/RoadRash/road_rash.h" +#include "Engine/Core/Log/logging.h" +#include +#include +#include +#include + +namespace dingosdk::drone::outfit { +namespace { +using road_rash::SkaterItems; +NoBailSkater local_skater() { + NoBailSkater skater; + if (!no_bail_skater(skater)) throw std::runtime_error("No local skater right now (load into a level first)."); + return skater; +} +} + +// ---- Goggles while flying. +namespace { +struct Worn { + bool on{}; // our eyewear is on the skater + std::uintptr_t entity{}, component{}; + std::string item; // what we put on + std::optional before; // how the game fed the skater its outfit before; empty: it did not + std::string original; // what the eyewear slot held before + std::vector original_parameters; + std::uint64_t last_flying{}, retry_after{}; + std::string last_error; +}; +Worn& worn() { static auto* value = new Worn; return *value; } + +int eyewear_slot(const SkaterItems& m, std::uintptr_t resource) { + const auto table = m.ptr(resource, 0x28); + const auto count = m.count(table, 24, multiplayer::max_cosmetic_slots); + for (std::size_t i = 0; i < count; ++i) { + const auto slot = m.ptr(table, i * 24 + 8) & ~std::uintptr_t{4}; + if (road_rash::lower(m.text(m.ptr(slot, 0x20))).find("eyewear") != std::string::npos) return static_cast(i); + } + return -1; +} +void put_on(Worn& w, const SkaterItems& m, std::uintptr_t entity, const std::string& item) { + const auto component = m.component(entity); + auto recipe = m.capture(entity); // throws while the skater's items are still updating: tried again later + const int slot = eyewear_slot(m, m.resource(component)); + SkaterItems::check(slot >= 0 && static_cast(slot) < recipe.items.size(), "The skater has no eyewear slot."); + SkaterItems::check(!road_rash::installed_item(m, road_rash::lower(item)).empty(), "That eyewear is not installed."); + auto& eyes = recipe.items[static_cast(slot)]; + if (!w.on) { + const auto fed = road_rash::feed(m, component); + w.before = fed.follows_outfit() ? std::optional(fed) : std::nullopt; + w.original = eyes.asset; + w.original_parameters = eyes.parameters; + } + if (eyes.asset == item) { w.on = true; w.entity = entity; w.component = component; w.item = item; return; } + eyes.asset = item; + road_rash::apply(m, component, recipe); + w.on = true; + w.entity = entity; + w.component = component; + w.item = item; + logging::log(logging::Level::info, logging::Channel::skater, "Drone: the pilot puts on {}.", item); +} +void take_off(Worn& w, const SkaterItems& m, std::uintptr_t entity) { + const auto component = m.component(entity); + // Road Rash keeps the skater on a recipe of its own: put only the eyewear back, so its marks stay. + if (w.before && component == w.component && !road_rash::enabled()) { + road_rash::hand_back(m, component, *w.before); // the game puts the player's whole outfit back + } else { + auto recipe = m.capture(entity); + const int slot = eyewear_slot(m, m.resource(component)); + SkaterItems::check(slot >= 0 && static_cast(slot) < recipe.items.size(), "The skater has no eyewear slot."); + auto& eyes = recipe.items[static_cast(slot)]; + if (eyes.asset == w.item) { + eyes.asset = w.original; + eyes.parameters = w.original_parameters; + road_rash::apply(m, component, recipe); + } + } + logging::log(logging::Level::info, logging::Channel::skater, "Drone: the pilot takes the eyewear off."); + w = Worn{}; +} +} + +void on_client_tick() noexcept { + auto& w = worn(); + const auto now = GetTickCount64(); + if (now < w.retry_after) return; + const int choice = static_cast(std::lround(knobs().value[kGoggles].load(std::memory_order_relaxed))); + if (flying()) w.last_flying = now; + // A moment's grace, so a pause or a reset does not take the goggles off and on again. + const bool airborne = w.last_flying && now - w.last_flying < 1500; + const bool wanted = airborne && choice >= 1 && static_cast(choice) <= eyewear_count; + if (!wanted && !w.on) return; + NoBailSkater skater; + if (!no_bail_skater(skater)) { w = Worn{}; return; } // no skater (a level change): it comes back as the profile has it + if (w.on && skater.entity != w.entity) { w = Worn{}; return; } // a new skater: ours is gone with the old one + try { + const SkaterItems m{road_rash::readable, skater.base}; + if (wanted) { + const std::string item = eyewear[static_cast(choice - 1)].item; + if (!w.on || w.item != item) put_on(w, m, skater.entity, item); + } else { + take_off(w, m, skater.entity); + } + w.last_error.clear(); + } catch (const std::exception& error) { + w.retry_after = now + 500; + if (w.last_error != error.what()) { + w.last_error = error.what(); + logging::log(logging::Level::info, logging::Channel::skater, "Drone goggles: {}", error.what()); + } + } catch (...) { + w.retry_after = now + 500; + } +} + +std::vector find_items(std::string_view word, std::size_t limit, std::size_t& total) { + total = 0; + const auto skater = local_skater(); + const SkaterItems m{road_rash::readable, skater.base}; + const auto wanted = road_rash::lower(word); + const auto manager = m.ptr(m.base, addr::engine::cosmetics_manager); + m.check(m.ptr(manager) == m.base + addr::engine::cosmetics_manager_vtable && m.get(manager, 0xa8) == 1, + "The cosmetic catalog is not ready."); + const auto buckets = m.ptr(manager, 0x30); + const auto size = m.get(manager, 0x38), count = m.get(manager, 0x3c); + m.check(size && size <= 16384 && count && count <= 8192, "The cosmetic catalog exceeds its bounds."); + std::vector out; + std::uint32_t visited{}; + for (std::uint32_t bucket = 0; bucket < size && visited < count; ++bucket) { + auto node = m.ptr(buckets, bucket * 8ULL); + for (std::uint32_t chain = 0; node && chain < count && visited < count; ++chain, ++visited, node = m.ptr(node, 0x10)) { + const auto asset = m.ptr(node, 8) & ~std::uintptr_t{4}; + if (!asset) continue; + auto name = m.text(m.ptr(asset, 0x38)); + if (name.empty() || road_rash::lower(name).find(wanted) == std::string::npos) continue; + ++total; + if (out.size() < limit) out.push_back(std::move(name)); + } + } + return out; +} + +std::vector slots() { + const auto skater = local_skater(); + const SkaterItems m{road_rash::readable, skater.base}; + const auto recipe = m.capture(skater.entity); + const auto resource = m.resource(m.component(skater.entity)); + const auto table = m.ptr(resource, 0x28); + const auto count = m.count(table, 24, multiplayer::max_cosmetic_slots); + std::vector out; + for (std::size_t i = 0; i < count; ++i) { + const auto slot = m.ptr(table, i * 24 + 8) & ~std::uintptr_t{4}; + const auto name = m.text(m.ptr(slot, 0x20)); + const auto& held = i < recipe.items.size() ? recipe.items[i].asset : std::string{}; + out.push_back(std::format("{:>2} {:<28} {}", i, name, held.empty() ? "-" : held)); + } + return out; +} +} diff --git a/Extension/Skater/drone_outfit.h b/Extension/Skater/drone_outfit.h new file mode 100644 index 00000000..74ede703 --- /dev/null +++ b/Extension/Skater/drone_outfit.h @@ -0,0 +1,31 @@ +#pragma once +// What the drone can put on the pilot's skater while it flies (goggles, a hat): for now, finding +// the items. `droneitems` lists the installed cosmetics whose names contain a word, and the local +// skater's outfit slots, so the right item can be chosen. Game thread (console commands). +#include +#include +#include + +namespace dingosdk::drone::outfit { +// What the `goggles` setting picks (1 = the first): eyewear from the game's own catalog that +// looks most like FPV goggles. The pilot's skater wears it while the drone flies. +struct Eyewear { const char* label; const char* item; }; +inline constexpr Eyewear eyewear[] = { + {"Large visor 1", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00001"}, + {"Large visor 2", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00002"}, + {"Large visor 3", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00003"}, + {"Large visor 4", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00004"}, + {"Shield aviators", "Own_EyewearGlasses_Gen_ShieldAviator_00001"}, + {"Wraparounds", "Own_EyewearGlasses_Gen_WrapAround_00001"}, + {"Slim wraparounds", "Own_EyewearGlasses_Gen_SunglassesWraparoundSlim_00001"}, + {"Speed shades", "Own_EyewearGlasses_Dime_SpeedShades_00001"}, +}; +inline constexpr std::size_t eyewear_count = sizeof(eyewear) / sizeof(eyewear[0]); +// Once per client tick (Runtime/client_tick.cpp): puts the chosen eyewear on the local skater +// while the drone flies and gives the skater's own back when it stops. +void on_client_tick() noexcept; +// Installed cosmetic names containing `word` (any case), at most `limit` of them; `total` is how many matched. +std::vector find_items(std::string_view word, std::size_t limit, std::size_t& total); +// The local skater's outfit slots, one line each: index, slot name and what it holds now. +std::vector slots(); +} diff --git a/Extension/Skater/drone_radio.h b/Extension/Skater/drone_radio.h new file mode 100644 index 00000000..bbe693b7 --- /dev/null +++ b/Extension/Skater/drone_radio.h @@ -0,0 +1,336 @@ +#pragma once +// USB radio-controller input for the FPV drone (RadioMaster, Jumper, FrSky... anything that +// shows up in Windows' "Set up USB game controllers" when its USB mode is Joystick). +// +// The pure maths (normalise / dead-zone / axis mapping) has no Windows dependency so it can be +// unit-tested anywhere. Only poll() and game_in_foreground() need Windows (winmm). +// +// Axis numbers 1..6 are winmm's X, Y, Z, R, U, V. `droneradiocheck` prints all six so you can see +// which one each stick moves; `droneaxes roll pitch throttle yaw` maps them (negative flips). +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winmm.lib") +#endif + +namespace dingosdk::drone::radio { + +// Mode 2 on an EdgeTX radio sends AETR: channel 1 aileron (roll), 2 elevator (pitch), +// 3 throttle, 4 rudder (yaw). Those are the defaults below: roll=1, pitch=2, throttle=3, yaw=4. +struct Config { + std::atomic enabled{false}; + std::atomic map[4]; // roll, pitch, throttle, yaw -> signed axis number (1..6) + std::atomic device{-1}; // -1 = pick automatically + Config() { for (int i = 0; i < 4; ++i) map[i] = i + 1; } +}; +inline Config& config() { static Config value; return value; } +inline std::array mapping_snapshot() { + auto& c = config(); + return {c.map[0].load(), c.map[1].load(), c.map[2].load(), c.map[3].load()}; +} + +// Sticks as the flight model wants them. +struct Command { + bool valid = false; + float roll = 0; // + = right + float pitch = 0; // + = stick forward (nose down) + float yaw = 0; // + = right + float throttle = 0; // 0..1 (absolute; the throttle stick does not self-centre) +}; + +inline float normalize_axis(std::uint32_t value, std::uint32_t lo, std::uint32_t hi) { + if (hi <= lo) return 0.0f; + const float t = (static_cast(value) - static_cast(lo)) / (static_cast(hi) - static_cast(lo)); + return std::clamp(t * 2.0f - 1.0f, -1.0f, 1.0f); +} +inline float deadzone(float v, float dz) { + const float a = std::abs(v); + if (!(a > dz)) return 0.0f; + return std::copysign(std::min(1.0f, (a - dz) / (1.0f - dz)), v); +} +inline float pick_axis(const std::array& axes, int signed_index) { + const int index = std::abs(signed_index) - 1; + if (index < 0 || index > 5) return 0.0f; + return signed_index < 0 ? -axes[index] : axes[index]; +} +// A throttle stick that springs back to the middle (a gamepad's): the middle holds the drone up, +// up climbs to full, down drops to nothing. `hover` is the throttle that holds altitude. +inline float spring_throttle(float stick, float hover) { + const float h = std::clamp(std::isfinite(hover) ? hover : 0.35f, 0.05f, 0.95f); + const float v = std::clamp(std::isfinite(stick) ? stick : 0.0f, -1.0f, 1.0f); + return v >= 0.0f ? h + v * (1.0f - h) : h * (1.0f + v); +} +inline Command map_axes(const std::array& axes, const std::array& map, float dz = 0.03f, + bool centred_throttle = false, float hover = 0.35f) { + Command out; + out.valid = true; + out.roll = deadzone(pick_axis(axes, map[0]), dz); + out.pitch = deadzone(pick_axis(axes, map[1]), dz); + out.throttle = centred_throttle ? spring_throttle(deadzone(pick_axis(axes, map[2]), dz), hover) + : std::clamp((pick_axis(axes, map[2]) + 1.0f) * 0.5f, 0.0f, 1.0f); + out.yaw = deadzone(pick_axis(axes, map[3]), dz); + return out; +} + +struct Sample { + bool valid = false; + std::array axes{}; + std::uint32_t buttons = 0; // bit n-1 set = button n pressed + std::string name; + int device = -1; +}; + +// Guided controller setup: works out which axis each stick is on, and which way round, by asking +// the pilot to move one stick at a time. Then, optionally, which switch arms and which resets. +// +// It starts from the sticks at rest (centred, throttle down). For each stick it picks the unused +// 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. +class Calibration { +public: + enum class Step { idle, rest, throttle, yaw, pitch, roll, arm, reset, done }; + static constexpr float move_threshold = 0.6f; // how far from rest counts as "pushed" + static constexpr float switch_threshold = 0.8f; // a switch swings further than a stick drifts + static constexpr float return_threshold = 0.25f; + static constexpr float hold_seconds = 0.3f; + + Step step() const { return step_; } + bool active() const { return step_ != Step::idle && step_ != Step::done; } + // True after a stick or switch was found, until it goes back to rest. + bool waiting_for_return() const { return returning_; } + // The axis (0-5) the current step would pick if held, or -1. + int candidate() const { return candidate_; } + + // Results, in the same form `droneaxes` and `dronetune` take. + std::array mapping() const { return map_; } // roll, pitch, throttle, yaw: signed 1..6 + int arm_axis() const { return arm_axis_; } + int arm_button() const { return arm_button_; } + int reset_axis() const { return reset_axis_; } + int reset_button() const { return reset_button_; } + // The throttle stick rested in the middle, not at the bottom: it springs back (a gamepad). + bool throttle_centred() const { return throttle_centred_; } + + void start() { *this = Calibration{}; step_ = Step::rest; } + void cancel() { *this = Calibration{}; } + // "Ready" on the rest step: the sticks as they are now are where they rest. + void capture_rest(const std::array& axes, std::uint32_t buttons) { + if (step_ != Step::rest) return; + rest_ = axes; + rest_buttons_ = buttons; + step_ = Step::throttle; + } + // "Skip" on a switch step: no switch for it. + void skip() { + if (step_ == Step::arm) { step_ = Step::reset; clear_hold(); } + else if (step_ == Step::reset) { step_ = Step::done; clear_hold(); } + } + // Once per frame while the setup is shown. + void update(const std::array& axes, std::uint32_t buttons, float seconds) { + if (!active() || step_ == Step::rest) return; + if (!std::isfinite(seconds) || seconds < 0.0f) seconds = 0.0f; + if (returning_) { + if (back_at_rest(axes, buttons)) { returning_ = false; advance(); } + return; + } + if (step_ == Step::arm || step_ == Step::reset) update_switch(axes, buttons, seconds); + else update_stick(axes, seconds); + } + +private: + Step step_ = Step::idle; + std::array rest_{}; + std::uint32_t rest_buttons_ = 0; + std::array map_{1, 2, 3, 4}; + std::array used_{}; + std::uint32_t used_buttons_ = 0; + int arm_axis_ = 0, arm_button_ = 0, reset_axis_ = 0, reset_button_ = 0; + int candidate_ = -1, candidate_button_ = 0; + float held_ = 0.0f; + bool returning_ = false; + bool throttle_centred_ = false; + int found_axis_ = -1, found_button_ = 0; + + void clear_hold() { candidate_ = -1; candidate_button_ = 0; held_ = 0.0f; } + static int slot(Step s) { return s == Step::roll ? 0 : s == Step::pitch ? 1 : s == Step::throttle ? 2 : 3; } + int furthest(const std::array& axes, float threshold) const { + int best = -1; + float distance = threshold; + for (int i = 0; i < 6; ++i) { + if (used_[static_cast(i)]) continue; + const float d = std::abs(axes[static_cast(i)] - rest_[static_cast(i)]); + if (std::isfinite(d) && d > distance) { distance = d; best = i; } + } + return best; + } + bool hold(int axis, int button, float seconds) { + if (axis != candidate_ || button != candidate_button_) { + candidate_ = axis; + candidate_button_ = button; + held_ = 0.0f; + return false; + } + held_ += seconds; + return (axis >= 0 || button > 0) && held_ >= hold_seconds; + } + void update_stick(const std::array& axes, float seconds) { + const int axis = furthest(axes, move_threshold); + if (!hold(axis, 0, seconds)) return; + const auto i = static_cast(axis); + const int sign = axes[i] >= rest_[i] ? 1 : -1; + map_[static_cast(slot(step_))] = sign * (axis + 1); + if (step_ == Step::throttle) throttle_centred_ = std::abs(rest_[i]) < 0.5f; + used_[i] = true; + found_axis_ = axis; + found_button_ = 0; + returning_ = true; + clear_hold(); + } + void update_switch(const std::array& axes, std::uint32_t buttons, float seconds) { + const int axis = furthest(axes, switch_threshold); + int button = 0; + const std::uint32_t fresh = buttons & ~rest_buttons_ & ~used_buttons_; + for (int n = 1; n <= 32 && !button; ++n) + if (fresh & (1u << (n - 1))) button = n; + if (axis >= 0) button = 0; // an axis is the clearer signal when both move + if (!hold(axis, button, seconds)) return; + if (axis >= 0) { + const auto i = static_cast(axis); + const int value = (axes[i] >= rest_[i] ? 1 : -1) * (axis + 1); // its new side counts as "on" + (step_ == Step::arm ? arm_axis_ : reset_axis_) = value; + used_[i] = true; + } else { + (step_ == Step::arm ? arm_button_ : reset_button_) = button; + used_buttons_ |= 1u << (button - 1); + } + found_axis_ = axis; + found_button_ = button; + // The arm switch can stay on; the reset switch has to be let go before finishing. + returning_ = step_ == Step::reset; + if (!returning_) advance(); + clear_hold(); + } + bool back_at_rest(const std::array& axes, std::uint32_t buttons) const { + if (found_axis_ >= 0) { + const auto i = static_cast(found_axis_); + return std::abs(axes[i] - rest_[i]) < return_threshold; + } + return found_button_ <= 0 || (buttons & (1u << (found_button_ - 1))) == 0; + } + void advance() { + switch (step_) { + case Step::throttle: step_ = Step::yaw; break; + case Step::yaw: step_ = Step::pitch; break; + case Step::pitch: step_ = Step::roll; break; + case Step::roll: step_ = Step::arm; break; + case Step::arm: step_ = Step::reset; break; + case Step::reset: step_ = Step::done; break; + default: break; + } + } +}; +// The one setup the drone window 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 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() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +inline void mark_calibrating() { calibration_seen().store(calibration_clock(), std::memory_order_relaxed); } +inline bool calibrating() { + const auto seen = calibration_seen().load(std::memory_order_relaxed); + return seen != 0 && calibration_clock() - seen < 500; +} + +#ifdef _WIN32 +} +namespace dingosdk::overlay { // UI/Overlay/overlay.h: reads that see the controller while the game is kept off it +void begin_input_access() noexcept; +void end_input_access() noexcept; +} +namespace dingosdk::drone::radio { +namespace detail { +struct InputAccess { + InputAccess() noexcept { overlay::begin_input_access(); } + ~InputAccess() { overlay::end_input_access(); } + InputAccess(const InputAccess&) = delete; + InputAccess& operator=(const InputAccess&) = delete; +}; +inline std::string narrow(const wchar_t* text) { + std::string out; + for (; *text; ++text) out += *text < 128 ? static_cast(*text) : '?'; + return out; +} +inline std::string lowered(std::string s) { + for (auto& ch : s) if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + 'a' - 'A'); + return s; +} +inline bool read_device(UINT id, Sample& out) { + JOYCAPSW caps{}; + if (joyGetDevCapsW(id, &caps, sizeof(caps)) != JOYERR_NOERROR) return false; + JOYINFOEX info{}; + info.dwSize = sizeof(info); + info.dwFlags = JOY_RETURNX | JOY_RETURNY | JOY_RETURNZ | JOY_RETURNR | JOY_RETURNU | JOY_RETURNV | JOY_RETURNBUTTONS; + if (joyGetPosEx(id, &info) != JOYERR_NOERROR) return false; + const UINT n = caps.wNumAxes; + out.axes = {}; + if (n > 0) out.axes[0] = normalize_axis(info.dwXpos, caps.wXmin, caps.wXmax); + if (n > 1) out.axes[1] = normalize_axis(info.dwYpos, caps.wYmin, caps.wYmax); + if (n > 2) out.axes[2] = normalize_axis(info.dwZpos, caps.wZmin, caps.wZmax); + if (n > 3) out.axes[3] = normalize_axis(info.dwRpos, caps.wRmin, caps.wRmax); + if (n > 4) out.axes[4] = normalize_axis(info.dwUpos, caps.wUmin, caps.wUmax); + if (n > 5) out.axes[5] = normalize_axis(info.dwVpos, caps.wVmin, caps.wVmax); + out.buttons = info.dwButtons; + out.name = narrow(caps.szPname); + out.device = static_cast(id); + out.valid = true; + return true; +} +} + +// Reads the radio. Safe to call from any thread; rescans for a controller at most once a second. +inline Sample poll() { + // While the drone flies with a controller, ReSkate keeps the game from reading it; this read + // has to see it all the same. + detail::InputAccess access; + static std::atomic cached{-1}; + static std::atomic next_scan{0}; + Sample sample; + const int forced = config().device.load(); + if (forced >= 0) { detail::read_device(static_cast(forced), sample); return sample; } + int id = cached.load(); + if (id >= 0 && detail::read_device(static_cast(id), sample)) return sample; + cached.store(-1); + const auto now = GetTickCount64(); + if (now < next_scan.load()) return sample; + next_scan.store(now + 1000); + const UINT count = std::min(joyGetNumDevs(), 16); + int first = -1; + for (UINT i = 0; i < count; ++i) { + Sample candidate; + if (!detail::read_device(i, candidate)) continue; + if (first < 0) first = static_cast(i); + if (detail::lowered(candidate.name).find("radio") != std::string::npos) { cached.store(static_cast(i)); return candidate; } + } + if (first >= 0) { detail::read_device(static_cast(first), sample); cached.store(first); } + return sample; +} + +// True while this game's window has focus, so the radio does not fly the drone from the background. +inline bool game_in_foreground() { + DWORD pid{}; + GetWindowThreadProcessId(GetForegroundWindow(), &pid); + return pid == GetCurrentProcessId(); +} +#endif +} diff --git a/Extension/UI/Overlay/drone_hud_overlay.cpp b/Extension/UI/Overlay/drone_hud_overlay.cpp new file mode 100644 index 00000000..1d00e5fe --- /dev/null +++ b/Extension/UI/Overlay/drone_hud_overlay.cpp @@ -0,0 +1,350 @@ +#include "overlay_internal.h" +#include +#include "Extension/Skater/drone_font.h" +#include "Extension/Skater/drone_osd.h" +#include "Extension/Skater/drone_beacon.h" +#include "Engine/Game/UI/game_view.h" + +// The FPV drone's goggle-style display and stick overlay. What to show comes from drone_osd.h; +// this draws it over the game (pixel font, drawn rectangle by rectangle, so no font file is needed). + +namespace dingosdk::overlay::detail { +namespace { +dingosdk::drone::osd::Layout& hud() { + static dingosdk::drone::osd::Layout value; + return value; +} + +ImU32 colour_of(const dingosdk::drone::osd::Layout& layout, bool warn, int alpha = 255) { + if (warn) return IM_COL32(255, 60, 60, alpha); + const auto rgb = dingosdk::drone::osd::preset_colour(layout.colour); + return IM_COL32(rgb[0], rgb[1], rgb[2], alpha); +} + +// Width of a pixel-font string in screen pixels, `px` being the size of one font pixel. +float pixel_width(const std::string& text, float px) { + if (text.empty()) return 0.0f; + return (static_cast(text.size()) * (dingosdk::drone::font::columns + 1) - 1) * px; +} + +void pixel_text(ImDrawList* draw, ImVec2 at, const std::string& text, float px, float tall, ImU32 colour, ImU32 shadow) { + const float outline = std::max(1.0f, px * 0.5f); + for (int pass = 0; pass < 2; ++pass) { + const ImU32 c = pass == 0 ? shadow : colour; + const float grow = pass == 0 ? outline : 0.0f; + float x = at.x; + for (const char ch : text) { + const auto rows = dingosdk::drone::font::glyph(ch); + for (int r = 0; r < dingosdk::drone::font::rows; ++r) + for (int col = 0; col < dingosdk::drone::font::columns; ++col) + if (rows[static_cast(r)] & (1 << (dingosdk::drone::font::columns - 1 - col))) { + const float x0 = x + static_cast(col) * px, y0 = at.y + static_cast(r) * px * tall; + draw->AddRectFilled(ImVec2(x0 - grow, y0 - grow), ImVec2(x0 + px + grow, y0 + px * tall + grow), c); + } + x += (dingosdk::drone::font::columns + 1) * px; + } + } +} + +void draw_stick(ImDrawList* draw, ImVec2 centre, float half, float x, float y, ImU32 colour, ImU32 shadow) { + const float t = std::max(1.5f, half * 0.04f); + const ImVec2 lo(centre.x - half, centre.y - half), hi(centre.x + half, centre.y + half); + draw->AddRectFilled(lo, hi, IM_COL32(0, 0, 0, 70)); + draw->AddRect(lo, hi, shadow, 0.0f, 0, t * 2.4f); + draw->AddRect(lo, hi, colour, 0.0f, 0, t); + draw->AddLine(ImVec2(centre.x - half, centre.y), ImVec2(centre.x + half, centre.y), IM_COL32(255, 255, 255, 60), t * 0.6f); + draw->AddLine(ImVec2(centre.x, centre.y - half), ImVec2(centre.x, centre.y + half), IM_COL32(255, 255, 255, 60), t * 0.6f); + const float limit = half * 0.86f; + const ImVec2 dot(centre.x + std::clamp(x, -1.0f, 1.0f) * limit, centre.y - std::clamp(y, -1.0f, 1.0f) * limit); + const float radius = half * 0.12f; + draw->AddCircleFilled(dot, radius + t, shadow); + draw->AddCircleFilled(dot, radius, colour); +} + +std::vector& beacons() { + static std::vector value; + return value; +} + +std::string metres_text(float metres) { + char text[32]{}; + if (metres < 1000.0f) std::snprintf(text, sizeof text, "%.0f m", std::floor(metres)); + else std::snprintf(text, sizeof text, "%.1f km", metres / 1000.0f); + return text; +} + +// An analogue FPV video look drawn over the game: scanlines, static, dark corners and rolling glitches. +// It is an overlay, so it adds texture and darkening; it cannot blur or smear the game picture itself. +float analog_knob(dingosdk::drone::Knob k) { return dingosdk::drone::knobs().value[k].load(std::memory_order_relaxed); } +std::uint32_t next_random(std::uint32_t& state) { + state ^= state << 13; state ^= state >> 17; state ^= state << 5; + return state; +} +float unit_random(std::uint32_t& state) { return static_cast(next_random(state) & 0xffffff) / 16777216.0f; } + +void draw_analog(ImDrawList* draw, const ImVec2& display) { + using namespace dingosdk::drone; + // Range breaks the picture up near its limit (drone_flight.h: signal_strength), with or without + // the analogue look; past the limit only static is left. + const float signal = std::clamp(telemetry().signal.load(std::memory_order_relaxed), 0.0f, 1.0f); + const float breakup = 1.0f - signal; + const bool look = analog_knob(kAnalog) >= 0.5f; + if (!look && breakup <= 0.0f) return; + const float noise = std::max(look ? std::clamp(analog_knob(kAnalogNoise), 0.0f, 1.0f) : 0.0f, breakup); + const float scan = look ? std::clamp(analog_knob(kAnalogScan), 0.0f, 1.0f) : 0.0f; + const float vignette = std::max(look ? std::clamp(analog_knob(kAnalogVignette), 0.0f, 1.0f) : 0.0f, breakup * 0.6f); + const float glitch = std::max(look ? std::clamp(analog_knob(kAnalogGlitch), 0.0f, 1.0f) : 0.0f, breakup); + + const float time = static_cast(ImGui::GetTime()); + const float unit = display.y / 1080.0f; + static std::uint32_t frame = 1; + std::uint32_t rng = 0x9e3779b9u ^ (++frame * 2654435761u); + const auto alpha = [](float a) { return static_cast(std::clamp(a, 0.0f, 1.0f) * 255.0f); }; + + // A cold, slightly washed-out cast, and a faint brightness flicker. + const float flicker = 0.02f + 0.02f * std::sin(time * 37.0f) + 0.02f * unit_random(rng); + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(40, 70, 70, alpha(0.05f + 0.07f * scan))); + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(0, 0, 0, alpha(flicker * (0.4f + noise)))); + + // Scanlines: a dark line every other row of a 540-line picture. + if (scan > 0.0f) { + const float gap = std::max(2.0f, 2.0f * unit); + const float thickness = std::max(1.0f, gap * 0.5f); + const ImU32 line = IM_COL32(0, 0, 0, alpha(0.10f + 0.35f * scan)); + for (float y = 0.0f; y < display.y; y += gap) draw->AddRectFilled(ImVec2(0, y), ImVec2(display.x, y + thickness), line); + } + + // Dark corners, as soft bands in from each edge. + if (vignette > 0.0f) { + const int edge = alpha(0.65f * vignette), clear = 0; + const float bx = display.x * 0.20f, by = display.y * 0.24f; + const ImU32 dark = IM_COL32(0, 0, 0, edge), none = IM_COL32(0, 0, 0, clear); + draw->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(bx, display.y), dark, none, none, dark); + draw->AddRectFilledMultiColor(ImVec2(display.x - bx, 0), ImVec2(display.x, display.y), none, dark, dark, none); + draw->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(display.x, by), dark, dark, none, none); + draw->AddRectFilledMultiColor(ImVec2(0, display.y - by), ImVec2(display.x, display.y), none, none, dark, dark); + } + + // Static: random grey specks and short horizontal streaks. + if (noise > 0.0f) { + const int specks = static_cast(2200.0f * noise); + const float speck = std::max(1.0f, 1.5f * unit); + for (int i = 0; i < specks; ++i) { + const float x = unit_random(rng) * display.x, y = unit_random(rng) * display.y; + const int v = 150 + static_cast(unit_random(rng) * 105.0f); + draw->AddRectFilled(ImVec2(x, y), ImVec2(x + speck, y + speck), IM_COL32(v, v, v, alpha(0.12f + 0.35f * noise * unit_random(rng)))); + } + const int streaks = static_cast(14.0f * noise); + for (int i = 0; i < streaks; ++i) { + const float x = unit_random(rng) * display.x, y = unit_random(rng) * display.y; + const float length = (20.0f + unit_random(rng) * 160.0f) * unit; + draw->AddRectFilled(ImVec2(x, y), ImVec2(x + length, y + std::max(1.0f, unit)), IM_COL32(220, 235, 235, alpha(0.10f + 0.2f * noise))); + } + } + + // Glitches: a pale band that rolls down the picture, and now and then a burst of torn lines. + if (glitch > 0.0f) { + const float cycle = 5.0f; + const float t = std::fmod(time, cycle) / cycle; + const float band_height = display.y * 0.09f; + const float y = -band_height + t * (display.y + band_height * 2.0f); + draw->AddRectFilled(ImVec2(0, y), ImVec2(display.x, y + band_height), IM_COL32(200, 230, 230, alpha(0.05f + 0.07f * glitch))); + draw->AddRectFilled(ImVec2(0, y + band_height * 0.45f), ImVec2(display.x, y + band_height * 0.55f), IM_COL32(255, 255, 255, alpha(0.04f + 0.06f * glitch))); + const float burst = std::fmod(time, 7.0f); + if (burst < 0.25f * (0.4f + glitch)) { + const int tears = 3 + static_cast(8.0f * glitch); + for (int i = 0; i < tears; ++i) { + const float ty = unit_random(rng) * display.y; + const float h = (2.0f + unit_random(rng) * 10.0f) * unit; + const float shift = (unit_random(rng) - 0.5f) * display.x * 0.08f * glitch; + draw->AddRectFilled(ImVec2(std::max(0.0f, shift), ty), ImVec2(display.x + std::min(0.0f, shift), ty + h), + IM_COL32(230, 240, 240, alpha(0.10f + 0.18f * glitch))); + draw->AddRectFilled(ImVec2(0, ty + h), ImVec2(display.x, ty + h + std::max(1.0f, unit)), IM_COL32(0, 0, 0, alpha(0.35f * glitch))); + } + } + } + // Range: as the link weakens, the picture sinks under grey snow, smoothly, until only snow is + // left where it is lost. Drawn on top of everything above, so it reads the same with or + // without the analogue look. + if (breakup > 0.0f) { + const float b = std::clamp(breakup, 0.0f, 1.0f); + const float fade = b * b * (3.0f - 2.0f * b); // smooth in and out + draw->AddRectFilled(ImVec2(0, 0), display, IM_COL32(24, 26, 26, alpha(0.92f * fade * fade))); + const int flakes = static_cast(9000.0f * std::pow(b, 1.4f)); + const float flake = std::max(1.0f, 2.0f * unit); + for (int i = 0; i < flakes; ++i) { + const float x = unit_random(rng) * display.x, y = unit_random(rng) * display.y; + const int v = 110 + static_cast(unit_random(rng) * 145.0f); + draw->AddRectFilled(ImVec2(x, y), ImVec2(x + flake, y + flake), IM_COL32(v, v, v, alpha(0.25f + 0.6f * b * unit_random(rng)))); + } + // Torn lines of snow across the picture, more of them the weaker it gets. + const int lines = static_cast(40.0f * b * b); + for (int i = 0; i < lines; ++i) { + const float y = unit_random(rng) * display.y, h = (1.0f + unit_random(rng) * 3.0f) * unit; + const int v = 150 + static_cast(unit_random(rng) * 100.0f); + draw->AddRectFilled(ImVec2(0, y), ImVec2(display.x, y + h), IM_COL32(v, v, v, alpha(0.08f + 0.3f * b * unit_random(rng)))); + } + } +} + +// A little quadcopter: an X with a propeller circle at each end, drawn around `at`. +void drone_icon(ImDrawList* draw, ImVec2 at, float size, ImU32 colour, ImU32 shadow) { + const float arm = size * 0.5f, prop = size * 0.2f, t = std::max(1.5f, size * 0.09f); + const ImVec2 ends[4] = {{-arm, -arm}, {arm, -arm}, {arm, arm}, {-arm, arm}}; + for (int pass = 0; pass < 2; ++pass) { + const ImU32 c = pass == 0 ? shadow : colour; + const float w = pass == 0 ? t * 2.4f : t; + draw->AddLine(ImVec2(at.x + ends[0].x, at.y + ends[0].y), ImVec2(at.x + ends[2].x, at.y + ends[2].y), c, w); + draw->AddLine(ImVec2(at.x + ends[1].x, at.y + ends[1].y), ImVec2(at.x + ends[3].x, at.y + ends[3].y), c, w); + for (const auto& e : ends) draw->AddCircle(ImVec2(at.x + e.x, at.y + e.y), prop, c, 14, w); + } + draw->AddCircleFilled(at, size * 0.1f, colour); +} + +// Markers for other players' drones, placed over the world with the camera the client last used. +void draw_beacons(const std::vector& list) { + if (list.empty()) return; + const auto view = dingosdk::latest_game_view(); + if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return; + const auto display = ImGui::GetIO().DisplaySize; + if (display.x <= 0 || display.y <= 0) return; + const auto& m = view->world; + const float k = display.y / 1080.0f; + const float focal = display.y / (2.0f * std::tan(view->vertical_fov * 3.14159265f / 360.0f)); + const ImVec2 centre(display.x * 0.5f, display.y * 0.5f); + const float margin = 40.0f * k; + auto& s = state(); + auto* font = s.menu.bold ? s.menu.bold : ImGui::GetFont(); + auto* draw = ImGui::GetBackgroundDrawList(); + const float pulse = 0.78f + 0.22f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); + const ImU32 colour = IM_COL32(90, 232, 255, static_cast(255 * pulse)); + const ImU32 shadow = IM_COL32(0, 0, 0, 200); + for (const auto& other : list) { + const float dx = other.position[0] - m[12], dy = other.position[1] - m[13], dz = other.position[2] - m[14]; + const float depth = -(dx * m[8] + dy * m[9] + dz * m[10]); + const float side = dx * m[0] + dy * m[1] + dz * m[2]; + const float height = dx * m[4] + dy * m[5] + dz * m[6]; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + const std::string label = (other.name.empty() ? std::string("Player") : other.name) + "'s drone"; + const std::string metres = metres_text(distance); + const float size = std::clamp(14.0f * k, 10.0f, 30.0f); + ImVec2 at; + bool on_screen = false; + if (depth > 0.1f) { + at = ImVec2(centre.x + side * focal / depth, centre.y - height * focal / depth); + on_screen = at.x >= margin && at.x <= display.x - margin && at.y >= margin && at.y <= display.y - margin; + } + if (on_screen) { + drone_icon(draw, at, std::clamp(34.0f * k, 22.0f, 60.0f), colour, shadow); + const auto text_size = font->CalcTextSizeA(size, FLT_MAX, 0.0f, label.c_str()); + const ImVec2 text_at(at.x - text_size.x * 0.5f, at.y - 34.0f * k - text_size.y); + const float offset = std::max(1.0f, size / 14.0f); + draw->AddText(font, size, ImVec2(text_at.x + offset, text_at.y + offset), shadow, label.c_str()); + draw->AddText(font, size, text_at, colour, label.c_str()); + const float fine_size = size * 0.78f; + const auto metres_size = font->CalcTextSizeA(fine_size, FLT_MAX, 0.0f, metres.c_str()); + const ImVec2 metres_at(at.x - metres_size.x * 0.5f, at.y + 30.0f * k); + draw->AddText(font, fine_size, ImVec2(metres_at.x + offset, metres_at.y + offset), shadow, metres.c_str()); + draw->AddText(font, fine_size, metres_at, IM_COL32(235, 235, 235, 230), metres.c_str()); + } else { + // Off screen: an arrow at the screen's edge pointing to it, with the distance. + float ex = depth > 0.1f ? at.x - centre.x : side, ey = depth > 0.1f ? at.y - centre.y : -height; + if (depth <= 0.1f && std::abs(ex) < 1e-3f && std::abs(ey) < 1e-3f) ey = 1.0f; // straight behind + const float hx = centre.x - margin, hy = centre.y - margin; + const float scale = std::min(std::abs(ex) > 1e-6f ? hx / std::abs(ex) : FLT_MAX, + std::abs(ey) > 1e-6f ? hy / std::abs(ey) : FLT_MAX); + const ImVec2 edge(centre.x + ex * scale, centre.y + ey * scale); + const float length = std::sqrt(ex * ex + ey * ey); + const ImVec2 dir(length > 1e-6f ? ex / length : 0.0f, length > 1e-6f ? ey / length : 1.0f); + const ImVec2 perp(-dir.y, dir.x); + const float a = 13.0f * k; + const ImVec2 tip(edge.x + dir.x * a, edge.y + dir.y * a); + const ImVec2 b1(edge.x - dir.x * a * 0.5f + perp.x * a * 0.8f, edge.y - dir.y * a * 0.5f + perp.y * a * 0.8f); + const ImVec2 b2(edge.x - dir.x * a * 0.5f - perp.x * a * 0.8f, edge.y - dir.y * a * 0.5f - perp.y * a * 0.8f); + draw->AddTriangleFilled(ImVec2(tip.x + 2.0f * k, tip.y + 2.0f * k), ImVec2(b1.x + 2.0f * k, b1.y + 2.0f * k), + ImVec2(b2.x + 2.0f * k, b2.y + 2.0f * k), shadow); + draw->AddTriangleFilled(tip, b1, b2, colour); + const float fine_size = size * 0.78f; + const auto metres_size = font->CalcTextSizeA(fine_size, FLT_MAX, 0.0f, metres.c_str()); + ImVec2 text_at(edge.x - dir.x * a * 2.2f - metres_size.x * 0.5f, edge.y - dir.y * a * 2.2f - metres_size.y * 0.5f); + text_at.x = std::clamp(text_at.x, 4.0f * k, display.x - metres_size.x - 4.0f * k); + text_at.y = std::clamp(text_at.y, 4.0f * k, display.y - metres_size.y - 4.0f * k); + const float offset = std::max(1.0f, fine_size / 14.0f); + draw->AddText(font, fine_size, ImVec2(text_at.x + offset, text_at.y + offset), shadow, metres.c_str()); + draw->AddText(font, fine_size, text_at, colour, metres.c_str()); + } + } +} +} // namespace + +bool drone_hud_pending() { + hud() = dingosdk::drone::osd::build(); + beacons().clear(); + if (dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f) + beacons() = dingosdk::drone::beacon::snapshot(); + return hud().active || !beacons().empty(); +} + +void draw_drone_hud() { + const auto& layout = hud(); + { // The analogue look goes under everything else, so markers and the OSD stay crisp. + const auto size = ImGui::GetIO().DisplaySize; + if (layout.active && size.x > 0 && size.y > 0) draw_analog(ImGui::GetBackgroundDrawList(), size); + } + draw_beacons(beacons()); + if (!layout.active) return; + const auto display = ImGui::GetIO().DisplaySize; + if (display.x <= 0 || display.y <= 0) return; + auto* draw = ImGui::GetBackgroundDrawList(); + const float unit = display.y / 1080.0f; + const float px = std::max(1.0f, layout.scale * 2.0f * unit); // one font pixel + constexpr float tall = 1.45f; // goggle glyphs are taller than wide + const float text_height = dingosdk::drone::font::rows * px * tall; + const ImU32 shadow = IM_COL32(0, 0, 0, 220); + const ImU32 colour_normal = colour_of(layout, false); + auto* smooth_font = ImGui::GetFont(); + + for (const auto& item : layout.items) { + const ImU32 colour = colour_of(layout, item.warn); + const float height = text_height; + float width; + if (layout.font == 0) width = pixel_width(item.text, px); + else width = smooth_font->CalcTextSizeA(height, FLT_MAX, 0.0f, item.text.c_str()).x; + ImVec2 at(item.x * display.x, item.y * display.y); + if (item.align == dingosdk::drone::osd::Align::centre) at.x -= width * 0.5f; + else if (item.align == dingosdk::drone::osd::Align::right) at.x -= width; + if (item.bottom) at.y -= height; + if (layout.font == 0) { + pixel_text(draw, at, item.text, px, tall, colour, shadow); + } else { + const float offset = std::max(1.0f, height / 14.0f); + draw->AddText(smooth_font, height, ImVec2(at.x + offset, at.y + offset), shadow, item.text.c_str()); + draw->AddText(smooth_font, height, at, colour, item.text.c_str()); + } + } + + if (layout.crosshair) { + const float arm = 14.0f * unit * layout.scale, gap = 4.0f * unit * layout.scale, t = std::max(2.0f, 2.0f * unit * layout.scale); + const ImVec2 c(display.x * 0.5f, display.y * 0.5f); + for (int pass = 0; pass < 2; ++pass) { + const ImU32 col = pass == 0 ? shadow : colour_normal; + const float w = pass == 0 ? t * 2.2f : t; + draw->AddLine(ImVec2(c.x - gap - arm, c.y), ImVec2(c.x - gap, c.y), col, w); + draw->AddLine(ImVec2(c.x + gap, c.y), ImVec2(c.x + gap + arm, c.y), col, w); + draw->AddLine(ImVec2(c.x, c.y - gap - arm), ImVec2(c.x, c.y - gap), col, w); + draw->AddLine(ImVec2(c.x, c.y + gap), ImVec2(c.x, c.y + gap + arm), col, w); + } + } + + if (layout.sticks.show) { + // Mode 2: left stick = yaw (side to side) and throttle (up and down); right stick = roll and pitch. + const float half = 0.5f * 0.17f * display.y * std::clamp(layout.sticks.size, 0.4f, 3.0f); + const float margin = layout.margin * display.x; + const float bottom = display.y * (1.0f - layout.margin) - text_height * 1.6f; + const float cy = bottom - half; + const ImVec2 left(margin + half, cy), right(display.x - margin - half, cy); + draw_stick(draw, left, half, layout.sticks.yaw, layout.sticks.throttle, colour_normal, shadow); + draw_stick(draw, right, half, layout.sticks.roll, layout.sticks.pitch, colour_normal, shadow); + } +} +} // namespace dingosdk::overlay::detail diff --git a/Extension/UI/Overlay/drone_menu_overlay.cpp b/Extension/UI/Overlay/drone_menu_overlay.cpp new file mode 100644 index 00000000..e26101f4 --- /dev/null +++ b/Extension/UI/Overlay/drone_menu_overlay.cpp @@ -0,0 +1,466 @@ +#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/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index 7a38cac0..09c6527c 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -3,6 +3,7 @@ #include "chat_rich.h" #include "nametag_gradient.h" #include "role_badge.h" +#include "Extension/Skater/drone_flight.h" #include #include #include @@ -314,9 +315,19 @@ 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). + if (tag.flying_drone && + dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f) { + const char *label = "FLYING DRONE"; + const auto label_size = font->CalcTextSizeA(detail, FLT_MAX, 0.0f, label); + const ImVec2 label_at(at.x - label_size.x * 0.5f, name_at.y - label_size.y); + draw->AddText(font, detail, ImVec2(label_at.x + offset, label_at.y + offset), shadow, label); + draw->AddText(font, detail, label_at, with_alpha(IM_COL32(90, 232, 255, 255), alpha), label); + bubble_bottom = label_at.y; + } if (tag.talking) speaker(draw, ImVec2(left - size * 1.05f, name_at.y + name_size.y * 0.5f), size * 0.8f, with_alpha(animated_colour, alpha)); - bubble_bottom = name_at.y; + bubble_bottom = std::min(bubble_bottom, name_at.y); // above the name, or the drone label over it } } if (value.show_bubbles && !tag.bubbles.empty() && tag.distance <= value.bubble_distance) { diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index af9e2fe1..dd616ef5 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -422,6 +422,7 @@ struct Nametag { std::string tag; // role badge before the name ("Dev", "ReSkate Admin", "Content Creator", "Centrix", "Homie", "Admin", "Host", "Friend") float distance{}; // metres from the local skater bool talking{}; + bool flying_drone{}; // flying the FPV drone: a FLYING DRONE label above the name // Recent chat lines to show as bubbles above the head, oldest first ("" = none). std::vector bubbles; bool self{}; // the local player: bubbles only, never a name or dot @@ -516,6 +517,23 @@ extern "C" __declspec(dllexport) bool DingoSDKOverlayStartV2( extern "C" __declspec(dllexport) bool DingoSDKOverlayStartV3( const dingosdk::overlay::CallbacksV3* callbacks); +// The first Xbox (XInput) or PlayStation (DualShock 4 / DualSense) controller, read directly. +// Sticks are -1..1 (up and right positive), triggers 0..1; `buttons` is XInput's wButtons bit mask. +// Used by the FPV drone's controller selection (Skater/drone_radio.h); safe from any thread. +struct Gamepad { + bool valid = false; + bool playstation = false; + float left_x = 0, left_y = 0, right_x = 0, right_y = 0; + float left_trigger = 0, right_trigger = 0; + unsigned buttons = 0; +}; +Gamepad read_gamepad(); +// Reads of a controller between these two calls see the real device even while ReSkate keeps +// the game from reading it (freecam or drone controller capture). Same thread, paired. +// The FPV drone reads its controller this way (Skater/drone_radio.h). +void begin_input_access() noexcept; +void end_input_access() noexcept; + // Nonblocking. A later Present releases GPU resources after its fence completes. // The DLL stays pinned until process exit so no thread can return into unloaded // detours. Input is released immediately, even if rendering has stopped. diff --git a/Extension/UI/Overlay/overlay_input.cpp b/Extension/UI/Overlay/overlay_input.cpp index ff84664c..b82a4dac 100644 --- a/Extension/UI/Overlay/overlay_input.cpp +++ b/Extension/UI/Overlay/overlay_input.cpp @@ -449,6 +449,25 @@ dingosdk::overlay::FlightInput read_player_flight_controller() { } } +namespace dingosdk::overlay { +void begin_input_access() noexcept { ++detail::overlay_input_access; } +void end_input_access() noexcept { --detail::overlay_input_access; } +Gamepad read_gamepad() { + const auto sample = read_controller_sample(); + Gamepad out; + if (!sample.device) return out; + const auto axis = [](std::int16_t v) { return std::clamp(static_cast(v) / 32767.0f, -1.0f, 1.0f); }; + out.valid = true; + out.playstation = sample.device >= 0x100u; + out.left_x = axis(sample.pad.sThumbLX); out.left_y = axis(sample.pad.sThumbLY); + out.right_x = axis(sample.pad.sThumbRX); out.right_y = axis(sample.pad.sThumbRY); + out.left_trigger = static_cast(sample.pad.bLeftTrigger) / 255.0f; + out.right_trigger = static_cast(sample.pad.bRightTrigger) / 255.0f; + out.buttons = sample.pad.wButtons; + return out; +} +} // namespace dingosdk::overlay + extern "C" void DingoSDKOverlaySetFreecamInputCapture(bool active) { const bool previous = state().freecam_controller_active.exchange(active, std::memory_order_relaxed); if (previous != active) diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 8eee201d..a4000eb2 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -307,6 +307,9 @@ void card_image_lost(); // Game debug text (game_text_overlay.cpp): polled every presented frame. bool game_text_pending(); void draw_game_text(); +// The FPV drone's goggle display and stick overlay (drone_hud_overlay.cpp): polled every presented frame. +bool drone_hud_pending(); +void draw_drone_hud(); // ReSkate's S.K.A.T.E. throwdown HUD (skate_hud_overlay.cpp): polled every presented frame. bool skate_hud_pending(); void draw_skate_hud(); @@ -318,6 +321,8 @@ 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 7169615c..4c51f479 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -632,6 +632,7 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool chat_frame = chat_pending(); const bool map_download_frame = map_download_pending(); const bool game_text_frame = game_text_pending(); + const bool drone_hud_frame = drone_hud_pending(); const bool skate_hud_frame = skate_hud_pending(); const bool nametag_frame = nametags_pending(); const bool meat_frame = hall_of_meat_pending(); @@ -654,7 +655,7 @@ void render(IDXGISwapChain* presented, UINT flags) { sync_menu_cursor(); } // Hidden, the overlay still draws while a notice or chat line is on screen. - if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && + if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !drone_hud_frame && !skate_hud_frame && !nametag_frame && !meat_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; @@ -703,11 +704,13 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_menu(); draw_console(); draw_perf_window(); + draw_drone_window(); } draw_hub_page(); draw_hall_of_meat(); draw_nametags(); draw_game_text(); + draw_drone_hud(); draw_skate_hud(); draw_perf_hud(); draw_trainer_hud(); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 2896f6a7..1b11d82a 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -38,6 +38,8 @@ #include "Extension/Skater/ai_skaters.h" #include "Extension/Skater/client_source_spawn.h" #include "Extension/Skater/skater_slot_override.h" +#include "Extension/Skater/drone_flight.h" +#include "Extension/Skater/drone_outfit.h" #include "Extension/Throwdowns/native_throwdowns.h" #include "Extension/Trainer/trainer.h" #include "Extension/World/level_loading.h" @@ -457,7 +459,7 @@ void update_model(std::uintptr_t client, TickState& frame) { } // Capture belongs to the freecam state itself. Keep it active across brief // request/loading phases instead of opening a path back to player input. - DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && (freecam_controller || dingosdk::drone::controller_capture())); dingosdk::ControllerInput controller; if (freecam_controller_combo || freecam_combo || tp_to_freecam_combo || noclip_combo || forward_velocity_combo || up_velocity_combo || offboard_up_velocity_combo || vote_yes_combo || vote_no_combo || poll_answers || map_prompt || map_vote || message_up || @@ -543,7 +545,7 @@ void update_model(std::uintptr_t client, TickState& frame) { if (r.freecam_controller_bind_latch.update(freecam_controller_combo, controller, r.observer_failed || controller_busy || !r.debug_model.free_camera)) { if (dingosdk::set_local_freecam_controller(!freecam_controller)) freecam_controller = !freecam_controller; - DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && (freecam_controller || dingosdk::drone::controller_capture())); record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam_controller\"}"); } @@ -636,7 +638,7 @@ void update_model(std::uintptr_t client, TickState& frame) { phase_issue == nullptr, debug_request ? &*debug_request : nullptr, &flight_input); // Input ownership must follow the live camera even with every overlay closed. - DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && freecam_controller); + DingoSDKOverlaySetFreecamInputCapture(dingosdk::client_free_camera_active() && (freecam_controller || dingosdk::drone::controller_capture())); // A debug action, or a mode the tick ended by itself (host policy, lost // camera), can have replaced the native camera: validate it again. if (debug_request || debug.free_camera != r.debug_model.free_camera || @@ -824,6 +826,7 @@ void update_model(std::uintptr_t client, TickState& frame) { if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. dingosdk::hall_of_meat::on_client_tick(); dingosdk::road_rash::on_client_tick(client); + dingosdk::drone::outfit::on_client_tick(); dingosdk::board_break::on_client_tick(client); dingosdk::grind_exit_control::on_client_tick(client); if (!has_request && now < r.next_model && state == r.previous_state) return; diff --git a/Server/README-roles.txt b/Server/README-roles.txt index 50fdc892..301fe8ca 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -184,6 +184,7 @@ What players may do object-scaling object-scaling on|off: whether players may resize objects. clear-objects Delete every placed object. effects effects on|off: whether players see each other's effects. + drones drones on|off: whether players see each other's FPV drones. noclip noclip on|off. (The menu calls it noclip-allow.) nobail nobail on|off. (The menu calls it nobail-allow.) boosts boosts on|off. (The menu calls it boosts-allow.) diff --git a/Server/README.txt b/Server/README.txt index 67bfe50e..5ba06260 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -250,6 +250,11 @@ sync_effects Let players see each other's skater effects (default true): relays none and players' games show each other without them, which saves a little traffic and drawing on a busy server. Console: effects on|off. +sync_drones Let players see each other's FPV drones (default true): the + marker where a player flying ReSkate's drone mode has + their drone. false relays none, and players' games + neither send their drone nor show anyone else's. + Console: drones on|off. announce_throwdowns Tell everyone in chat when a throwdown drop is placed (default true). @@ -590,6 +595,7 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. objects |off How many objects each player may have placed. object-scaling on|off Whether players may resize the objects they place. effects on|off Whether players see each other's skater effects. + drones on|off Whether players see each other's FPV drones. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 48cea63b..7fcb42df 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -19,7 +19,7 @@ constexpr std::string_view help_text = "status | net [player] | players | say | msg | msg-party | msg-admins | kick [minutes|restart] | kick-time |restart | ban [name] | unban | bans\n" "map | maps [reload] | name | password | welcome | listed on|off\n" "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n" - "placement everyone|admins|nobody | objects |off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" + "placement everyone|admins|nobody | objects |off | object-scaling on|off | effects on|off | drones on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | teleport \n" "votes [ on|off||seconds|cooldown|min-players ] | vote-cancel\n" "votes polls off|admins|everyone | votes poll-seconds | votes starter-yes on|off\n" @@ -610,6 +610,13 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(*value ? "Players see each other's skater effects." : "Players no longer see each other's skater effects. Ones already showing stay until that player's skater is shown again."); } + if (name == "drones") { + const auto value = on_off(argument); + if (!value) return std::string("drones on|off (now ") + (config_.sync_drones ? "on" : "off") + ")"; + config_.sync_drones = *value; + return changed(*value ? "Players see each other's FPV drones." + : "Players no longer see each other's FPV drones."); + } if (name == "votes") { const auto [text, did] = votes_command(argument); return did ? changed(text) : text; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 3bc764a5..fe5c3106 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -130,6 +130,7 @@ Layout layout(const ServerConfig &c) { players.set("object_limit", c.object_limit); players.set("allow_object_scaling", c.object_scaling); players.set("sync_effects", c.sync_effects); + players.set("sync_drones", c.sync_drones); players.set("announce_throwdowns", c.announce_throwdowns); auto &anti_cheat = root.section("anti_cheat"); @@ -379,6 +380,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector> sound; // a skater's samples, shared by everyone sent them const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics || - packet.kind == PacketKind::effects; + packet.kind == PacketKind::effects || packet.kind == PacketKind::drone; for (auto &[id, guest] : guests_) { auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; @@ -749,6 +749,7 @@ void Host::send_roster() { p.object_limit = config_.object_limit; p.object_scaling = config_.object_scaling; p.sync_effects = config_.sync_effects; + p.sync_drones = config_.sync_drones; p.guest_noclip = config_.noclip; p.guest_no_bail = config_.no_bail; p.guest_boosts = config_.boosts; @@ -972,6 +973,8 @@ bool Host::accept_data(Guest &source, const Packet &p) { accepted = source.sound_budget.accept(now_, p.audio.size()) && source.audio.push(p, now_); else if (p.kind == PacketKind::effects) accepted = config_.sync_effects && source.effect_budget.accept(now_); + else if (p.kind == PacketKind::drone) + accepted = config_.sync_drones && source.drone_budget.accept(now_); else if (p.kind == PacketKind::pose) accepted = source.poses.push_validated(p, now_); // The server never plays anything back: keep only what ordering needs. diff --git a/Server/server_host.h b/Server/server_host.h index 312b1dfc..367429eb 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -183,6 +183,7 @@ class Host { OutfitBudget outfit_budget; SoundBudget sound_budget; EffectBudget effect_budget; + EffectBudget drone_budget; // FPV drone markers (drone_beacon.h): a few a second // Their poses read this pass, passed on once all of the pass's messages are read // (relay_poses), and how many of their effects packets were passed on in it. std::vector relay_poses; diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index eb23e16f..74357cae 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -12,6 +12,7 @@ add_library(dingosdk_runtime SHARED Extension/Console/commands.cpp Extension/Console/perf_commands.cpp Extension/Skater/console_commands.cpp + Extension/Skater/drone_outfit.cpp Extension/Multiplayer/console_commands.cpp Extension/Customization/console_commands.cpp Extension/Multiplayer/Session/session.cpp diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 94ad3814..82e4ae79 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -15,6 +15,8 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/message_card.cpp 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/perf_overlay.cpp Extension/UI/Overlay/skate_hud_overlay.cpp Extension/UI/Overlay/nametag_overlay.cpp From 76c0fd09035204cde224c8b4ac9dd2defca558b9 Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 14 Oct 2026 13:33:52 +0100 Subject: [PATCH 2/3] Drone: settings move into the ReSkate menu (DRONE page) The drone's own floating window becomes a DRONE page in the ReSkate menu (Insert), after TRAINER, built from the menu's cards, ON/OFF rows, fields and option strips like every other page. Its tabs are the window's: FLY, HANDLING, RATES, DISPLAY, CONTROLLER and LOBBY. `dronemenu` now opens the menu on that page. The FLY tab ends with a small credit line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MArBL6UbYZPsp9rMnq2Ks5 --- Extension/Skater/console_commands.cpp | 15 +- Extension/Skater/drone_flight.h | 10 +- Extension/Skater/drone_radio.h | 6 +- Extension/UI/Overlay/drone_menu_overlay.cpp | 466 -------------------- Extension/UI/Overlay/drone_page.cpp | 463 +++++++++++++++++++ Extension/UI/Overlay/nametag_overlay.cpp | 2 +- Extension/UI/Overlay/overlay_internal.h | 2 - Extension/UI/Overlay/overlay_render.cpp | 3 +- Extension/UI/Overlay/skate_menu.cpp | 9 +- Extension/UI/Overlay/skate_menu.h | 1 + Extension/UI/Overlay/skate_menu_internal.h | 2 + cmake/UI.cmake | 2 +- 12 files changed, 491 insertions(+), 490 deletions(-) delete mode 100644 Extension/UI/Overlay/drone_menu_overlay.cpp create mode 100644 Extension/UI/Overlay/drone_page.cpp 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 From 301b7b220f785296f42e113a6a47e2d9063c7fe5 Mon Sep 17 00:00:00 2001 From: Aldo Date: Wed, 14 Oct 2026 19:45:05 +0100 Subject: [PATCH 3/3] Drone: turning the goggle display off hides it The `osd` setting (Show the goggle display) was saved but never read, so the readouts stayed. Off now hides every readout: battery, armed, timer, throttle, altitude, speed and signal. The range warnings (NO SIGNAL, FAILSAFE) still show, and the sticks, crosshair and analogue look keep their own switches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MArBL6UbYZPsp9rMnq2Ks5 --- Extension/Skater/drone_flight.h | 2 +- Extension/Skater/drone_osd.h | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Extension/Skater/drone_flight.h b/Extension/Skater/drone_flight.h index 6251d04a..fcf013a3 100644 --- a/Extension/Skater/drone_flight.h +++ b/Extension/Skater/drone_flight.h @@ -131,7 +131,7 @@ inline constexpr KnobInfo knob_info[] = { {"floor", "With collisions off: an invisible floor this many metres below where you took off (0 = none)", 0.0f, 1000.0f, 1.5f}, {"collide", "Hit the ground, walls and ramps (1), or fly through everything (0)", 0.0f, 1.0f, 1.0f}, {"radius", "How big the drone is for collisions, in metres: how close it gets to things", 0.05f, 3.0f, 0.3f}, - {"osd", "Show the goggle display: battery, timer, throttle, altitude and speed", 0.0f, 1.0f, 1.0f}, + {"osd", "Show the goggle display: battery, timer, throttle, altitude, speed and signal. The range warnings show either way", 0.0f, 1.0f, 1.0f}, {"volume", "Motor sound volume (0 = silent)", 0.0f, 1.0f, 0.5f}, {"osdscale", "Size of the goggle display's text (1 = normal, 2 = double)", 0.4f, 5.0f, 1.5f}, {"osdcolor", "Goggle display colour: 0 white, 1 green, 2 yellow, 3 cyan, 4 orange, 5 pink", 0.0f, 5.0f, 0.0f}, diff --git a/Extension/Skater/drone_osd.h b/Extension/Skater/drone_osd.h index 90992ab2..1c2fa052 100644 --- a/Extension/Skater/drone_osd.h +++ b/Extension/Skater/drone_osd.h @@ -75,17 +75,20 @@ inline Layout build() { const bool low = volts < 14.0f; const bool blink = (static_cast(seconds * 2.0f) & 1) != 0; const int total = static_cast(seconds); - add(knob(kShowBattery) >= 0.5f, m, m, Align::left, false, std::format("{:.1f}V", volts), low && blink); + // The goggle display as a whole (`osd`): off hides every readout. The range warnings below + // still show, since flying on without them loses the drone. + const bool osd = knob(kOsd) >= 0.5f; + add(osd && knob(kShowBattery) >= 0.5f, m, m, Align::left, false, std::format("{:.1f}V", volts), low && blink); const bool armed_now = t.armed.load(std::memory_order_relaxed); - add(knob(kShowArmed) >= 0.5f, 0.5f, m, Align::centre, false, armed_now ? "ARMED" : "DISARMED", !armed_now); - add(knob(kShowTimer) >= 0.5f, 1.0f - m, m, Align::right, false, std::format("{:02}:{:02}", total / 60, total % 60)); - add(knob(kShowThrottle) >= 0.5f, m, 1.0f - m, Align::left, true, std::format("THR {:.0f}%", throttle * 100.0f)); - add(knob(kShowAlt) >= 0.5f, 0.5f, 1.0f - m, Align::centre, true, std::format("ALT {:.1f}M", altitude)); - add(knob(kShowSpeed) >= 0.5f, 1.0f - m, 1.0f - m, Align::right, true, std::format("{:.0f}KM/H", speed * 3.6f)); + add(osd && knob(kShowArmed) >= 0.5f, 0.5f, m, Align::centre, false, armed_now ? "ARMED" : "DISARMED", !armed_now); + add(osd && knob(kShowTimer) >= 0.5f, 1.0f - m, m, Align::right, false, std::format("{:02}:{:02}", total / 60, total % 60)); + add(osd && knob(kShowThrottle) >= 0.5f, m, 1.0f - m, Align::left, true, std::format("THR {:.0f}%", throttle * 100.0f)); + add(osd && knob(kShowAlt) >= 0.5f, 0.5f, 1.0f - m, Align::centre, true, std::format("ALT {:.1f}M", altitude)); + add(osd && knob(kShowSpeed) >= 0.5f, 1.0f - m, 1.0f - m, Align::right, true, std::format("{:.0f}KM/H", speed * 3.6f)); // Range: link quality and the way home, and what happens at the edge. if (knob(kRange) > 0.0f) { const float signal = t.signal.load(std::memory_order_relaxed); - add(true, 1.0f - m, m + 0.05f, Align::right, false, + add(osd, 1.0f - m, m + 0.05f, Align::right, false, std::format("RSSI {:.0f}% {:.0f}M", signal * 100.0f, t.home_distance.load(std::memory_order_relaxed)), signal < 0.35f && blink); if (t.failsafe.load(std::memory_order_relaxed)) add(true, 0.5f, 0.42f, Align::centre, false, "FAILSAFE - RESET TO FLY AGAIN", true); else if (signal <= 0.0f) {