Skater: FPV drone mode

An FPV drone flown from the free camera: real quad physics (thrust,
gravity, drag, Betaflight "Actual" rates), collisions with the world, and
a goggle-style display. Open it with `dronemenu` (or the drone window's
Start flying).

Flying
- Keyboard and mouse, gamepads, or USB RC radios (EdgeTX etc. in Joystick
  mode). "Set up controller" learns each stick and switch by asking for
  one at a time, reversed axes and springing gamepad throttles included.
  While a controller flies the drone, the game is kept off it, as
  freecam's controller mode does.
- Presets (Tinywhoop, Toothpick, 5 inch), arm/disarm, back to takeoff,
  and every setting live through `dronetune`, saved in the profile.
- Optional range: the picture breaks up and is lost with distance from
  the takeoff point; past it, failsafe cuts the motors.
- OSD, on-screen sticks, an analogue video look, synthesised motor sound.
- Goggles: the pilot's skater wears eyewear from the game's catalog while
  flying, and gets their own back on landing (Road Rash aware).
- Start flying hides the game UI, Stop flying brings it back.

Multiplayer
- New packet `drone` (36): where a player's drone is and which way it
  faces, 15 times a second, unreliable, relayed like effects. Markers
  glide between updates. Protocol version 52.
- FLYING DRONE above the name of a player who is flying.
- Players choose whether to share their drone and whether to see others'.
- Dedicated servers: `sync_drones` (players section) and `drones on|off`.
  Off: no drone packets are relayed, and players are told in the roster.

Changes to existing code
- `flyspeed` takes any speed from 0.6 to 1500, not only the seven listed
  (the drone's presets and top speed slider use speeds of their own).
- Overlay: begin/end_input_access, so the drone's controller read sees
  the device while the game is kept off it.
- Profile: local_surface_hit, the park editor's ray from one point to
  another, for the drone's collisions.

Tests: Skater/Test/drone_radio_tests (controller setup), and the drone
packet and roster rule in Multiplayer/Test/protocol_tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MArBL6UbYZPsp9rMnq2Ks5
This commit is contained in:
AldoandClaude Opus 5.5 committed 2026-10-13 21:59:49 +01:00
1 parent 9124869ba4
commit 57d98307a7
42 files changed
+2972 -23

No files matched your search

+58 -7
View File
@@ -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 <atomic>
#include <cmath>
#include <cstdlib>
#include <format>
#include <optional>
#include <utility>
@@ -31,7 +35,10 @@ double source_flight_now() {
return static_cast<double>(counter.QuadPart) / frequency;
}
namespace {
constexpr std::array<float, 7> 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<float, 3>& from, const std::array<float, 3>& to,
std::array<float, 3>& 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<int>(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.");