diff --git a/CMakeLists.txt b/CMakeLists.txt index 007c40d..8e8dc72 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 0e1ac30..200b32a 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 a7f8449..2ade059 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 1861d45..705a87f 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 5739b9a..daf0fa2 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 9b811cf..d166bf4 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 5b72697..58a2e80 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 ecb82eb..5997a30 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 9fa1dc7..7674e57 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 94ee58b..99313a6 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 9927ac6..1908a59 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 99ce344..64087b2 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 cd24411..f315da0 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 0000000..c7e8eb2 --- /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 c89f5b3..b921ef8 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 4282cfd..50cd675 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 6e5de87..af9dab0 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 0000000..65cc4f8 --- /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 0000000..9e69c49 --- /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 0000000..4f16035 --- /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 0000000..2d2e4a5 --- /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 0000000..90992ab --- /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 0000000..c29cba2 --- /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 0000000..74ede70 --- /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 0000000..bbe693b --- /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 0000000..1d00e5f --- /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 0000000..e26101f --- /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 7a38cac..09c6527 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 af9e2fe..dd616ef 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 ff84664..b82a4da 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 8eee201..a4000eb 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 7169615..4c51f47 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 2896f6a..1b11d82 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 50fdc89..301fe8c 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 67bfe50..5ba0626 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 48cea63..7fcb42d 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 3bc764a..fe5c310 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 312b1df..367429e 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 eb23e16..74357ca 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 94ad381..82e4ae7 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