diff --git a/CMakeLists.txt b/CMakeLists.txt index f1cea474..e427d438 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -197,12 +197,6 @@ 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) - add_executable(dingosdk_drone_flight_tests Extension/Skater/Test/drone_flight_tests.cpp) - target_include_directories(dingosdk_drone_flight_tests PRIVATE "${PROJECT_SOURCE_DIR}") - add_test(NAME drone_flight COMMAND dingosdk_drone_flight_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}") @@ -363,21 +357,15 @@ if(DINGOSDK_BUILD_TRAINER_TESTS) target_include_directories(dingosdk_trainer_asset_list PRIVATE "${PROJECT_SOURCE_DIR}") target_link_libraries(dingosdk_trainer_asset_list PRIVATE dingosdk_game_archives) endif() -option(DINGOSDK_BUILD_ROAD_RASH_TESTS "Build Road Rash regression tests" OFF) -if(DINGOSDK_BUILD_ROAD_RASH_TESTS) +option(DINGOSDK_BUILD_ANIMATION_STUDIO_TESTS "Build Animation Studio regression tests" OFF) +if(DINGOSDK_BUILD_ANIMATION_STUDIO_TESTS) enable_testing() - # The side of a fall, the marks a count of bails leaves, the items' names and where a decal goes. - add_executable(dingosdk_road_rash_tests Extension/RoadRash/Test/road_rash_tests.cpp) - target_include_directories(dingosdk_road_rash_tests PRIVATE "${PROJECT_SOURCE_DIR}") - add_test(NAME road_rash COMMAND dingosdk_road_rash_tests) -endif() -option(DINGOSDK_BUILD_BOARD_BREAK_TESTS "Build Board Break regression tests" OFF) -if(DINGOSDK_BUILD_BOARD_BREAK_TESTS) - enable_testing() - # What a break leaves of a board, how likely a slam breaks it, and where its parts lie. - add_executable(dingosdk_board_break_tests Extension/BoardBreak/Test/board_break_tests.cpp) - target_include_directories(dingosdk_board_break_tests PRIVATE "${PROJECT_SOURCE_DIR}") - add_test(NAME board_break COMMAND dingosdk_board_break_tests) + # Keyframes, their blending, the profile and clip files, and joints hanging from their parents. + add_executable(dingosdk_animation_studio_tests Extension/AnimationStudio/Test/anim_core_tests.cpp + Extension/AnimationStudio/anim_core.cpp) + target_include_directories(dingosdk_animation_studio_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_animation_studio_tests PRIVATE dingosdk_json) + add_test(NAME animation_studio COMMAND dingosdk_animation_studio_tests) endif() option(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS "Build replay keyframe smoothing regression tests" OFF) if(DINGOSDK_BUILD_REPLAY_KEYFRAMES_TESTS) diff --git a/Engine/Vfs/builtin_packs.h b/Engine/Vfs/builtin_packs.h index da1cc0d8..8c8de083 100644 --- a/Engine/Vfs/builtin_packs.h +++ b/Engine/Vfs/builtin_packs.h @@ -35,9 +35,6 @@ inline constexpr std::string_view official_stamp = ".official-version"; // launcher keeps the last list of official mods it read there. inline constexpr std::string_view official_folder = ".reskate-official"; inline constexpr std::string_view official_off_file = "official-off.txt"; -// The official mods the features are made for, by folder name (Namespace-Name). -inline constexpr std::string_view board_break_official = "Fourth_Studio-Board_Break_Pack"; -inline constexpr std::string_view road_rash_official = "Fourth_Studio-Road_Rash"; // Moves the official mods kept in Mods/.reskate-official into the Mods folder, each at the bottom // of the load order (where they loaded from there), switched off when they were turned off there. // Says what it did, a line each. Does nothing once there is nothing left there. Never throws. diff --git a/Engine/Vfs/mod_catalog.cpp b/Engine/Vfs/mod_catalog.cpp index cc196f57..fb7cd54f 100644 --- a/Engine/Vfs/mod_catalog.cpp +++ b/Engine/Vfs/mod_catalog.cpp @@ -381,7 +381,7 @@ Catalog load_catalog(const std::filesystem::path& data_root, const MergeObserver const auto deck_of = [&](const Mod& mod) { return std::find(packs.begin(), packs.end(), mod.name) != packs.end() ? std::string{} : board_break_deck_of(mod.name); }; - // Board Break (Extension/BoardBreak/board_break.h). The deck that can break is a mod's own + // The breakable decks (Extension/Skater/breakable_deck.h). The deck that can break is a mod's own // copy of the game's one deck model, and any mod that reshapes the deck replaces that same // model: the one higher in the load order has it. So the deck the player chose goes first // for this run, whatever mods.json says of the order, and what would fight it is set aside: diff --git a/Engine/Vfs/mod_catalog.h b/Engine/Vfs/mod_catalog.h index 9b6f8c69..4b6b0105 100644 --- a/Engine/Vfs/mod_catalog.h +++ b/Engine/Vfs/mod_catalog.h @@ -48,7 +48,7 @@ struct Catalog { std::vector set_aside; }; -// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model for every +// The breakable decks (Extension/Skater/breakable_deck.h). The game has one deck model for every // board, so each deck that can break is a copy of it in a content mod, and the deck the player // chose is the one that loads. A deck comes in one of two ways: // - a mod whose folder is named Board_Break_ (also behind a team name, as a mod manager diff --git a/Extension/AnimationStudio/Test/anim_core_tests.cpp b/Extension/AnimationStudio/Test/anim_core_tests.cpp new file mode 100644 index 00000000..97794cfa --- /dev/null +++ b/Extension/AnimationStudio/Test/anim_core_tests.cpp @@ -0,0 +1,150 @@ +// Animation Studio's keyframes apart from the game: turning numbers, blending keys, what a profile +// and a clip file hold, and that edits never grow a pose. +#include "Extension/AnimationStudio/anim_core.h" +#include +#include +#include + +using namespace dingosdk::anim_studio; +namespace { +void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +bool near(float a, float b, float within = 1e-3f) { return std::abs(a - b) < within; } +bool same_turn(const Quat& a, const Quat& b) { + const float d = std::abs(a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]); + return d > 0.9999f; +} + +void euler_numbers_read_back() { + for (const Vec3 angles : {Vec3{0, 0, 0}, Vec3{30, 0, 0}, Vec3{0, -45, 0}, Vec3{10, 20, 30}, Vec3{-120, 60, 170}, Vec3{5, -80, -90}}) { + const auto back = to_euler(from_euler(angles)); + check(same_turn(from_euler(back), from_euler(angles)), "Degrees turned into a rotation and back give the same rotation"); + } + const auto x = from_euler({90, 0, 0}); + const auto v = rotate(x, {0, 1, 0}); + check(near(v[0], 0) && near(v[1], 0) && near(v[2], 1), "90 degrees about X turns +Y onto +Z"); +} + +void slerp_meets_both_ends() { + const auto a = from_euler({0, 0, 0}), b = from_euler({0, 90, 0}); + check(same_turn(slerp(a, b, 0), a) && same_turn(slerp(a, b, 1), b), "Blending starts and ends on the keys"); + check(same_turn(slerp(a, b, 0.5f), from_euler({0, 45, 0})), "and halfway is half the turn"); +} + +void keys_hold_outside_and_blend_between() { + Animation animation; + animation.clip.times = {0, 1}; + set_key(animation, 343, 0.2f, {from_euler({0, 0, 0}), {0, 0, 0}}); + set_key(animation, 343, 0.8f, {from_euler({60, 0, 0}), {0, 0.3f, 0}}); + const auto* track = animation.track(343); + check(track && track->keys.size() == 2, "Two keys on the left foot"); + check(same_turn(evaluate(*track, 0.0f).rotation, from_euler({0, 0, 0})), "Before the first key it holds"); + check(same_turn(evaluate(*track, 1.0f).rotation, from_euler({60, 0, 0})), "After the last key it holds"); + check(near(evaluate(*track, 0.5f).offset[1], 0.15f), "Halfway between the keys the move is halfway"); + set_key(animation, 343, 0.8f + 1.0f / 240, {from_euler({30, 0, 0}), {}}); + check(track->keys.size() == 2, "A key set on top of another replaces it"); + check(remove_key(animation, 343, 0.2f) && track->keys.size() == 1, "and a key can be taken off"); + auto& key = animation.track(343)->keys.front(); + key.ease = Ease::step; + set_key(animation, 343, 0.9f, {from_euler({0, 0, 0}), {}}); + check(same_turn(evaluate(*animation.track(343), 0.85f).rotation, from_euler({30, 0, 0})), "A stepped key holds until the next one"); +} + +void keys_can_be_dragged_in_time() { + Track track{7, {{0.1f}, {0.5f}, {0.9f}}}; + const auto index = move_key(track, 0, 0.7f, 1.0f); + check(index == 1 && near(track.keys[1].time, 0.7f) && near(track.keys[0].time, 0.5f), "A key dragged past another moves past it"); + move_key(track, 2, 5.0f, 1.0f); + check(near(track.keys.back().time, 1.0f), "A key stays on the clip"); +} + +void edits_are_on_top_of_the_pose_and_never_grow_it() { + Animation animation; + animation.clip.times = {0, 2}; + for (int i = 0; i < 1000; ++i) + set_key(animation, editable_joints()[static_cast(i) % editable_joints().size()].index, static_cast(i / 24) / 50.0f, + {from_euler({1, 0, 0}), {}}); + check(animation.key_count() == 1000, "A thousand keys are kept"); + std::vector pose(skeleton_joints); + pose[343].rotation = from_euler({0, 20, 0}); + apply_edits(animation, 0.5f, pose); + check(pose.size() == skeleton_joints, "The pose keeps its 395 joints whatever is keyed"); + check(same_turn(pose[343].rotation, multiply(from_euler({0, 20, 0}), from_euler({1, 0, 0}))), "The edit turns the joint on from its own pose"); + check(same_turn(pose[200].rotation, Quat{0, 0, 0, 1}), "A joint without keys is untouched"); +} + +void joints_hang_from_their_parents() { + std::vector pose(skeleton_joints); + pose[7].position = {0, 1, 0}; // the hips 1 m up + pose[341].position = {0.1f, 0, 0}; // the left thigh beside them + pose[342].position = {0, -0.5f, 0}; // the knee below the thigh + pose[341].rotation = from_euler({0, 0, 90}); + const auto knee = world(pose, 342); + // The thigh's quarter turn about Z swings the knee's -Y offset onto +X. + check(near(knee.position[0], 0.6f) && near(knee.position[1], 1.0f), "Turning the thigh carries the knee"); + const auto hips = world(pose, 7); + check(near(hips.position[1], 1.0f), "and the hips stay where they were"); +} + +void profiles_read_back_and_bad_ones_are_refused() { + Animation animation; + animation.name = "Kickflip 2"; + animation.trick = "Kickflip"; + animation.clip.times = {0, 1.5f}; + animation.speed = 0.5f; + set_key(animation, 7, 0.4f, {from_euler({0, 10, 0}), {0, 0.1f, 0}}); + set_key(animation, 278, 1.0f, {from_euler({20, 0, 5}), {}}).ease = Ease::linear; + const auto back = from_json(to_json(animation)); + check(back.name == "Kickflip 2" && back.trick == "Kickflip" && near(back.speed, 0.5f), "Name, trick and speed read back"); + check(back.key_count() == 2 && back.track(278) && back.track(278)->keys[0].ease == Ease::linear, "Keys read back"); + check(same_turn(back.track(7)->keys[0].rotation, from_euler({0, 10, 0})) && near(back.track(7)->keys[0].offset[1], 0.1f), + "with their values"); + bool refused = false; + try { (void)from_json(R"({"format":"fourth-animation-studio","version":1,"duration":1,"tracks":[{"joint":200,"keys":[]}]})"); } + catch (const std::runtime_error&) { refused = true; } + check(refused, "A track on a joint the editor does not edit is refused"); + refused = false; + try { (void)from_json(R"({"format":"fourth-animation-studio","version":1,"duration":1,"tracks":[{"joint":7,"keys":[{"t":9,"r":[0,0,0,1],"p":[0,0,0]}]}]})"); } + catch (const std::runtime_error&) { refused = true; } + check(refused, "A key past the clip is refused"); + check(file_safe(" ../Kick:flip* ") == "Kickflip", "File names keep only safe letters"); +} + +void clips_read_back_and_broken_ones_are_refused() { + Clip clip; + clip.joints = 3; + clip.times = {0, 0.5f}; + clip.frames.resize(6); + clip.frames[4].position = {1, 2, 3}; + clip.takeoff = 0.1f; + clip.landing = 0.4f; + const auto bytes = clip_bytes(clip); + const auto back = clip_from_bytes(bytes); + check(back.joints == 3 && back.times.size() == 2 && near(back.frames[4].position[2], 3) && near(back.landing, 0.4f), "A clip reads back"); + std::vector pose(3); + back.sample(0.25f, pose); + check(near(pose[1].position[1], 1.0f), "and samples halfway between frames"); + auto cut = bytes; + cut.pop_back(); + bool refused = false; + try { (void)clip_from_bytes(cut); } catch (const std::runtime_error&) { refused = true; } + check(refused, "A cut-short clip is refused"); +} +} // namespace + +int main() { + try { + euler_numbers_read_back(); + slerp_meets_both_ends(); + keys_hold_outside_and_blend_between(); + keys_can_be_dragged_in_time(); + edits_are_on_top_of_the_pose_and_never_grow_it(); + joints_hang_from_their_parents(); + profiles_read_back_and_bad_ones_are_refused(); + clips_read_back_and_broken_ones_are_refused(); + } catch (const std::exception& error) { + std::cerr << "FAILED: " << error.what() << '\n'; + return 1; + } + std::cout << "anim_core: all passed\n"; + return 0; +} diff --git a/Extension/AnimationStudio/anim_core.cpp b/Extension/AnimationStudio/anim_core.cpp new file mode 100644 index 00000000..be82ab9d --- /dev/null +++ b/Extension/AnimationStudio/anim_core.cpp @@ -0,0 +1,498 @@ +#include "anim_core.h" +#include "Engine/Core/Json/json.h" +#include "Extension/Multiplayer/Net/skater_bone_parents.h" +#include +#include +#include +#include + +namespace dingosdk::anim_studio { +namespace { +constexpr float pi = 3.14159265358979f; +float dot(const Quat& a, const Quat& b) noexcept { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; } +Vec3 lerp(const Vec3& a, const Vec3& b, float t) noexcept { + return {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t}; +} +// Cubic Hermite through p1 -> p2 with Catmull-Rom slopes from the neighbours. +Vec3 hermite(const Vec3& p0, const Vec3& p1, const Vec3& p2, const Vec3& p3, float u) noexcept { + const float u2 = u * u, u3 = u2 * u; + const float h00 = 2 * u3 - 3 * u2 + 1, h10 = u3 - 2 * u2 + u, h01 = -2 * u3 + 3 * u2, h11 = u3 - u2; + Vec3 out{}; + for (std::size_t i = 0; i < 3; ++i) { + const float m1 = (p2[i] - p0[i]) * 0.5f, m2 = (p3[i] - p1[i]) * 0.5f; + out[i] = h00 * p1[i] + h10 * m1 + h01 * p2[i] + h11 * m2; + } + return out; +} +void require(bool ok, const char* what) { + if (!ok) throw std::runtime_error(what); +} +float number(const Json& value, const char* what) { + require(value.is_number(), what); + const auto result = value.get(); + require(std::isfinite(result), what); + return result; +} +template std::array numbers(const Json& value, const char* what) { + require(value.is_array() && value.size() == N, what); + std::array out{}; + for (std::size_t i = 0; i < N; ++i) out[i] = number(value.at(i), what); + return out; +} +std::string_view ease_name(Ease ease) noexcept { + return ease == Ease::linear ? "linear" : ease == Ease::step ? "step" : "smooth"; +} + +constexpr std::array joints{{ + {board_joint, "Board", true}, + {7, "Hips", true}, + {42, "Lower back", false}, + {44, "Chest", false}, + {101, "Neck", false}, + {103, "Head", false}, + {275, "Left shoulder", false}, + {276, "Left arm", false}, + {277, "Left elbow", false}, + {278, "Left hand", false}, + {46, "Right shoulder", false}, + {47, "Right arm", false}, + {48, "Right elbow", false}, + {49, "Right hand", false}, + {341, "Left leg", false}, + {342, "Left knee", false}, + {343, "Left foot", false}, + {344, "Left toes", false}, + {8, "Right leg", false}, + {9, "Right knee", false}, + {10, "Right foot", false}, + {11, "Right toes", false}, + {43, "Middle back", false}, + {45, "Upper chest", false}, + {102, "Upper neck", false}, +}}; +} // namespace + +Quat multiply(const Quat& a, const Quat& b) noexcept { + return {a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]}; +} +Quat conjugate(const Quat& q) noexcept { return {-q[0], -q[1], -q[2], q[3]}; } +Quat normalised(const Quat& q) noexcept { + const float length = std::sqrt(dot(q, q)); + if (!(length > 1e-8f) || !std::isfinite(length)) return {0, 0, 0, 1}; + return {q[0] / length, q[1] / length, q[2] / length, q[3] / length}; +} +Quat slerp(Quat a, const Quat& b, float t) noexcept { + float cosine = dot(a, b); + if (cosine < 0) { + a = {-a[0], -a[1], -a[2], -a[3]}; + cosine = -cosine; + } + if (cosine > 0.9995f) { + Quat out{}; + for (std::size_t i = 0; i < 4; ++i) out[i] = a[i] + (b[i] - a[i]) * t; + return normalised(out); + } + const float angle = std::acos(std::clamp(cosine, -1.0f, 1.0f)), sine = std::sin(angle); + const float wa = std::sin((1 - t) * angle) / sine, wb = std::sin(t * angle) / sine; + return normalised({a[0] * wa + b[0] * wb, a[1] * wa + b[1] * wb, a[2] * wa + b[2] * wb, a[3] * wa + b[3] * wb}); +} +Vec3 rotate(const Quat& q, const Vec3& v) noexcept { + const Vec3 u{q[0], q[1], q[2]}; + const float s = q[3]; + const Vec3 t{2 * (u[1] * v[2] - u[2] * v[1]), 2 * (u[2] * v[0] - u[0] * v[2]), 2 * (u[0] * v[1] - u[1] * v[0])}; + return {v[0] + s * t[0] + (u[1] * t[2] - u[2] * t[1]), v[1] + s * t[1] + (u[2] * t[0] - u[0] * t[2]), + v[2] + s * t[2] + (u[0] * t[1] - u[1] * t[0])}; +} +Quat axis_angle(const Vec3& axis, float radians) noexcept { + const float length = std::sqrt(axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]); + if (!(length > 1e-8f)) return {0, 0, 0, 1}; + const float s = std::sin(radians * 0.5f) / length; + return {axis[0] * s, axis[1] * s, axis[2] * s, std::cos(radians * 0.5f)}; +} +Quat from_euler(const Vec3& degrees) noexcept { + const float k = pi / 180; + return normalised(multiply(multiply(axis_angle({1, 0, 0}, degrees[0] * k), axis_angle({0, 1, 0}, degrees[1] * k)), + axis_angle({0, 0, 1}, degrees[2] * k))); +} +Vec3 to_euler(const Quat& raw) noexcept { + const auto q = normalised(raw); + const float x = q[0], y = q[1], z = q[2], w = q[3]; + const float m00 = 1 - 2 * (y * y + z * z), m01 = 2 * (x * y - z * w), m02 = 2 * (x * z + y * w); + const float m12 = 2 * (y * z - x * w), m22 = 1 - 2 * (x * x + y * y); + const float k = 180 / pi; + const float b = std::asin(std::clamp(m02, -1.0f, 1.0f)); + if (std::abs(m02) > 0.9999f) { + // Gimbal lock: all of the turn about X and Z goes on X. + const float m10 = 2 * (x * y + z * w), m11 = 1 - 2 * (x * x + z * z); + return {std::atan2(m10, m11) * k, b * k, 0}; + } + return {std::atan2(-m12, m22) * k, b * k, std::atan2(-m01, m00) * k}; +} +bool finite(const Joint& joint) noexcept { + for (const float v : joint.scale) if (!std::isfinite(v) || std::abs(v) > 1000) return false; + for (const float v : joint.rotation) if (!std::isfinite(v) || std::abs(v) > 1.01f) return false; + for (const float v : joint.position) if (!std::isfinite(v) || std::abs(v) > 1e6f) return false; + return true; +} + +std::span editable_joints() noexcept { return joints; } +const EditableJoint* editable(std::uint16_t joint) noexcept { + for (const auto& j : joints) if (j.index == joint) return &j; + return nullptr; +} + +void Clip::sample(float seconds, std::span out) const noexcept { + if (empty()) return; + const auto count = std::min(out.size(), joints); + if (seconds <= times.front() || times.size() == 1) { + std::copy_n(frames.begin(), count, out.begin()); + return; + } + if (seconds >= times.back()) { + std::copy_n(frames.begin() + (times.size() - 1) * joints, count, out.begin()); + return; + } + const auto next = static_cast(std::upper_bound(times.begin(), times.end(), seconds) - times.begin()); + const auto previous = next - 1; + const float span = times[next] - times[previous]; + const float t = span > 1e-6f ? (seconds - times[previous]) / span : 0.0f; + const auto* a = frames.data() + previous * joints; + const auto* b = frames.data() + next * joints; + for (std::size_t i = 0; i < count; ++i) { + out[i].scale = lerp(a[i].scale, b[i].scale, t); + out[i].rotation = slerp(a[i].rotation, b[i].rotation, t); + out[i].position = lerp(a[i].position, b[i].position, t); + } +} + +bool Clip::sample_board(float seconds, Joint& out) const noexcept { + if (empty() || board.size() != times.size()) return false; + if (seconds <= times.front() || times.size() == 1) { + out = board.front(); + return true; + } + if (seconds >= times.back()) { + out = board.back(); + return true; + } + const auto next = static_cast(std::upper_bound(times.begin(), times.end(), seconds) - times.begin()); + const auto previous = next - 1; + const float span = times[next] - times[previous]; + const float t = span > 1e-6f ? (seconds - times[previous]) / span : 0.0f; + out.scale = lerp(board[previous].scale, board[next].scale, t); + out.rotation = slerp(board[previous].rotation, board[next].rotation, t); + out.position = lerp(board[previous].position, board[next].position, t); + return true; +} + +Offset evaluate(const Track& track, float seconds) noexcept { + const auto& keys = track.keys; + if (keys.empty()) return {}; + if (seconds <= keys.front().time) return {keys.front().rotation, keys.front().offset}; + if (seconds >= keys.back().time) return {keys.back().rotation, keys.back().offset}; + std::size_t i = 0; + while (i + 1 < keys.size() && keys[i + 1].time <= seconds) ++i; + const auto& a = keys[i]; + const auto& b = keys[i + 1]; + const float span = b.time - a.time; + const float u = span > 1e-6f ? std::clamp((seconds - a.time) / span, 0.0f, 1.0f) : 1.0f; + switch (a.ease) { + case Ease::step: + return {a.rotation, a.offset}; + case Ease::linear: + return {slerp(a.rotation, b.rotation, u), lerp(a.offset, b.offset, u)}; + case Ease::smooth: + default: { + const float eased = u * u * (3 - 2 * u); + const auto& before = i > 0 ? keys[i - 1].offset : a.offset; + const auto& after = i + 2 < keys.size() ? keys[i + 2].offset : b.offset; + return {slerp(a.rotation, b.rotation, eased), hermite(before, a.offset, b.offset, after, u)}; + } + } +} + +Track* Animation::track(std::uint16_t joint) noexcept { + for (auto& t : tracks) if (t.joint == joint) return &t; + return nullptr; +} +const Track* Animation::track(std::uint16_t joint) const noexcept { + for (const auto& t : tracks) if (t.joint == joint) return &t; + return nullptr; +} +std::size_t Animation::key_count() const noexcept { + std::size_t n = 0; + for (const auto& t : tracks) n += t.keys.size(); + return n; +} +void apply_edits(const Animation& animation, float seconds, std::span pose) noexcept { + for (const auto& track : animation.tracks) { + if (track.hidden || track.keys.empty() || track.joint >= pose.size()) continue; + const auto edit = evaluate(track, seconds); + auto& joint = pose[track.joint]; + joint.rotation = normalised(multiply(joint.rotation, edit.rotation)); + for (std::size_t i = 0; i < 3; ++i) joint.position[i] += edit.offset[i]; + } +} +Key& set_key(Animation& animation, std::uint16_t joint, float seconds, const Offset& value, float merge) { + auto* track = animation.track(joint); + if (!track) { + animation.tracks.push_back({joint, {}, false, false}); + track = &animation.tracks.back(); + } + for (auto& key : track->keys) + if (std::abs(key.time - seconds) <= merge) { + key.rotation = normalised(value.rotation); + key.offset = value.offset; + return key; + } + Key key{seconds, normalised(value.rotation), value.offset, Ease::smooth}; + const auto at = std::upper_bound(track->keys.begin(), track->keys.end(), seconds, + [](float t, const Key& k) { return t < k.time; }); + return *track->keys.insert(at, key); +} +bool remove_key(Animation& animation, std::uint16_t joint, float seconds, float merge) { + auto* track = animation.track(joint); + if (!track) return false; + const auto before = track->keys.size(); + std::erase_if(track->keys, [&](const Key& k) { return std::abs(k.time - seconds) <= merge; }); + return track->keys.size() != before; +} +std::size_t move_key(Track& track, std::size_t index, float to, float duration) noexcept { + if (index >= track.keys.size()) return index; + auto key = track.keys[index]; + key.time = std::clamp(to, 0.0f, std::max(duration, 0.0f)); + track.keys.erase(track.keys.begin() + static_cast(index)); + const auto at = std::upper_bound(track.keys.begin(), track.keys.end(), key.time, + [](float t, const Key& k) { return t < k.time; }); + const auto placed = static_cast(at - track.keys.begin()); + track.keys.insert(at, key); + return placed; +} + +std::int16_t parent(std::uint16_t joint) noexcept { + return joint < multiplayer::skater_bone_parents.size() ? multiplayer::skater_bone_parents[joint] : -1; +} +Joint world(std::span local, std::uint16_t joint) noexcept { + std::array chain{}; + std::size_t length = 0; + for (int at = joint; at >= 0 && at < static_cast(local.size()) && length < chain.size(); at = parent(static_cast(at))) + chain[length++] = static_cast(at); + Joint result; + float scale = 1; + for (std::size_t i = length; i-- > 0;) { + const auto& bone = local[chain[i]]; + const auto moved = rotate(result.rotation, {bone.position[0] * scale, bone.position[1] * scale, bone.position[2] * scale}); + for (std::size_t a = 0; a < 3; ++a) result.position[a] += moved[a]; + result.rotation = normalised(multiply(result.rotation, bone.rotation)); + scale *= (bone.scale[0] + bone.scale[1] + bone.scale[2]) / 3.0f; + } + result.scale = {scale, scale, scale}; + return result; +} + +std::string to_json(const Animation& animation) { + Json root = Json::object(); + root["format"] = "fourth-animation-studio"; + root["version"] = 1; + root["name"] = animation.name; + root["trick"] = animation.trick; + root["speed"] = animation.speed; + root["loop"] = animation.loop; + root["pop"] = animation.pop; + root["duration"] = animation.clip.duration(); + root["takeoff"] = animation.clip.takeoff; + root["landing"] = animation.clip.landing; + Json tracks = Json::array(); + for (const auto& track : animation.tracks) { + if (track.keys.empty()) continue; + Json t = Json::object(); + t["joint"] = track.joint; + if (const auto* e = editable(track.joint)) t["label"] = std::string(e->label); + t["hidden"] = track.hidden; + t["locked"] = track.locked; + Json keys = Json::array(); + for (const auto& key : track.keys) { + Json k = Json::object(); + k["t"] = key.time; + k["r"] = Json::array({key.rotation[0], key.rotation[1], key.rotation[2], key.rotation[3]}); + k["p"] = Json::array({key.offset[0], key.offset[1], key.offset[2]}); + k["ease"] = std::string(ease_name(key.ease)); + keys.push_back(std::move(k)); + } + t["keys"] = std::move(keys); + tracks.push_back(std::move(t)); + } + root["tracks"] = std::move(tracks); + return root.dump(2); +} +Animation from_json(std::string_view text) { + const auto root = Json::parse(text); + require(root.is_object() && root.value("format", "") == "fourth-animation-studio", "not an Animation Studio profile"); + require(root.contains("version") && root.at("version").is_number() && root.at("version").get() == 1, + "the profile is from a newer Animation Studio"); + Animation result; + result.name = root.value("name", ""); + result.trick = root.value("trick", ""); + result.speed = root.contains("speed") ? std::clamp(number(root.at("speed"), "bad speed"), 0.1f, 4.0f) : 1.0f; + result.loop = root.contains("loop") && root.at("loop").is_boolean() ? root.at("loop").get() : true; + result.pop = root.value("pop", ""); + if (result.pop != "low" && result.pop != "mid" && result.pop != "high") result.pop.clear(); + const float duration = root.contains("duration") ? number(root.at("duration"), "bad duration") : 0.0f; + require(duration >= 0 && duration < 120, "bad duration"); + std::size_t total = 0; + if (root.contains("tracks")) { + const auto& tracks = root.at("tracks"); + require(tracks.is_array() && tracks.size() <= joints.size(), "bad tracks"); + for (std::size_t i = 0; i < tracks.size(); ++i) { + const auto& t = tracks.at(i); + require(t.is_object() && t.contains("joint") && t.at("joint").is_number_integer(), "a track has no joint"); + const auto joint = t.at("joint").get(); + require(joint >= 0 && joint < 65536 && editable(static_cast(joint)), "a track names a joint the editor does not edit"); + require(!result.track(static_cast(joint)), "two tracks for one joint"); + Track track{static_cast(joint), {}, + t.contains("hidden") && t.at("hidden").is_boolean() && t.at("hidden").get(), + t.contains("locked") && t.at("locked").is_boolean() && t.at("locked").get()}; + require(t.contains("keys") && t.at("keys").is_array(), "a track has no keys"); + const auto& keys = t.at("keys"); + total += keys.size(); + require(total <= 20000, "too many keys"); + for (std::size_t k = 0; k < keys.size(); ++k) { + const auto& key = keys.at(k); + require(key.is_object() && key.contains("t") && key.contains("r") && key.contains("p"), "a key is incomplete"); + Key value; + value.time = number(key.at("t"), "bad key time"); + require(value.time >= 0 && value.time <= duration + 0.01f, "a key lies past the clip"); + value.rotation = normalised(numbers<4>(key.at("r"), "bad key rotation")); + value.offset = numbers<3>(key.at("p"), "bad key offset"); + for (const float v : value.offset) require(std::abs(v) < 10, "a key moves a joint more than 10 m"); + const auto ease = key.value("ease", "smooth"); + value.ease = ease == "linear" ? Ease::linear : ease == "step" ? Ease::step : Ease::smooth; + track.keys.push_back(value); + } + std::stable_sort(track.keys.begin(), track.keys.end(), [](const Key& a, const Key& b) { return a.time < b.time; }); + result.tracks.push_back(std::move(track)); + } + } + return result; +} + +// "FAS3": the joints that never move in the clip (most of the face and the fingers) are stored once, +// the others every frame, and the board every frame when there is one. "FAS1" (no board) and "FAS2" +// (every joint every frame, then the board) still read. +std::vector clip_bytes(const Clip& clip) { + std::vector out; + const auto put = [&](const void* data, std::size_t size) { + const auto* p = static_cast(data); + out.insert(out.end(), p, p + size); + }; + const auto put_joint = [&](const Joint& j) { + put(j.scale.data(), 12); + put(j.rotation.data(), 16); + put(j.position.data(), 12); + }; + const bool board = clip.board.size() == clip.times.size() && !clip.board.empty(); + const std::uint32_t frames = static_cast(clip.times.size()); + put("FAS3", 4); + put(&clip.joints, 4); + put(&frames, 4); + put(&clip.takeoff, 4); + put(&clip.landing, 4); + const std::uint8_t has_board = board ? 1 : 0; + put(&has_board, 1); + put(clip.times.data(), clip.times.size() * 4); + std::vector moves(clip.joints, 0); + const auto same = [](const Joint& a, const Joint& b) { + for (std::size_t i = 0; i < 3; ++i) + if (a.scale[i] != b.scale[i] || a.position[i] != b.position[i]) return false; + for (std::size_t i = 0; i < 4; ++i) + if (a.rotation[i] != b.rotation[i]) return false; + return true; + }; + for (std::uint32_t j = 0; j < clip.joints && frames; ++j) + for (std::uint32_t f = 1; f < frames && !moves[j]; ++f) moves[j] = !same(clip.frames[f * clip.joints + j], clip.frames[j]); + put(moves.data(), moves.size()); + for (std::uint32_t j = 0; j < clip.joints && frames; ++j) + if (!moves[j]) put_joint(clip.frames[j]); + for (std::uint32_t f = 0; f < frames; ++f) { + for (std::uint32_t j = 0; j < clip.joints; ++j) + if (moves[j]) put_joint(clip.frames[f * clip.joints + j]); + if (board) put_joint(clip.board[f]); + } + return out; +} +Clip clip_from_bytes(std::span bytes) { + std::size_t at = 0; + const auto get = [&](void* data, std::size_t size) { + require(bytes.size() - at >= size, "the clip file is cut short"); + std::memcpy(data, bytes.data() + at, size); + at += size; + }; + const auto get_joint = [&](Joint& j, const char* what) { + get(j.scale.data(), 12); + get(j.rotation.data(), 16); + get(j.position.data(), 12); + require(finite(j), what); + }; + char magic[4]{}; + require(bytes.size() >= 20, "the clip file is cut short"); + get(magic, 4); + const bool v3 = std::memcmp(magic, "FAS3", 4) == 0; + bool board = std::memcmp(magic, "FAS2", 4) == 0; + require(v3 || board || std::memcmp(magic, "FAS1", 4) == 0, "not an Animation Studio clip"); + Clip clip; + std::uint32_t frames{}; + get(&clip.joints, 4); + get(&frames, 4); + get(&clip.takeoff, 4); + get(&clip.landing, 4); + require(clip.joints > 0 && clip.joints <= 512 && frames <= 20000, "the clip is too large"); + if (v3) { + std::uint8_t has_board{}; + get(&has_board, 1); + board = has_board != 0; + } + clip.times.resize(frames); + get(clip.times.data(), frames * 4ull); + for (std::size_t i = 0; i < clip.times.size(); ++i) + require(std::isfinite(clip.times[i]) && clip.times[i] >= 0 && (i == 0 || clip.times[i] >= clip.times[i - 1]), "the clip's times are out of order"); + clip.frames.resize(static_cast(frames) * clip.joints); + if (board) clip.board.resize(frames); + if (v3) { + std::vector moves(clip.joints); + get(moves.data(), moves.size()); + std::size_t moving = 0; + for (const auto m : moves) moving += m ? 1 : 0; + require(bytes.size() - at == (clip.joints - moving) * 40ull + frames * (moving + (board ? 1 : 0)) * 40ull, "the clip file has the wrong size"); + Joint still; + for (std::uint32_t j = 0; j < clip.joints; ++j) + if (!moves[j]) { + get_joint(still, "the clip holds a broken pose"); + for (std::uint32_t f = 0; f < frames; ++f) clip.frames[f * clip.joints + j] = still; + } + for (std::uint32_t f = 0; f < frames; ++f) { + for (std::uint32_t j = 0; j < clip.joints; ++j) + if (moves[j]) get_joint(clip.frames[f * clip.joints + j], "the clip holds a broken pose"); + if (board) get_joint(clip.board[f], "the clip holds a broken board"); + } + } else { + require(bytes.size() - at == static_cast(frames) * (clip.joints + (board ? 1 : 0)) * 40ull, "the clip file has the wrong size"); + for (auto& j : clip.frames) get_joint(j, "the clip holds a broken pose"); + for (auto& j : clip.board) get_joint(j, "the clip holds a broken board"); + } + require(std::isfinite(clip.takeoff) && std::isfinite(clip.landing), "bad takeoff or landing"); + return clip; +} +std::string file_safe(std::string_view name) { + std::string out; + for (const char c : name) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_') out += c; + if (out.size() >= 48) break; + } + while (!out.empty() && out.back() == ' ') out.pop_back(); + while (!out.empty() && out.front() == ' ') out.erase(out.begin()); + return out.empty() ? std::string("Animation") : out; +} +} // namespace dingosdk::anim_studio diff --git a/Extension/AnimationStudio/anim_core.h b/Extension/AnimationStudio/anim_core.h new file mode 100644 index 00000000..a5212e7f --- /dev/null +++ b/Extension/AnimationStudio/anim_core.h @@ -0,0 +1,132 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +// FOURTH Animation Studio's own data: a trick recorded from the game, and the keyframes a player puts +// on it. Nothing here touches the game. The game side (anim_studio.cpp) copies the pose the native +// animation just wrote into a Clip, and writes the evaluated pose back over the same buffer, the way +// first person shrinks the head (client_first_person.cpp): no native animation data, channel or +// asset is created or changed, and the next native update writes the whole pose again. +// +// A pose is parent-local per joint, {scale, Hamilton quaternion xyzw, translation}, in the numbering +// of the 395-joint AnimBase_Default_Skeleton the skater's animation output uses (Hips 7, Spine 42, +// Head 103). Its first 386 joints are the render skeleton's, with the parents in +// Extension/Multiplayer/Net/skater_bone_parents.h; joint 1 (AITrajectory) carries the world placement. +namespace dingosdk::anim_studio { +using Vec3 = std::array; +using Quat = std::array; // x, y, z, w + +struct Joint { + Vec3 scale{1, 1, 1}; + Quat rotation{0, 0, 0, 1}; + Vec3 position{}; +}; + +// ---- maths --------------------------------------------------------------------------------------- +Quat multiply(const Quat& a, const Quat& b) noexcept; // a then b applied in a's frame: a * b +Quat conjugate(const Quat& q) noexcept; +Quat normalised(const Quat& q) noexcept; +Quat slerp(Quat a, const Quat& b, float t) noexcept; +Vec3 rotate(const Quat& q, const Vec3& v) noexcept; +Quat axis_angle(const Vec3& axis, float radians) noexcept; +// Degrees about X, then Y, then Z (each about the joint's own axes): the inspector's numbers. +Quat from_euler(const Vec3& degrees) noexcept; +Vec3 to_euler(const Quat& q) noexcept; +bool finite(const Joint& joint) noexcept; + +// ---- the joints a player edits -------------------------------------------------------------------- +struct EditableJoint { + std::uint16_t index; + std::string_view label; // as the timeline shows it + bool moves; // takes a position as well as a rotation (the hips) +}; +// In the order of the timeline's rows. +std::span editable_joints() noexcept; +const EditableJoint* editable(std::uint16_t joint) noexcept; +inline constexpr std::uint32_t skeleton_joints = 395; +inline constexpr std::uint16_t hips_joint = 7, placement_joint = 1; +// Not a skater joint: the board (its deck's mount, which carries the deck, trucks and wheels). Its +// track turns and moves the board where the recording had it, in the board's own frame. +inline constexpr std::uint16_t board_joint = 900; + +// ---- a recorded trick ----------------------------------------------------------------------------- +struct Clip { + std::uint32_t joints{}; + std::vector times; // seconds from the first frame, rising + std::vector frames; // times.size() * joints + // The board each frame, relative to the skater's placement joint (1); empty when not recorded. + std::vector board; + float takeoff{-1}, landing{-1}; // seconds, -1 when not seen + bool empty() const noexcept { return times.empty() || !joints; } + float duration() const noexcept { return times.empty() ? 0.0f : times.back(); } + std::span frame(std::size_t i) const noexcept { return {frames.data() + i * joints, joints}; } + // The pose at `seconds` (clamped), each joint blended between the two frames around it. + void sample(float seconds, std::span out) const noexcept; + // The board at `seconds`, relative to the placement joint; false when the clip has none. + bool sample_board(float seconds, Joint& out) const noexcept; +}; + +// ---- keyframes ------------------------------------------------------------------------------------ +enum class Ease : std::uint8_t { smooth, linear, step }; +// An edit of one joint: turned by `rotation` in its own frame (after the native pose), moved by +// `offset` in its parent's frame. Additive, so it rides on whatever the native animation does. +struct Key { + float time{}; // seconds on the clip + Quat rotation{0, 0, 0, 1}; + Vec3 offset{}; + Ease ease{Ease::smooth}; +}; +struct Track { + std::uint16_t joint{}; + std::vector keys; // by time + bool hidden{}, locked{}; +}; +struct Offset { + Quat rotation{0, 0, 0, 1}; + Vec3 offset{}; +}; +// Before the first key and after the last the nearest key holds. +Offset evaluate(const Track& track, float seconds) noexcept; + +struct Animation { + std::string name; // the profile's name, also its file name + std::string trick; // what the player says it is (Kickflip, Heelflip ...) + Clip clip; + std::vector tracks; + float speed{1}; + bool loop{true}; + // Which pop it is for: "" any, or "low", "mid", "high" (a trick can have one of each). + std::string pop; + Track* track(std::uint16_t joint) noexcept; + const Track* track(std::uint16_t joint) const noexcept; + std::size_t key_count() const noexcept; +}; +// Puts the animation's edits at `seconds` on `pose` (already holding the base pose). +void apply_edits(const Animation& animation, float seconds, std::span pose) noexcept; +// Sets (adds or replaces) the key of `joint` at `seconds`; keys closer than `merge` are one key. +Key& set_key(Animation& animation, std::uint16_t joint, float seconds, const Offset& value, float merge = 1.0f / 120); +bool remove_key(Animation& animation, std::uint16_t joint, float seconds, float merge = 1.0f / 120); +// Moves a key to `to` (clamped to the clip), keeping the track in time order. Returns its new index. +std::size_t move_key(Track& track, std::size_t index, float to, float duration) noexcept; + +// ---- skeleton ------------------------------------------------------------------------------------- +// The world transform of `joint` composed from the root through its parents (skater_bone_parents), +// `local` being a whole parent-local pose. Uniform scale, as the game's skater has. +Joint world(std::span local, std::uint16_t joint) noexcept; +std::int16_t parent(std::uint16_t joint) noexcept; + +// ---- files ---------------------------------------------------------------------------------------- +// The profile: name, trick, settings and keyframes, as versioned JSON. The clip goes beside it in a +// small binary file. Both throw std::runtime_error on bad input; nothing half-read is returned. +std::string to_json(const Animation& animation); +Animation from_json(std::string_view text); // without the clip +std::vector clip_bytes(const Clip& clip); +Clip clip_from_bytes(std::span bytes); +// A name safe as a file name ("Kickflip 2"): letters, digits, space, - and _ only. +std::string file_safe(std::string_view name); +} // namespace dingosdk::anim_studio diff --git a/Extension/BoardBreak/Test/board_break_tests.cpp b/Extension/BoardBreak/Test/board_break_tests.cpp deleted file mode 100644 index d5653033..00000000 --- a/Extension/BoardBreak/Test/board_break_tests.cpp +++ /dev/null @@ -1,238 +0,0 @@ -// Board Break's rules, apart from the game: what a break leaves of a board, how likely a slam -// breaks it, and where the parts of a broken board lie. -#include "Extension/BoardBreak/board_break_model.h" -#include "Extension/BoardBreak/board_break_feel.h" -#include -#include -#include -#include - -using namespace dingosdk::board_break; -namespace { -void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } -bool near(float a, float b, float within = 1e-4f) { return std::abs(a - b) < within; } -bool near(Vec a, Vec b, float within = 1e-4f) { return length(a - b) < within; } -// Where a point of a part ends up after the part's motion. -Vec moved(const Rigid& motion, Vec point) { return turn(motion.facing, point) + motion.at; } -constexpr Quat level{}; -// The rig's mount: a quarter turn about the upright axis. -const Quat mount = about({0, 1, 0}, 1.5707963f); - -void a_break_leaves_two_pieces_or_one_that_hangs_together() { - for (const auto place : {Place::nose, Place::mid, Place::tail}) { - const auto clean = broken_at(place, false); - check(clean.hinge == Hinge::none && clean.loose, "A clean break has a piece that comes off"); - check((clean.kept | clean.loose) == whole_board && !(clean.kept & clean.loose), "and the two are the whole board, each part once"); - const auto joined = broken_at(place, true); - check(joined.kept == whole_board && !joined.loose && joined.hinge != Hinge::none, "Held by the grip tape, nothing comes off"); - } - check(broken_at(Place::nose, false).loose == nose && broken_at(Place::tail, false).loose == tail, "An end comes off by itself"); - const auto halves = broken_at(Place::mid, false); - check(halves.kept == (nose | front | front_truck) && halves.loose == (back | tail | back_truck), - "In the middle the board keeps its front half, and each half its truck"); - check(broken_at(Place::nose, true).hinge == Hinge::nose && broken_at(Place::mid, true).hinge == Hinge::fold && - broken_at(Place::tail, true).hinge == Hinge::tail, - "and each place hangs together its own way"); -} - -void a_harder_slam_breaks_a_board_more_often() { - check(break_chance(10.0f, 10.0f) == 1.0f && break_chance(30.0f, 10.0f) == 1.0f, "A heavy slam always breaks it"); - check(break_chance(0.0f, 10.0f) == 0.0f && break_chance(6.0f, 10.0f) == 0.0f, "A light one never does"); - check(near(break_chance(8.0f, 10.0f), 0.5f), "and in between it rises smoothly"); - float before{}; - for (float force = 0; force <= 12.0f; force += 0.5f) { - const auto chance = break_chance(force, 10.0f); - check(chance >= before && chance <= 1.0f, "It never falls with more force"); - before = chance; - } -} - -void the_size_of_a_break_picks_its_sound() { - check(break_size(Place::mid, false, 0.0f) == Size::heavy, "A board that comes apart in the middle is the biggest break"); - check(break_size(Place::mid, true, 0.9f) == Size::heavy && break_size(Place::mid, true, 0.4f) == Size::medium, - "A fold right down is as big, a shallow one in between"); - check(break_size(Place::nose, false, 0.0f) == Size::medium && break_size(Place::tail, false, 0.0f) == Size::medium, "An end that comes off is in between"); - check(break_size(Place::nose, true, 0.0f) == Size::light && break_size(Place::tail, true, 0.0f) == Size::light, "One that hangs on is a small one"); -} - -void a_whole_board_keeps_its_stretches_away() { - const auto bones = bones_of({}); - for (std::size_t i = 0; i < 3; ++i) check(near(bones[i].at, away), "A stretch that does not show goes far away"); - check(near(bones[3].at, Vec{}) && near(bones[3].facing.w, 1.0f), "and the tail's, which hangs on the front's, goes with that one"); -} - -void the_tail_is_placed_from_the_front_stretch() { - // The tail's bone hangs on the front stretch's: wherever the two lie, the front's motion - // followed by what goes into the tail's bone is the tail's own motion. - const Rigid front_motion{about({1, 0, 0}, 0.3f), {0.01f, 0.02f, -0.03f}}; - const Rigid tail_motion{unit(about({0, 1, 0}, 1.1f) * about({1, 0, 0}, -0.4f)), {0.5f, -0.2f, 0.7f}}; - for (const bool front_shown : {true, false}) { - std::array stretch{}; - stretch[1] = {front_shown, front_motion}; - stretch[3] = {true, tail_motion}; - const auto bones = bones_of(stretch); - const Rigid parent = front_shown ? front_motion : Rigid{{}, away}; - for (const Vec point : {Vec{0, 0.09f, -0.3f}, Vec{0.1f, 0.1f, -0.4f}}) - check(near(moved(parent, moved(bones[3], point)), moved(tail_motion, point), 2e-3f), "The tail lies where its piece does"); - } -} - -void a_piece_shows_only_its_own_stretches() { - const auto clean = broken_at(Place::mid, false); - const auto layout = lay_out(clean.kept, clean.hinge, 0.0f, 1.0f, mount); - check(layout.stretch[0].shown && layout.stretch[1].shown && !layout.stretch[2].shown && !layout.stretch[3].shown, - "The front half is the nose and the front stretch"); - check(layout.front_truck && !layout.back_truck, "on its front truck"); - const auto bones = bones_of(layout.stretch); - check(near(bones[2].at, away), "and the back stretch is nowhere to be seen"); - // Half a board on one truck tips onto its broken end while it is the right way up, and not on its grip. - const auto broken_end = moved(layout.stretch[1].motion, turn(mount, Vec{0, 0.09f, mid_row})); - check(broken_end.y < 0.09f - 0.03f, "The half tips onto its broken end"); - const auto upside_down = lay_out(clean.kept, clean.hinge, 0.0f, -1.0f, mount); - check(near(upside_down.stretch[1].motion.at, Vec{}), "Lying on its grip it stays as it is"); -} - -void a_folded_board_meets_at_the_break() { - for (const float depth : {0.2f, 0.6f, 1.0f}) { - Rigid front_half, back_half; - folded(depth, front_half, back_half); - // The grip of both halves at the break: the same height, a crack's width apart. - const Vec grip{0, 0.095f, mid_row}; - const auto ahead = moved(front_half, grip), behind = moved(back_half, grip); - check(near(ahead.y, behind.y, 1e-3f), "Both halves' grip is at one height at the break"); - check(near(ahead.z - behind.z, crack, 1e-3f), "with the crack between them"); - check(ahead.y < grip.y, "and the break has sagged"); - // Each half turns about its own axle: the wheels stay where they were, but for the slide along the board. - check(near(moved(front_half, {0, axle_height, front_axle}).y, axle_height, 1e-4f) && - near(moved(back_half, {0, axle_height, back_axle}).y, axle_height, 1e-4f), - "The axles keep their height"); - } - const auto whole = lay_out(whole_board, Hinge::fold, 0.8f, 1.0f, mount); - for (const auto& one : whole.stretch) check(one.shown, "A folded board shows all four stretches"); - check(!near(whole.stretch[1].motion.at, whole.stretch[2].motion.at), "its two halves moved apart"); - check(near(whole.stretch[0].motion.at, whole.stretch[1].motion.at) && near(whole.stretch[2].motion.at, whole.stretch[3].motion.at), - "and each end with its half"); -} - -void a_hanging_end_turns_about_the_break() { - const auto layout = lay_out(whole_board, Hinge::nose, 0.5f, 1.0f, mount); - check(near(layout.stretch[1].motion.at, Vec{}) && near(layout.stretch[2].motion.at, Vec{}) && near(layout.stretch[3].motion.at, Vec{}), - "The rest of the board stays where it is"); - const auto tip = moved(layout.stretch[0].motion, turn(mount, Vec{0, 0.14f, 0.41f})); - check(tip.y < 0.14f - 0.05f, "and the nose droops"); - const auto hinge = moved(hung_on_grip(nose_row, 1.0f, 0.5f), {0, grip_height - 0.004f, nose_row}); - check(near(hinge, Vec{0, grip_height - 0.004f, nose_row + crack * 0.5f}), "It turns about the break line, a hair away from the board"); -} - -void a_truck_goes_with_its_piece() { - // The deck's bone is moved away to hide the real deck, and the truck hangs on it: what goes into - // the truck's bone takes that move back and adds the piece's motion. - const Vec mount_at{0, 0.0856f, 0}; - const auto still = truck_bone({}, mount, mount_at, level, front_truck_place); - check(near(still.at + away, front_truck_place) && near(still.facing.w, 1.0f), "A piece that has not moved leaves its truck where it was"); - const Rigid lifted{{}, {0, 0.25f, 0}}; - const auto up = truck_bone(lifted, mount, mount_at, level, front_truck_place); - check(near(up.at + away, front_truck_place + Vec{0, 0.25f, 0}), "A piece that is lifted takes its truck up with it"); -} -void a_baseplate_stays_on_the_deck_while_its_truck_steers() { - // A baseplate sits on the truck's effector, a child of the truck's bone. Whatever the truck's - // bone does, the truck's bone followed by the effector's is what it is at rest. - for (const auto& rest : {back_truck_rest, front_truck_rest}) { - const auto still = baseplate_bone({rest.facing, rest.at}, rest); - check(near(still.at, rest.effector) && near(std::abs(still.facing.w), 1.0f), "At rest the effector is where the rig has it"); - const Vec bolt{0.03f, 0.08f, rest.at.z + 0.02f}; - const auto on_deck = moved({rest.facing, rest.at}, moved({{}, rest.effector}, bolt)); - for (const float lean : {-0.4f, 0.15f, 0.6f}) { - const Rigid steered{unit(rest.facing * about({0, 0, 1}, lean)), rest.at}; - const auto effector = baseplate_bone(steered, rest); - check(near(moved(steered, moved(effector, bolt)), on_deck, 1e-4f), "Steered, the baseplate has not moved on the deck"); - } - } -} - -void the_settings_keep_to_their_ranges() { - Settings s; - check(set_setting_value(s, "sensitivity", 9999) && s.sensitivity == sensitivity_max, "A percent is kept within its range"); - check(set_setting_value(s, "always", 5) && s.always && setting_value(s, "always") == 1, "a switch is on or off"); - check(set_setting_value(s, "joined", -3) && s.joined == 0, "and nothing goes below its least"); - check(!set_setting_value(s, "nonsense", 1) && setting_value(s, "nonsense") == -1, "An unknown name is no setting"); - for (const auto& info : setting_info) check(setting_value(s, info.name) >= 0, "Every setting has a value"); -} - -void a_worn_or_touchy_board_breaks_sooner() { - check(slam_chance(heavy_slam, 100, 0.0f) == 1.0f, "A heavy slam always breaks a new board"); - check(slam_chance(6.0f, 100, 0.0f) < slam_chance(6.0f, 100, 1.0f), "A worn board breaks from less"); - check(slam_chance(heavy_slam * 0.5f, 200, 0.0f) == 1.0f, "At 200% half the force always breaks it"); - check(slam_chance(8.0f, 50, 0.0f) < slam_chance(8.0f, 100, 0.0f), "at 50% it takes more"); - check(slam_chance(2.0f, 100, 0.0f) == 0.0f && slam_chance(2.0f, 100, 0.6f) == 0.0f, "A stumble never breaks a board that is not done"); - check(slam_chance(0.0f, 400, 1.0f) == 0.0f, "nor does a bail whose force is not known, at any setting"); -} - -void every_hit_wears_a_board() { - check(landing_wear(3.0f) == 0.0f && landing_wear(5.0f) > 0.0f && landing_wear(9.0f) > landing_wear(5.0f), "Harder landings wear more, light ones none"); - check(rail_wear(3.0f, true) > rail_wear(3.0f, false), "A slide wears the deck more than a grind on the trucks"); - check(grind_wear_per_second(true) > grind_wear_per_second(false), "and sliding more than grinding"); - check(slam_wear(2.0f) == 0.0f && slam_wear(12.0f) > slam_wear(7.0f), "A stumble costs nothing, a hard slam a lot"); - check(add_wear(0.5f, 0.1f, 0) == 0.5f && near(add_wear(0.5f, 0.1f, 200), 0.7f) && add_wear(0.95f, 1.0f, 100) == 1.0f, - "Wear goes at the player's rate, and never past worn out"); -} - -void a_worn_board_handles_worse() { - check(handling(0.0f) == Handling{} && handling(0.2f) == Handling{}, "A board under a fifth worn handles as new"); - const auto half = handling(0.6f), done = handling(1.0f); - check(half.steering < 1.0f && done.steering < half.steering && done.spring < half.spring && done.twist > half.twist, - "The more worn, the lazier the turn and the softer the trucks"); - check(done.steering > 0.5f && done.spring > 0.2f, "but it can still be ridden"); - check(!gives_way_on_landing(20.0f, 400, done_wear - 0.01f), "A board that is not done never gives way on a landing"); - check(!gives_way_on_landing(6.0f, 100, done_wear) && gives_way_on_landing(6.5f, 100, done_wear), - "A board just done goes on a big drop"); - check(!gives_way_on_landing(3.0f, 100, 1.0f) && gives_way_on_landing(3.5f, 100, 1.0f), - "one worn right out on any real drop, but not on an ollie on flat ground"); - check(gives_way_on_landing(4.0f, 200, done_wear) && !gives_way_on_landing(4.0f, 100, done_wear), "Sensitivity makes it touchier"); -} - -void a_board_across_its_way_slides() { - const Vec level_up{0, 1, 0}, ahead{0, 0, 1}, across{1, 0, 0}, upside_down{0, -1, 0}, going{0, -1, 5}; - check(contact_of(ahead, level_up, going) == Contact::grind, "Along the rail it grinds on its trucks"); - check(contact_of(across, level_up, going) == Contact::slide, "Across it, it slides on the deck"); - check(contact_of(across, upside_down, going) == Contact::darkslide, "On its grip it is a darkslide"); - check(contact_of(across, level_up, {0, -2, 0.1f}) == Contact::grind, "A board that hardly moves along counts as grinding"); - check(rail_chance(9.0f, Contact::grind, 100, 0.0f) == 0.0f, "A grind on the trucks never snaps the board"); - check(rail_chance(2.0f, Contact::slide, 100, 0.0f) == 0.0f && rail_chance(6.0f, Contact::slide, 100, 0.0f) == 1.0f, - "A hop onto a rail is safe, a drop onto it snaps the board"); - check(rail_chance(3.5f, Contact::darkslide, 100, 0.0f) > rail_chance(3.5f, Contact::slide, 100, 0.0f), "a darkslide sooner"); -} - -void a_board_breaks_where_it_hit() { - check(place_of_break(0.8f, false, 0.1f) == Place::nose && place_of_break(-0.8f, false, 0.1f) == Place::tail, "Nose first at the nose, tail first at the tail"); - check(place_of_break(0.8f, true, 0.1f) == Place::mid, "Across a rail in the middle"); - check(place_of_break(0.0f, false, 0.2f) == Place::mid && place_of_break(0.0f, false, 0.9f) == Place::tail, "Flat mostly in the middle"); - check(body_loudness(2.0f) == 0.0f && body_loudness(body_full) == 1.0f && body_loudness(5.0f) < body_loudness(9.0f), "Harder hits sound louder"); -} -} // namespace - -int main() { - try { - a_break_leaves_two_pieces_or_one_that_hangs_together(); - a_harder_slam_breaks_a_board_more_often(); - the_size_of_a_break_picks_its_sound(); - a_whole_board_keeps_its_stretches_away(); - the_tail_is_placed_from_the_front_stretch(); - a_piece_shows_only_its_own_stretches(); - a_folded_board_meets_at_the_break(); - a_hanging_end_turns_about_the_break(); - a_truck_goes_with_its_piece(); - a_baseplate_stays_on_the_deck_while_its_truck_steers(); - the_settings_keep_to_their_ranges(); - a_worn_or_touchy_board_breaks_sooner(); - every_hit_wears_a_board(); - a_worn_board_handles_worse(); - a_board_across_its_way_slides(); - a_board_breaks_where_it_hit(); - std::cout << "board break tests passed\n"; - return 0; - } catch (const std::exception& error) { - std::cerr << "board break test failed: " << error.what() << '\n'; - return 1; - } -} diff --git a/Extension/BoardBreak/board_break.cpp b/Extension/BoardBreak/board_break.cpp deleted file mode 100644 index 20ff067c..00000000 --- a/Extension/BoardBreak/board_break.cpp +++ /dev/null @@ -1,1338 +0,0 @@ -#include "board_break.h" -#include "board_break_model.h" -#include "Engine/Core/Log/logging.h" -#include "Engine/Core/Platform/memory.h" -#include "Engine/Game/Build/20260929/no_bail.h" -#include "Engine/Vfs/builtin_packs.h" -#include "Engine/Vfs/mod_catalog.h" -#include "Extension/Assets/live_mods.h" -#include "Extension/Multiplayer/Remote/native_pose_layout.h" -#include "Extension/Multiplayer/Remote/native_skater.h" -#include "Extension/Profile/local_profile_runtime.h" -#include "Extension/Skater/no_bail.h" -#include "board_break_audio.h" -#include "Engine/Game/Build/addresses.h" -#include "Engine/Game/Build/20260929/skater_state.h" -#include "Extension/HallOfMeat/hall_of_meat_model.h" -#include "Extension/HallOfMeat/hall_of_meat_skater.h" -#include "Extension/Multiplayer/Remote/native_vfx.h" -#include "Extension/Multiplayer/Session/session.h" -#include "Extension/Progression/script_natives.h" -#include "Extension/Skater/physics_tuning.h" -#include "Extension/UI/Overlay/overlay.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#pragma comment(lib, "ole32.lib") -#pragma comment(lib, "shell32.lib") - -namespace dingosdk::board_break { -namespace { -constexpr const char* enabled_preference = "BoardBreak"; -// A slam of `heavy` force or more always breaks the board, one under 60% of it never, and the -// chance rises smoothly in between (break_chance). Half of all breaks stay joined by the grip tape. -// A board that is done gives way under the rider on a hard enough landing: the nose goes. -// (board_break_feel.h holds the forces, the chances and what wear does.) -namespace sstate = addr::skater_state; -constexpr std::size_t cause_count = 35; // the collector's cause bytes (+0x28) and forces (+0x4c) -constexpr std::uint64_t recent_ms = 1500; // a cause belongs to a wipeout this soon before it -// The board is back in the skater's hand: close to the skater and not lying flat. -constexpr float hand_reach = 1.3f, hand_upright = 0.8f; -// The board rig's pose, in the order the game evaluates it: 1 the rig's place in the world, 2 the -// deck's mount, 3 the deck, 4 and 8 the trucks, and the spare bones that carry the four stretches -// (nose, front, back, tail; the tail's hangs on the front's). -constexpr int root_bone = 1, mount_bone = 2, deck_bone = 3, back_truck_bone = 4, front_truck_bone = 8; -// The trucks' effectors, each right after its truck's bone: a deck pack's truck model has the -// baseplates on them (board_break_model.h, baseplate_bone). -constexpr int back_plate_bone = 5, front_plate_bone = 9; -constexpr std::array carrier{12, 13, 15, 14}; -constexpr std::uint32_t rig_bones = 16; -// The wheels, each on its truck's bone: back right and left, front right and left. -constexpr std::array wheel_bones{6, 7, 10, 11}; - -bool readable(std::uintptr_t p, void* out, std::size_t size) { return size ? memory::peek_bytes(p, out, size) : p >= 0x10000; } - -template void say(logging::Level level, std::format_string text, Args&&... args) { - logging::log(level, logging::Channel::skater, "Board Break: {}", std::format(text, std::forward(args)...)); -} - -// Written by the physics step, read by the client tick. -struct Hit { - std::atomic at{}; - std::atomic reason{-1}; - std::atomic force{}; -}; -Hit& last_cause() { static Hit value; return value; } -Hit& last_wipeout() { static Hit value; return value; } - -struct Broken { - std::uintptr_t board{}; - std::uint64_t since{}; - bool gave_way{}; // it went under the rider: it stays broken while it is ridden - bool asked{}; // it was broken from the console, to be looked at: it stays until it is mended there - std::string deck; // the deck it was: the one that gets a new board -}; -struct State { - // Any thread. - std::atomic ready{}, on{true}, wanted_mend{}, turned_off{}; - std::atomic base{}; - std::atomic has_deck{}; // one of Board Break's decks is the one in the game - std::atomic wanted_snap{-1}; // asked for from another thread, done at the next tick - std::mutex names; - std::string deck, wanted_deck; // under `names`: the deck in the game, and one asked for (done at the next tick) - // Client thread from here on. - std::optional broken; - Break parts; // what the broken board keeps, what flew off, how they hang together - float angle{}; // how far a hanging end droops (radians), or how deep a fold sags (0 to 1) - std::uint32_t flights{}; // counts the breaks, so each starts a flight of its own - bool counted{}, hooked{}; - std::uint64_t wipeouts{}, back_since{}, last_error{}, board_seen{}; - // The ground under the board: its own height while it rolls on it, and what of that was fresh - // when the board broke (else not a number). - float ride_height{}; - std::uint64_t ride_when{}; - float break_ground{std::numeric_limits::quiet_NaN()}; - std::mt19937 random{std::random_device{}()}; - // The player's settings as the physics thread reads them (feel() holds the real ones). - std::atomic always{}, rails{true}, wear_on{true}, body_sounds{true}; - std::atomic sensitivity{100}; - // Wear: the board in the game's (for the physics thread), and what the physics thread has - // added since the tick took it. - std::atomic wear_now{}, wear_added{}; - // A board that came down too hard onto a rail, seen by the physics thread: the tick breaks it. - std::atomic rail_snap{}; - std::atomic landing_give{}; // the physics thread saw a done board land too hard - std::atomic landing_speed{}; - std::atomic rail_contact{}; - std::atomic rail_speed{}; - // The board's long axis (towards the nose) and its up axis in the world, from the tick. - std::mutex axes_mutex; - Vec along{}, up{}; - std::uint64_t axes_at{}; - // Client thread: the tuning set for wear, and when; when the settings were last saved. - std::uint64_t handled_at{}, saved_at{}, session_checked{}; - bool in_session{}, setup_read{}, effects_hooked{}; -}; -State& state() { - static auto* value = new State; - return *value; -} - -// What the render pose hook does to the boards, published by the client tick. -struct RenderEdit { - std::atomic holder{}; // the local board's animation holder - std::atomic kept{}; // the piece it shows (Piece bits), 0 while it is whole - std::atomic hinge{}; - std::atomic angle{}; - std::atomic park{}; // a breakable deck is in the game: whole boards hide their stretches - // The deck's own trucks (deck.ini: axle): how far apart the wheels sit against a standard board's. - std::atomic axle{1.0f}; -}; -RenderEdit& render_edit() { static RenderEdit value; return value; } - -// ---- the board's pose -------------------------------------------------------------------------- -// One bone: scale, rotation and position, four floats each. -struct PoseBone { - float scale[4], rotation[4], position[4]; -}; -bool load_bone(std::uintptr_t address, PoseBone& out) noexcept { - __try { - std::memcpy(&out, reinterpret_cast(address), sizeof(out)); - return true; - } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } -} -// Rotation and position of one bone, at its own size; the fourth lane of each is left as it is. -bool store_bone(std::uintptr_t address, Quat rotation, Vec position) noexcept { - __try { - auto* lane = reinterpret_cast(address); - lane[0] = lane[1] = lane[2] = 1.0f; - lane[4] = rotation.x; lane[5] = rotation.y; lane[6] = rotation.z; lane[7] = rotation.w; - lane[8] = position.x; lane[9] = position.y; lane[10] = position.z; - return true; - } __except (EXCEPTION_EXECUTE_HANDLER) { return false; } -} -Quat facing_of(const PoseBone& bone) noexcept { return unit({bone.rotation[0], bone.rotation[1], bone.rotation[2], bone.rotation[3]}); } -Vec place_of(const PoseBone& bone) noexcept { return {bone.position[0], bone.position[1], bone.position[2]}; } -// The pose buffer of a board, or 0 when it is not the rig this knows. -std::uintptr_t board_pose(std::uintptr_t base, std::uintptr_t holder) { - const auto pose = multiplayer::read_native_pose_layout(readable, base, holder, 64); - return pose.buffer && pose.count == rig_bones ? pose.buffer : 0; -} -std::uintptr_t bone_at(std::uintptr_t buffer, int index) noexcept { return buffer + static_cast(index) * 0x30ULL; } -// The board's place in the world as its pose has it. -bool board_place(std::uintptr_t base, std::uintptr_t holder, Vec& at, Quat& facing) { - const auto buffer = board_pose(base, holder); - PoseBone root{}; - if (!buffer || !load_bone(bone_at(buffer, root_bone), root)) return false; - at = place_of(root); - facing = facing_of(root); - return std::isfinite(at.x) && std::isfinite(at.y) && std::isfinite(at.z); -} -// Moves a truck with a piece (truck_bone). -void carry_truck(std::uintptr_t buffer, const Rigid& motion, int index, Vec place) noexcept { - PoseBone mount{}, truck{}; - if (!load_bone(bone_at(buffer, mount_bone), mount) || !load_bone(bone_at(buffer, index), truck)) return; - const auto bone = truck_bone(motion, facing_of(mount), place_of(mount), facing_of(truck), place); - (void)store_bone(bone_at(buffer, index), bone.facing, bone.at); -} - -// Keeps the baseplates on the deck (baseplate_bone), from each truck's bone as the animation left -// it: a truck that a piece has carried off already, in a pose nobody has written since, is left. -void hold_baseplates(std::uintptr_t buffer) noexcept { - const auto hold = [&](int truck_index, int plate_index, const TruckRest& rest) { - PoseBone truck{}; - if (!load_bone(bone_at(buffer, truck_index), truck)) return; - const Rigid now{facing_of(truck), place_of(truck)}; - if (!(length(now.at - rest.at) < 0.05f)) return; - const auto bone = baseplate_bone(now, rest); - (void)store_bone(bone_at(buffer, plate_index), bone.facing, bone.at); - }; - hold(back_truck_bone, back_plate_bone, back_truck_rest); - hold(front_truck_bone, front_plate_bone, front_truck_rest); -} - -// A deck's own trucks (deck.ini: axle): the wheels further apart on a wider deck, closer together on -// a narrower one. The game puts each wheel on its truck's bone along the axle, about 0.0876 m out; -// that place is set here rather than scaled, so a pose that comes by twice is not moved twice. Only -// what is drawn changes: the board rides as it always does. -constexpr float wheel_offset = 0.0876f; -void spread_wheels(std::uintptr_t buffer) noexcept { - const float axle = render_edit().axle.load(std::memory_order_relaxed); - if (axle == 1.0f) return; - for (const int index : wheel_bones) { - PoseBone wheel{}; - if (!load_bone(bone_at(buffer, index), wheel)) continue; - int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits - for (int i = 1; i < 3; ++i) - if (std::abs(wheel.position[i]) > std::abs(wheel.position[way])) way = i; - const float out = std::abs(wheel.position[way]); - if (!(out > 0.05f && out < 0.16f)) continue; // not where the game puts a wheel - __try { - reinterpret_cast(bone_at(buffer, index))[8 + way] = std::copysign(wheel_offset * axle, wheel.position[way]); - } __except (EXCEPTION_EXECUTE_HANDLER) {} - } -} - -// ---- the piece that comes off -------------------------------------------------------------------- -// The client tick starts a flight (a new id) when the board breaks; the render pose hook moves the -// piece through the world from there (a kick, gravity, the ground) and writes it relative to -// wherever the board itself is in that frame, so the two part company although both are the one board. -struct Flight { - std::mutex mutex; - // From the tick. - std::uint32_t id{}; // changes with every break; 0 = no piece in flight - std::uint32_t mask{}; // the piece's stretches of deck and its trucks - float ground{}; // world height of the ground under the skater - float rest{}; // how far above the ground the piece's origin lies once it rests - Vec kick{}, spin{}; // start speed (m/s) and turning (axis x rad/s), in the deck's own axes - float ridden{std::numeric_limits::quiet_NaN()}; // the ground the board rolled on as it broke - // The hook's. - std::uint32_t running{}; - std::int64_t last{}; - Vec at{}, speed{}, turning{}; - Quat facing{}; - bool landed{}; // lying on what is taken for the ground right now - bool settled{}; // has landed once: flat from then on, whichever side came up - bool upside_down{}; - Vec from{}; // where the board was when it broke - float limit{}; // the tick's first guess of the ground: never taken higher than this - float tipped{}; // 0..1: how far a half on one truck has tipped onto its broken end -}; -Flight& flight() { static Flight value; return value; } -// The stretches of deck that are in flight (0 for none) and, in `piece`, where they are from the -// board's own place; the piece's trucks are moved here. -std::uint32_t fly_piece(std::uintptr_t buffer, Rigid& piece) { - auto& f = flight(); - std::unique_lock lock(f.mutex, std::try_to_lock); - if (!lock.owns_lock() || !f.id) return 0; - PoseBone root{}, mount{}; - if (!load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return 0; - const Vec board_at = place_of(root); - const Quat board_facing = facing_of(root), deck_turn = facing_of(mount); - LARGE_INTEGER now{}; - QueryPerformanceCounter(&now); - static const double frequency = [] { LARGE_INTEGER value{}; QueryPerformanceFrequency(&value); return static_cast(value.QuadPart); }(); - if (f.running != f.id) { - f.running = f.id; - f.last = now.QuadPart; - f.at = f.from = board_at; - f.facing = board_facing; - f.speed = turn(board_facing, turn(deck_turn, f.kick)); - f.turning = turn(board_facing, turn(deck_turn, f.spin)); - f.landed = f.settled = f.upside_down = false; - f.limit = f.ground; - f.tipped = 0; - } - const auto dt = static_cast(std::clamp((now.QuadPart - f.last) / frequency, 0.0, 0.05)); - f.last = now.QuadPart; - // The ground is where the board itself lies: its lowest point right now, which physics brings - // to rest on the ground. The skater's height at the slam (the hips, and the skater may be in the - // air) is only an upper limit. It is the board's height NOW, not the lowest ever seen: one odd - // pose (a teleport, a frame before the board is placed) must not sink the piece for good, and a - // pose that is nowhere near the break is passed over altogether. - const bool plausible = std::isfinite(board_at.x) && std::isfinite(board_at.y) && std::isfinite(board_at.z) && length(board_at - f.from) < 30.0f; - float lowest = board_at.y + 1.0f; - if (plausible) { - for (const Vec corner : {Vec{0, 0, 0}, Vec{0, 0.093f, 0}, Vec{0, 0.141f, 0.41f}, Vec{0, 0.136f, -0.40f}, Vec{0.105f, 0.10f, 0}, - Vec{-0.105f, 0.10f, 0}, Vec{0.0876f, 0, front_axle}, Vec{-0.0876f, 0, front_axle}, Vec{0.0876f, 0, back_axle}, - Vec{-0.0876f, 0, back_axle}}) - lowest = (std::min)(lowest, (board_at + turn(board_facing, turn(deck_turn, corner))).y); - // Only a board that is still near the piece says anything about the ground under the piece. - if (std::hypot(board_at.x - f.at.x, board_at.z - f.at.z) < 4.0f) { - // The ground the board rolled on as it broke is exact, where its lowest point depends on - // how it has come to lie and on what: that height stays, unless the board has plainly - // dropped below it since (off a ledge, down a stair). - if (std::isfinite(f.ridden)) f.ground = lowest < f.ridden - 0.15f ? lowest : f.ridden; - else f.ground = (std::min)(f.limit, lowest); - } - } - // Where the piece's origin is once it lies on the ground: on its grip when it is upside down, - // else on its underside or its wheels. A little above it: real ground is rough, and a deck is a - // centimetre thick. A gap under a piece goes unseen, a piece cut off by the ground does not. - const float lift = f.mask & (front_truck | back_truck) ? 0.004f : 0.012f; - const auto lying = [&] { - const bool flipped = f.settled ? f.upside_down : turn(f.facing, {0, 1, 0}).y < 0; - return (flipped ? f.ground + 0.141f : f.ground + f.rest) + lift; // upside down it rests on the raised end of its grip side - }; - if (f.landed && f.at.y > lying() + 0.01f) { - f.landed = false; // the ground turned out to be lower: it drops the rest of the way - f.speed = {}; - f.turning = {}; - } - if (!f.landed) { - f.speed.y -= 9.81f * dt; - f.at = f.at + f.speed * dt; - if (const auto rate = length(f.turning); rate > 1e-4f) f.facing = unit(about(f.turning * (1.0f / rate), rate * dt) * f.facing); - if (f.speed.y <= 0 && f.at.y <= lying()) { // on its way down: it starts on the ground it will land on - if (!f.settled) { - // Down: lying flat, pointing the way it was, the same side up. - const auto ahead = turn(f.facing, {0, 0, 1}); - f.upside_down = turn(f.facing, {0, 1, 0}).y < 0; - f.facing = about({0, 1, 0}, std::atan2(ahead.x, ahead.z)); - if (f.upside_down) f.facing = unit(f.facing * about(turn(deck_turn, {0, 0, 1}), 3.14159265f)); // rolled over, about its length - f.speed = {f.speed.x * 0.3f, 0, f.speed.z * 0.3f}; - f.settled = true; - } else f.speed = {}; - f.at.y = lying(); - f.landed = true; - } - } else { - f.at = f.at + f.speed * dt; // a short slide - f.speed = f.speed * (std::max)(0.0f, 1.0f - 7.0f * dt); - f.at.y = lying(); // and it stays on the ground if that comes up again - } - // A half board that came down on its wheels tips onto its broken end. - const bool on_one_truck = (f.mask & (back_truck | front_truck)) && f.settled && !f.upside_down; - f.tipped = on_one_truck && f.landed ? (std::min)(1.0f, f.tipped + dt / 0.18f) : on_one_truck ? f.tipped : 0.0f; - const auto tip = on_root(deck_turn, f.mask & back_truck ? tipped_on_axle(back_axle, 1.0f, back_tip * f.tipped) - : tipped_on_axle(front_axle, -1.0f, front_tip * f.tipped)); - // Where that is from the board's own place. - const auto back_to_board = conj(board_facing); - const auto lies_facing = unit(back_to_board * f.facing); - const auto lies_at = turn(back_to_board, f.at - board_at); - piece = {unit(lies_facing * tip.facing), lies_at + turn(lies_facing, tip.at)}; - if (f.mask & back_truck) carry_truck(buffer, piece, back_truck_bone, back_truck_place); - if (f.mask & front_truck) carry_truck(buffer, piece, front_truck_bone, front_truck_place); - return f.mask & stretches; -} - -// ---- what the hook writes ------------------------------------------------------------------------ -// The local board, broken: the real deck goes away, the trucks come back (their baseplates with -// them), each stretch goes to where its piece lies and one that belongs to no piece goes away too. -void draw_broken(std::uintptr_t base, std::uintptr_t holder, std::uint32_t kept, Hinge hinge, float angle) { - const auto buffer = board_pose(base, holder); - PoseBone root{}, mount{}, deck{}; - if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount) || - !load_bone(bone_at(buffer, deck_bone), deck)) - return; - hold_baseplates(buffer); - spread_wheels(buffer); - // From where its animation put it this frame; a pose nobody has written since the last move stays as it is. - if (const Vec at = place_of(deck); length(at) < 100.0f) (void)store_bone(bone_at(buffer, deck_bone), facing_of(deck), at + away); - auto layout = lay_out(kept, hinge, angle, turn(facing_of(root), {0, 1, 0}).y, facing_of(mount)); - if (layout.back_truck) carry_truck(buffer, layout.back_motion, back_truck_bone, back_truck_place); - if (layout.front_truck) carry_truck(buffer, layout.front_motion, front_truck_bone, front_truck_place); - if (hinge == Hinge::none) { - Rigid piece{}; - const auto flying = fly_piece(buffer, piece); - for (std::size_t i = 0; i < layout.stretch.size(); ++i) - if (flying & (1u << i)) layout.stretch[i] = {true, piece}; - } - const auto bones = bones_of(layout.stretch); - for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at); -} -// A whole board, any player's: its four stretches go far away from it, and its baseplates sit on -// the deck. -void park_pieces(std::uintptr_t base, std::uintptr_t holder) { - const auto buffer = board_pose(base, holder); - if (!buffer) return; - hold_baseplates(buffer); - spread_wheels(buffer); - const auto bones = bones_of({}); - for (std::size_t i = 0; i < bones.size(); ++i) (void)store_bone(bone_at(buffer, carrier[i]), bones[i].facing, bones[i].at); -} - -// ---- the player's settings, the deck's setup, wear ------------------------------------------------- -// Kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings, a line each: the settings by name (as the -// console knows them), each deck's wear (wear.=0.37), the materials marked as metal. -struct Feel { - std::mutex mutex; - Settings settings; - std::map wear; // by deck - std::set metal; // marked by the player - std::set pack_metal; // a pack's list (sounds/metal/materials.txt) - bool loaded{}, dirty{}; -}; -Feel& feel() { - static auto* value = new Feel; - return *value; -} -std::filesystem::path settings_path() { - PWSTR raw{}; - std::filesystem::path result; - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &raw)) && raw) - result = std::filesystem::path(raw) / L"ReSkate" / L"BoardBreak.settings"; - if (raw) CoTaskMemFree(raw); - return result; -} -std::string trimmed(std::string_view text) { - while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); - while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); - return std::string(text); -} -template bool number(std::string_view text, T& out) { - const auto parsed = std::from_chars(text.data(), text.data() + text.size(), out); - return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(); -} -// Numbers out of a line of them ("12 40, 77"). -std::set numbers_in(std::string_view text) { - std::set out; - std::string one; - const auto take = [&] { - unsigned value{}; - if (!one.empty() && number(std::string_view(one), value) && value <= 0xffff) out.insert(static_cast(value)); - one.clear(); - }; - for (const char c : text) { - if (c >= '0' && c <= '9') one += c; - else take(); - } - take(); - return out; -} -// Under the lock. -void load_feel(Feel& f) { - if (f.loaded) return; - f.loaded = true; - try { - std::ifstream in(settings_path()); - std::string line; - while (std::getline(in, line)) { - const auto eq = line.find('='); - if (eq == std::string::npos) continue; - const auto key = trimmed(std::string_view(line).substr(0, eq)); - const auto value = trimmed(std::string_view(line).substr(eq + 1)); - if (key.starts_with("wear.")) { - float worn{}; - if (number(std::string_view(value), worn) && std::isfinite(worn)) f.wear[key.substr(5)] = std::clamp(worn, 0.0f, 1.0f); - } else if (key == "metal") f.metal = numbers_in(value); - else if (int setting{}; number(std::string_view(value), setting)) (void)set_setting_value(f.settings, key, setting); - } - } catch (...) {} -} -// Written on a thread of its own: this is called from the client tick every few seconds while -// wear changes, and a write (a folder lookup, a file truncated and written, whatever the virus -// scanner does on close) held the game's frame up (2026-10-15). The text is taken under the lock; -// the writes are kept in order by their own. -void save_feel(Feel& f) { - try { - std::ostringstream out; - for (const auto& info : setting_info) out << info.name << "=" << setting_value(f.settings, info.name) << "\n"; - for (const auto& [deck, worn] : f.wear) out << "wear." << deck << "=" << std::format("{:.4f}", worn) << "\n"; - if (!f.metal.empty()) { - out << "metal="; - for (const auto m : f.metal) out << m << " "; - out << "\n"; - } - f.dirty = false; - std::thread([text = out.str()] { - static std::mutex writing; - std::lock_guard lock(writing); - try { - const auto path = settings_path(); - if (path.empty()) return; - std::error_code error; - std::filesystem::create_directories(path.parent_path(), error); - std::ofstream file(path, std::ios::trunc); - file << text; - } catch (...) {} - }).detach(); - } catch (...) {} -} -// The settings into the atomics the physics thread reads. -void mirror(State& s, const Settings& st) { - s.always.store(st.always, std::memory_order_relaxed); - s.rails.store(st.rails, std::memory_order_relaxed); - s.wear_on.store(st.wear, std::memory_order_relaxed); - s.body_sounds.store(st.body_sounds, std::memory_order_relaxed); - s.sensitivity.store(st.sensitivity, std::memory_order_relaxed); -} -Settings settings_now() { - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - return f.settings; -} -std::string deck_now(State& s) { - std::lock_guard lock(s.names); - return s.deck; -} - -// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's; -// more on a wider deck) and `label`, its name in the menus. -struct DeckSetup { - float axle = 1.0f; - std::string label; -}; -DeckSetup read_deck_setup(std::string_view deck) { - DeckSetup out; - if (deck.empty()) return out; - try { - const auto folder = mods::board_break_deck_folder(deck); - if (folder.empty()) return out; - std::ifstream in(folder / L"deck.ini"); - std::string line; - while (std::getline(in, line)) { - if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash); - const auto eq = line.find('='); - if (eq == std::string::npos) continue; - auto key = trimmed(std::string_view(line).substr(0, eq)); - for (auto& c : key) c = static_cast(std::tolower(static_cast(c))); - const auto value = trimmed(std::string_view(line).substr(eq + 1)); - float amount{}; - if (key == "axle" && number(std::string_view(value), amount) && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f); - else if (key == "label") out.label = value; - } - } catch (...) {} - return out; -} - -// The same, kept a minute per deck: the menus ask every frame. -DeckSetup setup_of(std::string_view deck) { - struct Kept { - DeckSetup setup; - std::uint64_t at{}; - }; - static std::mutex mutex; - static std::map> kept; - const auto now = GetTickCount64(); - { - std::lock_guard lock(mutex); - // (A minute: each look lists every mod's folder, and the world model asked every few seconds - // on the client thread while a breakable deck was out.) - if (const auto at = kept.find(deck); at != kept.end() && now - at->second.at < 60000) return at->second.setup; - } - auto setup = read_deck_setup(deck); - std::lock_guard lock(mutex); - kept[std::string(deck)] = {setup, now}; - return setup; -} - -// ---- sounds --------------------------------------------------------------------------------------- -// Out of the `sounds` folder of one of Board Break's deck mods: light, medium and heavy for the -// crack of a break that size, metal for a board breaking on metal, body for the body's hits in a -// bail; as many wave files in each as one likes, under any names. A crack size with none of its own -// takes the nearest that has some, and wave files in the folder itself serve when no subfolder does. -// sounds/metal/materials.txt may list the level's materials that are metal. -void load_sounds() { - static std::once_flag once; - std::call_once(once, [] { - const auto wavs = [](const std::filesystem::path& folder) { - std::vector found; - std::error_code failed; - for (std::filesystem::directory_iterator at(folder, failed), end; !failed && at != end; at.increment(failed)) { - auto extension = at->path().extension().wstring(); - for (auto& c : extension) c = static_cast(towlower(c)); - if (at->is_regular_file(failed) && extension == L".wav") found.push_back(at->path()); - } - std::sort(found.begin(), found.end()); - return found; - }; - for (const auto& mod : mods::board_break_folders()) { - std::error_code failed; - const auto folder = mod / L"sounds"; - if (!std::filesystem::is_directory(folder, failed)) continue; - const auto loose = wavs(folder); - constexpr std::array, 5> kinds{{{audio::Kind::light, L"light"}, {audio::Kind::medium, L"medium"}, - {audio::Kind::heavy, L"heavy"}, {audio::Kind::metal, L"metal"}, {audio::Kind::body, L"body"}}}; - for (const auto& [kind, name] : kinds) { - auto files = wavs(folder / name); - if (files.empty() && kind <= audio::Kind::heavy) files = loose; - audio::load(kind, files); - } - try { - std::ifstream in(folder / L"metal" / L"materials.txt"); - std::string line, all; - while (std::getline(in, line)) - if (const auto hash = line.find('#'); hash != 0) all += " " + line.substr(0, hash); - auto& f = feel(); - std::lock_guard lock(f.mutex); - f.pack_metal = numbers_in(all); - } catch (...) {} - break; - } - }); -} -void crack_sound(Size size) { - if (!settings_now().crack_sounds) return; - // Nearest first: a medium break without sounds of its own takes a heavy one before a light one. - static constexpr std::array, 3> order{{{audio::Kind::light, audio::Kind::medium, audio::Kind::heavy}, - {audio::Kind::medium, audio::Kind::heavy, audio::Kind::light}, - {audio::Kind::heavy, audio::Kind::medium, audio::Kind::light}}}; - for (const auto kind : order[static_cast(size)]) - if (audio::has(kind)) return audio::play(kind, 1.0f); -} - -// ---- what a board broke on -------------------------------------------------------------------------- -// The material of the level the board touched most in the last half second, near `at`: the game's -// own contact effects say (Extension/Multiplayer/Remote/native_vfx.h). -std::optional material_near(Vec at, std::uint64_t within_ms, float reach) { - std::map count; - for (const auto& one : multiplayer::recent_local_impacts(within_ms)) { - const auto& p = one.impact.position; - if (std::hypot(p[0] - at.x, p[1] - at.y, p[2] - at.z) <= reach) ++count[one.impact.material]; - } - std::optional best; - int most{}; - for (const auto& [material, times] : count) - if (times > most) best = material, most = times; - return best; -} -bool is_metal(std::uint16_t material) { - auto& f = feel(); - std::lock_guard lock(f.mutex); - return f.metal.contains(material) || f.pack_metal.contains(material); -} - -// ---- wear ---------------------------------------------------------------------------------------- -// Client thread: what the physics thread added goes onto the deck in the game's board. -void take_wear(State& s, const std::string& deck, float extra = 0.0f) { - const float added = s.wear_added.exchange(0.0f, std::memory_order_acq_rel) + extra; - if (deck.empty() || added <= 0.0f) return; - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - auto& worn = f.wear[deck]; - const auto before = worn; - worn = add_wear(worn, added, f.settings.wear_rate); - s.wear_now.store(worn, std::memory_order_relaxed); - if (worn - before >= 0.01f) say(logging::Level::debug, "a hard hit: the board is {:.1f}% worn now (+{:.1f}).", worn * 100.0f, (worn - before) * 100.0f); - if (worn != before) f.dirty = true; - if (condition_name(before) != condition_name(worn)) { - say(logging::Level::info, "the {} board is {} now ({:.0f}% worn).", deck_label(deck), condition_name(worn), worn * 100.0f); - // Told as it gets close, so a break that follows is no surprise. - if (condition_name(worn) == "beat") - overlay::notify(overlay::NoticeLevel::info, "Your board is beat", - "It breaks from smaller slams now. New board: ReSkate menu (Insert) > Addons > Board Break."); - else if (condition_name(worn) == "done") - overlay::notify(overlay::NoticeLevel::warning, "Your board is done", - "The next big drop snaps it. New board: ReSkate menu (Insert) > Addons > Board Break."); - } -} -// A board that broke is a new one once it is whole again: no wear, no scratches. -void new_board(State& s, const std::string& deck) { - if (deck.empty()) return; - auto& f = feel(); - { - std::lock_guard lock(f.mutex); - load_feel(f); - f.wear[deck] = 0.0f; - f.dirty = true; - } - if (deck == deck_now(s)) { - s.wear_now.store(0.0f, std::memory_order_relaxed); - s.wear_added.store(0.0f, std::memory_order_relaxed); - profile_runtime::reset_board_wear(); - } -} -void add_physics_wear(State& s, float amount) { - if (!(amount > 0.0f)) return; - float current = s.wear_added.load(std::memory_order_relaxed); - while (!s.wear_added.compare_exchange_weak(current, current + amount, std::memory_order_acq_rel)) {} -} -// What wear does to the board's handling: four values of the game's physics tuning, the game's own -// value times handling()'s factor. A value the player changed (the trainer) is left alone; outside -// a multiplayer session only, which shares tuning between its players. -struct Tuned { - bool tried{}, known{}, wrote{}; - std::array offset{}; - std::array game{}, written{}; -}; -Tuned& tuned() { - static Tuned value; - return value; -} -void apply_handling(std::uintptr_t base, std::uintptr_t entity, const Handling& want) { - auto& t = tuned(); - if (!t.known) { - physics_tuning::prepare(); - const auto model = physics_tuning::game_tuning(); - if (!model) return; - constexpr std::array names{"PhysicsSteering.GeneralScalar", "PhysicsTrucks.StaticTruckSpringStrength", - "PhysicsTrucks.StaticTruckDamping", "PhysicsTrucks.TruckTwistLimit"}; - std::size_t found{}; - for (std::size_t i = 0; i < model->fields.size() && i < model->field_names.size(); ++i) - for (std::size_t k = 0; k < names.size(); ++k) - if (model->field_names[i] == names[k] && model->fields[i].real && model->fields[i].offset + 4u <= model->image.size()) { - t.offset[k] = model->fields[i].offset; - std::memcpy(&t.game[k], model->image.data() + t.offset[k], 4); - ++found; - } - if (!t.tried) { - t.tried = true; - if (found != names.size()) say(logging::Level::warning, "wear cannot change the handling: {} of the 4 tuning values found.", found); - } - if (found != names.size()) return; - t.known = true; - } - physics_tuning::Values live; - if (!physics_tuning::read_live(base, live)) return; - const std::array factor{want.steering, want.spring, want.damping, want.twist}; - bool change{}; - for (std::size_t k = 0; k < factor.size(); ++k) { - if (t.offset[k] + 4u > live.image.size()) return; - float now{}; - std::memcpy(&now, live.image.data() + t.offset[k], 4); - const auto same = [](float a, float b) { return std::abs(a - b) <= 1e-4f * (std::max)(1.0f, std::abs(b)); }; - if (!same(now, t.game[k]) && !(t.wrote && same(now, t.written[k]))) continue; - const float target = t.game[k] * factor[k]; - t.written[k] = target; - if (!same(now, target)) { - std::memcpy(live.image.data() + t.offset[k], &target, 4); - change = true; - } - } - t.wrote = true; - if (change) (void)physics_tuning::write_live(base, entity, live); -} - -// ---- the board's axes, for the physics thread ------------------------------------------------------- -void note_axes(State& s, std::uintptr_t base, std::uintptr_t holder) { - const auto buffer = board_pose(base, holder); - PoseBone root{}, mount{}; - if (!buffer || !load_bone(bone_at(buffer, root_bone), root) || !load_bone(bone_at(buffer, mount_bone), mount)) return; - const auto facing = unit(facing_of(root) * facing_of(mount)); - std::lock_guard lock(s.axes_mutex); - s.along = turn(facing, {0, 0, 1}); - s.up = turn(facing, {0, 1, 0}); - s.axes_at = GetTickCount64(); -} -bool board_axes(State& s, Vec& along, Vec& up) { - std::lock_guard lock(s.axes_mutex); - if (!s.axes_at || GetTickCount64() - s.axes_at > 250) return false; - along = s.along; - up = s.up; - return true; -} - -// ---- every physics step of the local skater (No Bail's step listeners) ------------------------------ -// The board's root body velocity (as the grind exit control reads it). -bool board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { - std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; - std::uint32_t count{}; - if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || - !memory::peek(holder + sstate::board_physics_offset, physics) || !memory::peek(physics, type) || - type != skater.base + sstate::board_physics_vtable || !memory::peek(physics + sstate::physics_bodies_offset, bodies) || - !memory::peek(bodies, count) || count != sstate::board_body_count) - return false; - const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; - if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity)) - return false; - return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); -} -struct StepWatch { - bool was_air{}; - float air_down{}; // how fast the board was coming down, in its last step in the air - std::array body_at{}; - std::uint64_t any_at{}; -}; -void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { - auto& s = state(); - if (!s.ready.load(std::memory_order_acquire) || !s.on.load(std::memory_order_relaxed)) return; - static StepWatch w; // the physics thread's own - thread_local std::mt19937 roll{std::random_device{}()}; - const auto error = GetLastError(); - std::uint32_t physics{}; - if (memory::peek(skater.context + sstate::physics_state_offset, physics)) { - std::array v{}; - const bool known = board_velocity(skater, v); - const bool air = physics == 200 || physics == 201; - const bool grinding = physics >= 400 && physics < 500; - const bool rolling = physics >= 100 && physics < 200; - const bool wear_on = s.wear_on.load(std::memory_order_relaxed); - Vec along{}, up{}; - const auto contact = known && board_axes(s, along, up) ? contact_of(along, up, {v[0], v[1], v[2]}) : Contact::grind; - if (air && known) w.air_down = -v[1]; - if (w.was_air && (rolling || grinding)) { - const float down = w.air_down; - if (wear_on) add_physics_wear(s, rolling ? landing_wear(down) : rail_wear(down, contact != Contact::grind)); - const bool can_break = s.has_deck.load(std::memory_order_relaxed) && !s.rail_snap.load(std::memory_order_acquire); - // A done board gives way on a landing hard enough for how worn it is. - if (can_break && rolling && wear_on && !s.landing_give.load(std::memory_order_acquire) && - gives_way_on_landing(down, s.sensitivity.load(std::memory_order_relaxed), s.wear_now.load(std::memory_order_relaxed))) { - s.landing_speed.store(down, std::memory_order_relaxed); - s.landing_give.store(true, std::memory_order_release); - } - if (can_break && grinding && contact != Contact::grind && s.rails.load(std::memory_order_relaxed)) { - const float chance = s.always.load(std::memory_order_relaxed) ? (down > 1.5f ? 1.0f : 0.0f) - : rail_chance(down, contact, s.sensitivity.load(std::memory_order_relaxed), - s.wear_now.load(std::memory_order_relaxed)); - if (std::uniform_real_distribution(0.0f, 1.0f)(roll) < chance) { - s.rail_contact.store(static_cast(contact), std::memory_order_relaxed); - s.rail_speed.store(down, std::memory_order_relaxed); - s.rail_snap.store(true, std::memory_order_release); - // The board snaps under the rider: the rider goes down with it. Gently: the game reads the - // cause's force as how hard it was hit, and a slam's force (10 and more) with the board - // flat on the ground threw the rider high into the air. - no_bail_request_wipeout(2.5f); - } - } - } - if (grinding && wear_on) add_physics_wear(s, grind_wear_per_second(contact != Contact::grind) * seconds); - w.was_air = air; - } - // The body's hits in a bail, each where and as hard as it hit. - if (s.body_sounds.load(std::memory_order_relaxed) && hall_of_meat::skater_available()) { - const auto step = hall_of_meat::read_step(skater, seconds, wipeout); - if (step.ragdoll.value_or(false)) { - const auto now = GetTickCount64(); - for (std::size_t i = 1; i < skater_body::count; ++i) { - const auto& c = step.body.bodies[i]; - if (skater_body::foot(static_cast(i)) || !c.touching || c.impact < body_hit) continue; - if (now - w.body_at[i] < 220 || now - w.any_at < 70) continue; - w.body_at[i] = w.any_at = now; - audio::play(audio::Kind::body, body_loudness(c.impact)); - break; - } - } - } - SetLastError(error); -} - -// ---- breaking and mending -------------------------------------------------------------------------- -// `carried`: the skater is on foot with the board. The grip tape always holds then: a piece that -// came off would have to fall to the ground under the skater's feet, and where that is the board -// in a hand does not tell (a loose piece takes the ground from where the board rolled or lies). -bool snap(State& s, std::uintptr_t board, std::optional at, bool gave_way, bool carried, std::string_view why, bool asked = false) { - if (!s.has_deck.load(std::memory_order_acquire) || s.broken || !board) return false; - const auto place = at ? *at : static_cast(std::uniform_int_distribution(0, 2)(s.random)); - const bool joined = carried || std::uniform_real_distribution(0.0f, 1.0f)(s.random) < settings_now().joined / 100.0f; - const auto now = GetTickCount64(); - s.parts = broken_at(place, joined); - s.angle = s.parts.hinge == Hinge::fold ? std::uniform_real_distribution(0.35f, 1.0f)(s.random) - : std::uniform_real_distribution(0.30f, 0.65f)(s.random); - s.break_ground = s.ride_when && now - s.ride_when < 400 ? s.ride_height : std::numeric_limits::quiet_NaN(); - ++s.flights; - s.broken = Broken{board, now, gave_way, asked, deck_now(s)}; - s.back_since = 0; - crack_sound(break_size(place, joined, s.angle)); - say(logging::Level::info, "the board broke at the {}{} ({}).", place_names[static_cast(place)], - joined ? ", held together by the grip tape" : "", why); - return true; -} -void mend(State& s, std::string_view why) { - if (!s.broken) return; - say(logging::Level::debug, "the board is whole again after {:.1f} s ({}).", (GetTickCount64() - s.broken->since) / 1000.0, why); - // A board that really broke is a new one now; one broken from the console was only looked at. - if (!s.broken->asked) new_board(s, s.broken->deck); - s.broken.reset(); - s.parts = {}; -} -// A board that broke on metal (a rail, a bench's edge, a car) sounds like it. -void metal_sound(std::uintptr_t base, std::uintptr_t holder) { - Vec at{}; - Quat facing{}; - if (!board_place(base, holder, at, facing)) return; - const auto material = material_near(at, 600, 3.0f); - const bool metal = material && is_metal(*material); - if (metal && settings_now().crack_sounds) audio::play(audio::Kind::metal, 1.0f); - say(logging::Level::debug, "it broke on material {}{}.", material ? std::to_string(*material) : std::string("unknown"), metal ? " (metal)" : ""); -} -// Where the board is relative to the skater: how far, and how flat it lies (1 = flat on the ground -// either way up, 0 = on its edge, as when it is carried). -struct Hold { - bool known{}; - float distance{}, upright{}; - bool in_hand() const noexcept { return known && distance < hand_reach && upright < hand_upright; } -}; -Hold board_hold(std::uintptr_t base, std::uintptr_t client) { - Hold out; - const auto frame = multiplayer::capture_local(base, client, true); - if (!frame.ready || frame.pose.board.empty()) return out; - const auto& skater = frame.pose.root.position; - const auto& board = frame.pose.board.front(); - out.distance = std::hypot(board.position[0] - skater[0], board.position[1] - skater[1], board.position[2] - skater[2]); - const auto& q = board.rotation; // x, y, z, w: the board's up axis, turned into the world - out.upright = std::abs(1.0f - 2.0f * (q[0] * q[0] + q[2] * q[2])); - out.known = std::isfinite(out.distance) && std::isfinite(out.upright); - return out; -} -bool riding(std::uint32_t physics) noexcept { - // Ground/air and grind families; 300 is the wipeout, 5xx is on foot and getting on. - return (physics >= 100 && physics < 300) || (physics >= 400 && physics < 500); -} -// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next" -// for the one after the deck in the game. Empty when there is no such deck. -std::string deck_meant(std::string wanted, const std::string& in_game, const std::vector& installed) { - for (auto& c : wanted) c = c == ' ' ? '_' : static_cast(std::tolower(static_cast(c))); - if (installed.empty()) return {}; - if (wanted.empty() || wanted == "next") { - const auto now = std::find(installed.begin(), installed.end(), in_game); - return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1); - } - if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) { - // The decks the feature was made with keep their numbers whether they are installed or not; - // the others follow, in the order they are listed. - const auto number = static_cast(std::stoi(wanted)); - const auto& known = mods::board_break_decks; - if (number >= 1 && number <= known.size()) return std::string(known[number - 1]); - std::size_t next = known.size(); - for (const auto& deck : installed) - if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck; - return {}; - } - return wanted; -} -// What the hook is told this tick; `holder` 0 leaves the local board to be treated as any other. -void publish(State& s, std::uintptr_t base, std::uintptr_t client, std::uintptr_t holder) { - auto& edit = render_edit(); - const bool broken = s.broken && holder; - edit.park.store(s.has_deck.load(std::memory_order_acquire), std::memory_order_release); - edit.angle.store(s.angle, std::memory_order_release); - edit.hinge.store(static_cast(broken ? s.parts.hinge : Hinge::none), std::memory_order_release); - edit.kept.store(broken ? s.parts.kept : 0, std::memory_order_release); - edit.holder.store(holder, std::memory_order_release); - // The broken-off piece: one flight per break, gone when the board is whole again. - auto& f = flight(); - std::lock_guard lock(f.mutex); - if (!broken || !s.parts.loose) f.id = 0; - else if (f.id != s.flights) { - const auto frame = multiplayer::capture_local(base, client, true); - const auto roll = [&](float low, float high) { return std::uniform_real_distribution(low, high)(s.random); }; - const bool loose_end = !(s.parts.loose & (front_truck | back_truck)); // a nose or tail, no truck on it - const float along = s.parts.loose & nose ? 1.0f : -1.0f; // the way it leaves, along the board - const float push = loose_end ? 1.0f : 0.55f; - f.mask = s.parts.loose; - f.ground = frame.ready ? frame.pose.root.position[1] : 0.0f; - f.rest = loose_end ? -0.083f : 0.0f; // a bare piece lies on its underside, one with a truck stands on its wheels - f.ridden = s.break_ground; - f.kick = {roll(-0.9f, 0.9f) * push, (1.4f + roll(0.0f, 1.2f)) * push, along * (1.2f + roll(0.0f, 1.6f)) * push}; - f.spin = {along * (5.0f + roll(0.0f, 6.0f)) * push, roll(-3.0f, 3.0f) * push, roll(-2.0f, 2.0f) * push}; - f.id = s.flights; - } -} -} // namespace - -bool start(std::uintptr_t base) noexcept { - auto& s = state(); - if (s.ready.load(std::memory_order_acquire)) return true; - // Its official mod not installed, or turned off, in the launcher: no Board Break this launch, and - // nothing of it shows. - if (const auto mods_root = mods::engine_data_root() / mods::mods_folder; !mods::official_in_use(mods_root, mods::board_break_official)) { - s.turned_off.store(true, std::memory_order_release); - logging::write(logging::Level::info, logging::Channel::skater, - mods::official_off(mods_root, mods::board_break_official) ? "Board Break is off: its official mod is turned off in the launcher." - : "Board Break is off: its official mod is not installed (launcher, Mods, OFFICIAL)."); - return false; - } - if (!no_bail_available()) { - logging::write(logging::Level::warning, logging::Channel::skater, - "Board Break cannot follow the skater: its slams come through No Bail's hooks, which did not start."); - return false; - } - s.base.store(base, std::memory_order_release); - s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release); - { - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - mirror(s, f.settings); - } - // Rail snaps, wear and the body's hits come from each physics step of the local skater. - if (!add_no_bail_step_listener(&on_step)) - logging::write(logging::Level::warning, logging::Channel::skater, - "Board Break: No Bail has no room for its physics step listener: no rail snaps, wear or body sounds."); - const auto in_game = mods::board_break_in_play(); - { - std::lock_guard lock(s.names); - s.deck = in_game; - } - s.has_deck.store(!in_game.empty(), std::memory_order_release); - s.ready.store(true, std::memory_order_release); - logging::log(logging::Level::info, logging::Channel::skater, "Board Break ready ({}, deck: {}).", s.on.load() ? "on" : "off", - deck_label(in_game)); - return true; -} - -void note_cause(std::int32_t reason, float magnitude) noexcept { - auto& hit = last_cause(); - const auto now = GetTickCount64(); - // Keep the strongest of a burst: one slam records several causes. - if (now - hit.at.load(std::memory_order_relaxed) < 250 && magnitude <= hit.force.load(std::memory_order_relaxed)) return; - hit.force.store(magnitude, std::memory_order_relaxed); - hit.reason.store(reason, std::memory_order_relaxed); - hit.at.store(now, std::memory_order_release); -} -void note_wipeout(std::uintptr_t causes) noexcept { - std::array set{}; - std::array force{}; - auto& hit = last_wipeout(); - std::int32_t reason{-1}; - float strongest{}; - if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force))) - for (std::size_t i = 0; i < cause_count; ++i) - if (set[i] && (reason < 0 || force[i] > strongest)) { - reason = static_cast(i); - strongest = force[i]; - } - hit.force.store(strongest, std::memory_order_relaxed); - hit.reason.store(reason, std::memory_order_relaxed); - hit.at.store(GetTickCount64(), std::memory_order_release); -} - -void on_render_pose(std::uintptr_t animation_interface) noexcept { - auto& edit = render_edit(); - // Without a breakable deck in the game the boards have no stretches: nothing to do. - if (!edit.park.load(std::memory_order_acquire) || animation_interface <= 0xc0) return; - const auto base = state().base.load(std::memory_order_acquire); - const auto holder = edit.holder.load(std::memory_order_acquire); - const auto kept = edit.kept.load(std::memory_order_acquire); - const auto error = GetLastError(); - try { - if (kept && holder && animation_interface == holder + 0xc0) - draw_broken(base, holder, kept, static_cast(edit.hinge.load(std::memory_order_acquire)), edit.angle.load(std::memory_order_acquire)); - else park_pieces(base, animation_interface - 0xc0); - } catch (...) { /* A board that is being built or torn down: the next frame. */ } - SetLastError(error); -} - -void on_client_tick(std::uintptr_t client) noexcept { - auto& s = state(); - if (!s.ready.load(std::memory_order_acquire)) return; - const auto base = s.base.load(std::memory_order_acquire); - try { - const auto now = GetTickCount64(); - // The deck in the game: the launch's, or the one the last live apply of the mods put there. - const auto deck_in_game = mods::board_break_in_play(); - const bool in_game = !deck_in_game.empty(); - std::string wanted; - bool changed{}; - { - std::lock_guard lock(s.names); - changed = s.deck != deck_in_game; - s.deck = deck_in_game; - wanted.swap(s.wanted_deck); - } - s.has_deck.store(in_game, std::memory_order_release); - if (changed || !s.setup_read) { - s.setup_read = true; - const auto setup = read_deck_setup(deck_in_game); - render_edit().axle.store(setup.axle, std::memory_order_release); - { - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - const auto worn = f.wear.find(deck_in_game); - s.wear_now.store(worn == f.wear.end() ? 0.0f : worn->second, std::memory_order_relaxed); - } - if (in_game) load_sounds(); - if (in_game && setup.axle != 1.0f) - say(logging::Level::info, "the {} deck's wheels sit {:.2f} as far apart as a standard board's.", deck_label(deck_in_game), setup.axle); - } - if (changed) say(logging::Level::info, "the deck in the game is now: {}{}.", deck_label(deck_in_game), in_game ? "" : " (boards cannot break)"); - if (!wanted.empty() && wanted != deck_in_game && mods::save_board_break_deck(wanted)) { - mend(s, "another deck"); - say(logging::Level::info, "deck {} chosen. {}", deck_label(wanted), live_mods::apply(true)); - } - if (NoBailSkater probe; in_game && !s.effects_hooked && no_bail_skater(probe)) { - // What a board broke on comes from the game's contact effects (native_vfx.h). The hook is - // installed once, in a world (multiplayer would do it there too). - s.effects_hooked = true; - multiplayer::prepare_effects(base); - } - if (in_game && !s.hooked) { - // The boards' render pose hook is shared with multiplayer and first person, and only - // installed when one of them asks. - std::string detail; - s.hooked = multiplayer::install_entity_hooks(base, detail); - if (!s.hooked && now - s.last_error > 10000) { - s.last_error = now; - say(logging::Level::warning, "the boards' render pose hook is unavailable ({}).", detail); - } - } - NoBailSkater skater; - std::uintptr_t board{}, holder{}; - if (in_game && no_bail_skater(skater)) { - const auto found = multiplayer::read_native_board(readable, base, skater.entity); - board = found.entity; - holder = found.holder; - } - if (!board) { - // No local skater or no board (a respawn, a level change): the next board is whole. A - // broken one that is only out of sight for a moment stays as it is. - if (s.broken && in_game && now - s.board_seen < 500) return; - mend(s, "the board is gone"); - s.counted = false; - s.wanted_snap.store(-1, std::memory_order_release); - s.wanted_mend.store(false, std::memory_order_release); - publish(s, base, client, 0); - return; - } - s.board_seen = now; - note_axes(s, base, holder); - if (s.broken && s.broken->board != board) mend(s, "the skater has another board"); - // What wear has done, onto the board; and what that does to its handling. - take_wear(s, deck_in_game); - if (now - s.handled_at > 500) { - s.handled_at = now; - if (now - s.session_checked > 2000) { - s.session_checked = now; - const auto session = multiplayer::model(); - s.in_session = session.active || session.connected; - } - const auto st = settings_now(); - const bool worn = s.on.load(std::memory_order_acquire) && st.wear && !s.in_session; - apply_handling(base, skater.entity, worn ? handling(s.wear_now.load(std::memory_order_relaxed)) : Handling{}); - } - if (now - s.saved_at > 5000) { - s.saved_at = now; - auto& f = feel(); - std::lock_guard lock(f.mutex); - if (f.dirty) save_feel(f); - } - // A board that came down too hard onto a rail in a slide (the physics thread saw it). - if (s.rail_snap.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken) { - const auto contact = static_cast(s.rail_contact.load(std::memory_order_relaxed)); - const auto what = std::format("a {} onto a rail at {:.1f} m/s", contact_names[(std::min)(contact, contact_names.size() - 1)], s.rail_speed.load()); - if (snap(s, board, Place::mid, false, false, what)) metal_sound(base, holder); - } - // A done board that landed too hard (the physics thread saw it): the nose goes, and they ride on. - if (s.landing_give.exchange(false, std::memory_order_acq_rel) && s.on.load(std::memory_order_acquire) && !s.broken) - (void)snap(s, board, Place::nose, true, false, - std::format("a landing at {:.1f} m/s on a board that was done", s.landing_speed.load(std::memory_order_relaxed))); - watch_physics_state(client, skater.entity); - const auto watch = watched_physics_state(); - if (s.wanted_mend.exchange(false, std::memory_order_acq_rel)) mend(s, "asked for"); - const bool carried = watch.valid && watch.state >= 500 && watch.state < 600; // on foot and getting on - if (const int place = s.wanted_snap.exchange(-1, std::memory_order_acq_rel); place >= 0) - (void)snap(s, board, place < 3 ? std::optional(static_cast(place)) : std::nullopt, false, carried, "asked for", true); - if (watch.valid) { - if (watch.state == 100) { - // Rolling on the ground, the right way up: the board's own height is the ground's. - Vec at{}; - Quat facing{}; - if (board_place(base, holder, at, facing) && turn(facing, {0, 1, 0}).y > 0.9f) { - s.ride_height = at.y; - s.ride_when = now; - } - } - const bool on = s.on.load(std::memory_order_acquire); - if (!s.counted) { - s.wipeouts = watch.wipeouts; - s.counted = true; - } - if (watch.wipeouts != s.wipeouts) { - s.wipeouts = watch.wipeouts; - // The cause that made the selector choose Wipeout, or else the last one recorded. - auto* hit = &last_wipeout(); - if (hit->reason.load() < 0 || now - hit->at.load() > recent_ms) hit = &last_cause(); - const bool known = now - hit->at.load() <= recent_ms && hit->reason.load() >= 0; - const auto force = known ? hit->force.load() : 0.0f; - const auto st = settings_now(); - if (on && st.wear) take_wear(s, deck_in_game, slam_wear(force)); - const float chance = st.always ? 1.0f : slam_chance(force, st.sensitivity, s.wear_now.load(std::memory_order_relaxed)); - if (on && !s.broken && std::uniform_real_distribution(0.0f, 1.0f)(s.random) < chance) { - // Nose first it goes at the nose, tail first at the tail, flat mostly in the middle. - Vec along{}, up{}; - const float slope = board_axes(s, along, up) ? -along.y : 0.0f; - const auto place = place_of_break(slope, false, std::uniform_real_distribution(0.0f, 1.0f)(s.random)); - if (snap(s, board, place, false, watch.previous >= 500 && watch.previous < 600, - std::format("a slam of force {:.1f}{}", force, st.always ? ", set to always break" : ""))) - metal_sound(base, holder); - } - } - - if (s.broken) { - // Whole again once the skater rides it, or has picked it up after the fall. A board that - // gave way under the rider stays as it is while it is ridden; after a fall it is a - // broken board like any other. - const auto hold = board_hold(base, client); - const bool on_it = riding(watch.state); - if (s.broken->gave_way && watch.state == addr::no_bail::wipeout_physics_state) s.broken->gave_way = false; - const bool back = s.broken->gave_way ? !on_it && hold.in_hand() - : on_it || (watch.state != addr::no_bail::wipeout_physics_state && hold.in_hand()); - // A respawn gives the skater a new physics selector, which the watch reports as state 0 - // until it has chosen once: the board is whole from that moment. - if (watch.state == 0 && now - s.broken->since > 300) mend(s, "a respawn"); - else if (s.broken->asked) s.back_since = 0; - else if (!back) s.back_since = 0; - else if (!s.back_since) s.back_since = now; - else if (now - s.back_since > 300 && now - s.broken->since > 1200) mend(s, on_it ? "riding again" : "back in hand"); - } - } - publish(s, base, client, holder); - } catch (const std::exception& error) { - // The skater or its board is being rebuilt: tried again at a later tick. - const auto now = GetTickCount64(); - if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) { - s.last_error = now; - say(logging::Level::debug, "waiting ({}).", error.what()); - } - } catch (...) { /* A broken board is never worth the client tick. */ } -} - -bool available() noexcept { return state().ready.load(std::memory_order_acquire); } -bool turned_off() noexcept { return state().turned_off.load(std::memory_order_acquire); } -bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); } -void set_enabled(bool enabled) noexcept { - state().on.store(enabled, std::memory_order_release); - profile_runtime::set_local_preference(enabled_preference, enabled); -} -std::string deck() { - auto& s = state(); - std::lock_guard lock(s.names); - return s.deck; -} -std::string deck_label(std::string_view deck) { - if (deck.empty()) return "None"; - if (auto title = setup_of(deck).label; !title.empty()) return title; - std::string name(deck); - bool start = true; - for (auto& c : name) { - if (c == '_') c = ' '; - else if (start) c = static_cast(std::toupper(static_cast(c))); - start = c == ' '; - } - return name; -} -std::string choose_deck(std::string wanted) { - auto& s = state(); - if (!available()) return "Board Break is unavailable for this game build."; - const auto installed = mods::installed_board_break_decks(); - if (installed.empty()) return "None of Board Break's decks is installed."; - const auto in_game = deck(); - const auto chosen = deck_meant(wanted, in_game, installed); - if (chosen.empty()) return "There is no deck " + wanted + "."; - if (std::find(installed.begin(), installed.end(), chosen) == installed.end()) - return "The " + deck_label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder."; - if (chosen == in_game) return "The " + deck_label(chosen) + " deck is the one in the game."; - { - std::lock_guard lock(s.names); - s.wanted_deck = chosen; - } - return "The " + deck_label(chosen) + " deck: the mods are applied and the level loads again with it."; -} -void snap(int place) noexcept { state().wanted_snap.store(place >= 0 && place < 3 ? place : 3, std::memory_order_release); } -void mend() noexcept { state().wanted_mend.store(true, std::memory_order_release); } - -Settings settings() noexcept { - try { - return settings_now(); - } catch (...) { return {}; } -} -bool set_setting(std::string_view name, int value) noexcept { - try { - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - if (!set_setting_value(f.settings, name, value)) return false; - mirror(state(), f.settings); - save_feel(f); - return true; - } catch (...) { return false; } -} -float wear() noexcept { return state().wear_now.load(std::memory_order_relaxed); } -void set_wear(float value) noexcept { - try { - auto& s = state(); - const auto deck = deck_now(s); - if (deck.empty()) return; - value = std::clamp(value, 0.0f, 1.0f); - { - auto& f = feel(); - std::lock_guard lock(f.mutex); - load_feel(f); - f.wear[deck] = value; - save_feel(f); - } - s.wear_now.store(value, std::memory_order_relaxed); - if (value == 0.0f) profile_runtime::reset_board_wear(); - } catch (...) {} -} -std::string metal_command(std::string_view action) { - auto& f = feel(); - if (action == "mark" || action == "unmark") { - const auto material = material_near({}, 1500, std::numeric_limits::infinity()); - if (!material) return "No contacts in the last second and a half: grind the rail first, then mark it."; - std::lock_guard lock(f.mutex); - load_feel(f); - if (action == "mark") f.metal.insert(*material); - else f.metal.erase(*material); - save_feel(f); - return std::format("Material {} is {} metal.", *material, action == "mark" ? "now" : "no longer"); - } - std::lock_guard lock(f.mutex); - load_feel(f); - if (action == "clear") { - f.metal.clear(); - save_feel(f); - return "No material is marked as metal now (a pack's own list still counts)."; - } - std::string list; - for (const auto m : f.metal) list += (list.empty() ? "" : ", ") + std::to_string(m); - std::string pack; - for (const auto m : f.pack_metal) pack += (pack.empty() ? "" : ", ") + std::to_string(m); - return "Metal: " + (list.empty() ? std::string("none marked") : list) + "; from the deck pack: " + (pack.empty() ? std::string("none") : pack) + "."; -} -std::string materials_report() { - std::map count; - for (const auto& one : multiplayer::recent_local_impacts(3000)) ++count[one.impact.material]; - if (count.empty()) return "No contacts in the last 3 seconds."; - std::string out = "Materials touched in the last 3 s:"; - for (const auto& [material, times] : count) out += std::format(" {} ({}x{})", material, times, is_metal(material) ? ", metal" : ""); - return out + "."; -} -std::string status() { - auto& s = state(); - if (!available()) return "Board Break is unavailable for this game build."; - const auto st = settings_now(); - const auto deck = deck_now(s); - const auto worn = s.wear_now.load(std::memory_order_relaxed); - return std::format("Board Break {}; deck {}; the board is {} ({:.0f}% worn); sensitivity {}%, always break {}, rail snaps {}, wear {} at {}%, " - "grip holds {}% of breaks, crack sounds {}, body sounds {}.", - s.on.load() ? "on" : "off", deck_label(deck), condition_name(worn), worn * 100.0f, - st.sensitivity, st.always ? "on" : "off", st.rails ? "on" : "off", st.wear ? "on" : "off", st.wear_rate, st.joined, - st.crack_sounds ? "on" : "off", st.body_sounds ? "on" : "off"); -} -} // namespace dingosdk::board_break diff --git a/Extension/BoardBreak/board_break.h b/Extension/BoardBreak/board_break.h deleted file mode 100644 index 6f4c5a42..00000000 --- a/Extension/BoardBreak/board_break.h +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once -#include "board_break_feel.h" -#include -#include -#include - -// Board Break: a hard slam can break the local skater's board. It snaps at the nose, in the middle -// or at the tail; half the time the grip tape holds the pieces together and the board folds or an -// end hangs, otherwise a piece comes off, flies and lies where it lands. The board is whole again -// once the skater has picked it up, rides again or respawns. How hard the slam was (the force of -// the bail cause that led to the wipeout) decides whether the board breaks: a heavy one always, a -// lighter one now and then. -// -// The pieces are not drawn by ReSkate: they are in the deck model of a content mod, one mod for -// each deck shape, any folder named Board_Break_ (Engine/Vfs/mod_catalog.h). The game has one -// deck model for every board; such a mod replaces it with the same deck plus four stretches of it -// (nose, front, back, tail) cut along the break rows, each on a spare bone of the board's rig -// (contrib/board-break.md says how such a model is made). This only moves bones, -// in the boards' render pose hook (Extension/Multiplayer/Remote/native_skater.h): -// - a broken board has its real deck moved far away, the trucks moved back to where they were, -// and each stretch moved to where its piece lies; -// - a whole board has its stretches moved far away, every frame, on every board in the game: the -// game draws a deck's wood and grip as layers that do not hide what lies just under them, so -// stretches left inside the deck show as cracks. -// Nothing is scaled: the game's replay records where a bone is and how it is turned, not its size, -// so a break made of moves plays back in the replay editor as it happened. -// -// Without such a mod installed and loading first nothing ever breaks. With one, mods.json is not -// changed: the deck the player chose goes first for the run, and the other decks and any mod that -// reshapes the deck are set aside (mod_catalog.cpp). Choosing another deck applies the mods afresh -// and loads the level again (Extension/Assets/live_mods.h); the game reads the model then. -namespace dingosdk::board_break { -// Requires the validated build, No Bail started and the local profile loaded: the switch starts -// from the saved choice (on until a player changes it). -bool start(std::uintptr_t image_base) noexcept; -// Client thread, every tick, with the local client. -void on_client_tick(std::uintptr_t client) noexcept; -// From No Bail's hooks, on the physics thread: a bail cause recorded for the watched skater, and -// the moment its selector chose Wipeout (`causes` is its cause collector, read before it resets). -void note_cause(std::int32_t reason, float magnitude) noexcept; -void note_wipeout(std::uintptr_t causes) noexcept; -// From the boards' render pose hook, after a board's pose has been handed to the renderer and -// after another player's pose has been written into it (`animation_interface` is the board's -// animation holder + 0xc0): what is written to the pose here is what this frame draws. -void on_render_pose(std::uintptr_t animation_interface) noexcept; - -// Any thread. -bool available() noexcept; -// Its official mod is turned off in the launcher (Engine/Vfs/builtin_packs.h): it did not start. -bool turned_off() noexcept; -bool enabled() noexcept; // off: boards hold; one that is broken stays so until it is picked up -void set_enabled(bool enabled) noexcept; -// The name of the deck in the game ("standard", "wide" and so on), empty when none of Board Break's -// decks is: boards cannot break then. And a deck's name as the menus show it ("Powell"; "None"). -std::string deck(); -std::string deck_label(std::string_view deck); -// Asks for another deck: its name, its number in the list `boardbreak deck` prints, or "next" for -// the one after the deck in the game. Says what happens, for the console; the mods are applied and -// the level loaded again from the next tick. -std::string choose_deck(std::string wanted); -// Done at the next tick: breaks the board at `place` (0 nose, 1 middle, 2 tail; anything else is -// left to chance), to be looked at: that break stays until the board is mended here or the skater -// respawns. Or makes a broken board whole. -void snap(int place) noexcept; -void mend() noexcept; - -// ---- the feel of it ------------------------------------------------------------------------------- -// The player's settings (board_break_feel.h), kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings. -// Any thread; set_setting saves at once and is false for no such setting. -Settings settings() noexcept; -bool set_setting(std::string_view name, int value) noexcept; -// Wear and tear: how worn the board of the deck in the game is (0 new, 1 worn out), kept per deck -// with the settings. A board that broke is a new one once it is whole again. A worn board handles -// worse (softer trucks, a lazier turn: the game's own physics tuning, only outside multiplayer -// sessions, which share tuning), breaks more easily and, worn out, can give way under the rider. -// Its scratches are the game's own board wear (Mod Options > Board wear), reset with it. -float wear() noexcept; -void set_wear(float wear) noexcept; -// Metal: the level's materials a board breaking on them sounds like metal on. They are learned -// from the contacts while grinding (`boardbreak metal mark` marks what the board is on now as -// metal), and a pack can bring a list (sounds/metal/materials.txt). For the console. -std::string metal_command(std::string_view action); -std::string materials_report(); -std::string status(); -} diff --git a/Extension/BoardBreak/board_break_audio.cpp b/Extension/BoardBreak/board_break_audio.cpp deleted file mode 100644 index b7520960..00000000 --- a/Extension/BoardBreak/board_break_audio.cpp +++ /dev/null @@ -1,333 +0,0 @@ -#include "board_break_audio.h" -#include "Engine/Core/Platform/path_text.h" -#include "Engine/Core/Log/logging.h" -#include "Extension/Multiplayer/Remote/native_skater.h" -#include "Extension/Multiplayer/Voice/native_voice.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#pragma comment(lib, "xaudio2.lib") -#pragma comment(lib, "winmm.lib") -#pragma comment(lib, "ole32.lib") - -namespace dingosdk::board_break::audio { -namespace { -// One wave file, read whole: its format as the file gives it and its samples, and the same sound -// as the game's positioned sound takes it (16 kHz mono; empty when it could not be made). -struct Sound { - std::filesystem::path file; - std::vector format; // WAVEFORMATEX, or the larger WAVEFORMATEXTENSIBLE - std::vector samples; - std::vector voice; -}; -// The sound at the game sound's rate, mixed down to one channel: each output sample is the mean of -// the input samples it spans (a plain low-pass, so a crack's highs do not fold back as noise). -std::vector to_voice(const Sound& sound) { - WAVEFORMATEX format{}; - std::memcpy(&format, sound.format.data(), std::min(sound.format.size(), sizeof(format))); - auto tag = format.wFormatTag; - if (tag == WAVE_FORMAT_EXTENSIBLE && sound.format.size() >= 26) std::memcpy(&tag, sound.format.data() + 24, 2); // its SubFormat - const unsigned channels = format.nChannels, bits = format.wBitsPerSample, rate = format.nSamplesPerSec; - const bool floats = tag == WAVE_FORMAT_IEEE_FLOAT; - if (!channels || !rate || (tag != WAVE_FORMAT_PCM && !floats) || (floats && bits != 32) || - (!floats && bits != 8 && bits != 16 && bits != 24 && bits != 32)) - return {}; - const std::size_t stride = channels * (bits / 8), frames = sound.samples.size() / stride; - const auto at = [&](std::size_t frame) { - float sum = 0.0f; - for (unsigned c = 0; c < channels; ++c) { - const auto* p = sound.samples.data() + frame * stride + c * (bits / 8); - float v{}; - if (floats) std::memcpy(&v, p, 4); - else if (bits == 8) v = (static_cast(*p) - 128.0f) / 128.0f; - else if (bits == 16) { std::int16_t s{}; std::memcpy(&s, p, 2); v = s / 32768.0f; } - else if (bits == 24) v = static_cast(static_cast((p[0] << 8) | (p[1] << 16) | (p[2] << 24)) >> 8) / 8388608.0f; - else { std::int32_t s{}; std::memcpy(&s, p, 4); v = static_cast(s) / 2147483648.0f; } - sum += v; - } - return sum / static_cast(channels); - }; - const double step = static_cast(rate) / multiplayer::native_voice_rate; - const auto count = static_cast(static_cast(frames) / step); - std::vector out(count); - for (std::size_t i = 0; i < count; ++i) { - const auto from = static_cast(static_cast(i) * step); - const auto to = std::min(frames, std::max(from + 1, static_cast(static_cast(i + 1) * step))); - float sum = 0.0f; - for (auto f = from; f < to; ++f) sum += at(f); - const float v = std::isfinite(sum) ? sum / static_cast(to - from) : 0.0f; - out[i] = static_cast(std::clamp(v, -1.0f, 1.0f) * 32767.0f); - } - return out; -} -// A RIFF WAVE file's "fmt " and "data" chunks; false for anything else. -bool read_wave(const std::filesystem::path& file, Sound& out) { - std::ifstream in(file, std::ios::binary); - std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - if (bytes.size() < 12 || std::memcmp(bytes.data(), "RIFF", 4) || std::memcmp(bytes.data() + 8, "WAVE", 4)) return false; - std::size_t at = 12; - while (at + 8 <= bytes.size()) { - std::uint32_t size{}; - std::memcpy(&size, bytes.data() + at + 4, 4); - const auto body = at + 8; - if (body + size > bytes.size()) size = static_cast(bytes.size() - body); - if (!std::memcmp(bytes.data() + at, "fmt ", 4) && size >= 16) out.format.assign(bytes.begin() + body, bytes.begin() + body + size); - else if (!std::memcmp(bytes.data() + at, "data", 4)) out.samples.assign(bytes.begin() + body, bytes.begin() + body + size); - at = body + size + (size & 1); - } - if (out.format.size() < 16 || out.samples.empty()) return false; - std::uint16_t tag{}; - std::memcpy(&tag, out.format.data(), 2); - if (tag != WAVE_FORMAT_PCM && tag != WAVE_FORMAT_IEEE_FLOAT && tag != WAVE_FORMAT_EXTENSIBLE) return false; - // XAudio2 reads a whole WAVEFORMATEX (cbSize included). - if (out.format.size() < sizeof(WAVEFORMATEX)) out.format.resize(sizeof(WAVEFORMATEX), 0); - out.file = file; - return true; -} - -struct Request { - Kind kind{}; - float volume{}; -}; -// A sound the game's positioned sound is to play. -struct GameRequest { - const Sound* sound{}; - float volume{}; -}; -long long now_ms() { - return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); -} -struct Engine { - std::mutex mutex; - std::condition_variable wake; - std::deque queue; - std::deque game_queue; - // When the game's sound last could play them (tick_game_sound): till then, and a moment after - // it stops (a level left), the speakers do. - std::atomic game_ms{}; - std::array, kinds> sounds; - std::array loaded{}; - std::thread worker; - std::atomic started{}, failed{}; - std::mt19937 random{std::random_device{}()}; -}; -Engine& engine() { - static auto* value = new Engine; - return *value; -} - -void run(Engine& e) { - const auto com = CoInitializeEx(nullptr, COINIT_MULTITHREADED); - IXAudio2* xaudio{}; - IXAudio2MasteringVoice* master{}; - if (FAILED(XAudio2Create(&xaudio, 0, XAUDIO2_DEFAULT_PROCESSOR)) || FAILED(xaudio->CreateMasteringVoice(&master))) { - if (xaudio) xaudio->Release(); - xaudio = nullptr; - e.failed.store(true, std::memory_order_release); - logging::log(logging::Level::warning, logging::Channel::skater, - "Board Break: no sound device for its own sounds; the cracks play one at a time."); - } - std::vector voices; - for (;;) { - Request request; - const Sound* sound{}; - { - std::unique_lock lock(e.mutex); - e.wake.wait(lock, [&] { return !e.queue.empty(); }); - request = e.queue.front(); - e.queue.pop_front(); - auto& list = e.sounds[static_cast(request.kind)]; - if (list.empty()) continue; - sound = &list[std::uniform_int_distribution(0, list.size() - 1)(e.random)]; - } - if (!xaudio) { - // Without XAudio2: the cracks still sound, one at a time. - if (request.kind <= Kind::heavy) (void)PlaySoundW(sound->file.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT); - continue; - } - // Voices whose sound has played out go; at most a couple of dozen at once. - std::erase_if(voices, [](IXAudio2SourceVoice* voice) { - XAUDIO2_VOICE_STATE state{}; - voice->GetState(&state, XAUDIO2_VOICE_NOSAMPLESPLAYED); - if (state.BuffersQueued) return false; - voice->DestroyVoice(); - return true; - }); - if (voices.size() >= 24) { - voices.front()->DestroyVoice(); - voices.erase(voices.begin()); - } - IXAudio2SourceVoice* voice{}; - if (FAILED(xaudio->CreateSourceVoice(&voice, reinterpret_cast(sound->format.data())))) continue; - XAUDIO2_BUFFER buffer{}; - buffer.AudioBytes = static_cast(sound->samples.size()); - buffer.pAudioData = sound->samples.data(); - buffer.Flags = XAUDIO2_END_OF_STREAM; - voice->SetVolume(std::clamp(request.volume, 0.0f, 1.0f)); - if (FAILED(voice->SubmitSourceBuffer(&buffer)) || FAILED(voice->Start())) { - voice->DestroyVoice(); - continue; - } - voices.push_back(voice); - } - (void)com; -} -} - -void load(Kind kind, const std::vector& files) noexcept { - auto& e = engine(); - std::vector read; - for (const auto& file : files) { - try { - Sound sound; - if (read_wave(file, sound)) { - sound.voice = to_voice(sound); - read.push_back(std::move(sound)); - } - else logging::log(logging::Level::warning, logging::Channel::skater, "Board Break: {} is not a wave file it can play.", - path_utf8(file.filename())); - } catch (...) {} - } - std::lock_guard lock(e.mutex); - const auto at = static_cast(kind); - if (e.loaded[at]) return; - e.loaded[at] = true; - e.sounds[at] = std::move(read); -} -bool has(Kind kind) noexcept { - auto& e = engine(); - std::lock_guard lock(e.mutex); - return !e.sounds[static_cast(kind)].empty(); -} -void play(Kind kind, float volume) noexcept { - auto& e = engine(); - try { - // In a level: the game's own sound plays it, at the skater (tick_game_sound). - if (now_ms() - e.game_ms.load(std::memory_order_relaxed) < 400) { - std::lock_guard lock(e.mutex); - auto& list = e.sounds[static_cast(kind)]; - if (list.empty()) return; - const auto& sound = list[std::uniform_int_distribution(0, list.size() - 1)(e.random)]; - if (!sound.voice.empty()) { - if (e.game_queue.size() < 32) e.game_queue.push_back({&sound, volume}); - return; - } - } - if (!e.started.exchange(true)) { - e.worker = std::thread([&e] { run(e); }); - e.worker.detach(); - } - std::lock_guard lock(e.mutex); - if (e.queue.size() < 32) e.queue.push_back({kind, volume}); - } catch (...) { return; } - e.wake.notify_one(); -} -namespace { -using Clock = std::chrono::steady_clock; -// Board Break's sounds in the game's sample provider ("RSKB"), apart from voices and drones. -constexpr std::uint64_t source_tag = 0x52534b4200000000ULL; -// Their loudness through the game's sound, against the speakers' (1: the same samples). -constexpr float game_level = 1.0f; -// Sound made ahead for a sound just started, so its first blocks do not run dry; at most this -// much in one frame; and how long a finished sound keeps its place for its last blocks to play. -constexpr double preroll_seconds = 0.06; -constexpr float longest_frame = 0.1f; -constexpr float tail_seconds = 0.4f; -struct Stream { - const Sound* sound{}; - float gain{}; - std::size_t cursor{}; - double owed{}; - float tail{}; -}; -struct Player { - multiplayer::NativeVoicePlayback playback{source_tag}; - std::map streams; - std::uint64_t next_id = 1; - bool started{}; - Clock::time_point last{}; -}; -// Never destroyed: its sounds belong to the game, which is gone by then. -Player& player() { - static auto* value = new Player; - return *value; -} -} - -void tick_game_sound(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept { - auto& e = engine(); - auto& p = player(); - try { - const auto now = Clock::now(); - if (!p.started) { - p.playback.reset(1); - p.started = true; - p.last = now; - } - const float elapsed = std::clamp(std::chrono::duration(now - p.last).count(), 0.0f, longest_frame); - p.last = now; - { - std::lock_guard lock(e.mutex); - for (const auto& request : e.game_queue) - if (ready && p.streams.size() < 24) { - Stream stream; - stream.sound = request.sound; - stream.gain = std::clamp(request.volume, 0.0f, 1.0f) * game_level; - stream.owed = preroll_seconds * multiplayer::native_voice_rate; - p.streams.emplace(p.next_id++, stream); - } - e.game_queue.clear(); - } - std::array at{}; - bool placed{}; - if (ready && !p.streams.empty()) { - const auto frame = multiplayer::capture_local(base, client, false); - if (frame.ready) { - at = {frame.pose.root.position[0], frame.pose.root.position[1] + 0.3f, frame.pose.root.position[2]}; - placed = true; - } - } - if (!placed) p.streams.clear(); // (their sounds are let go once they have no place for a moment) - std::vector block; - for (auto it = p.streams.begin(); it != p.streams.end();) { - auto& stream = it->second; - const auto& samples = stream.sound->voice; - p.playback.position(it->first, 1, at, stream.gain); - if (stream.cursor >= samples.size()) { - stream.tail += elapsed; - if (stream.tail >= tail_seconds) { it = p.streams.erase(it); continue; } - ++it; - continue; - } - stream.owed += static_cast(elapsed) * multiplayer::native_voice_rate; - const auto count = std::min({static_cast(stream.owed), samples.size() - stream.cursor, - static_cast(multiplayer::native_voice_rate * longest_frame)}); - stream.owed -= static_cast(count); - if (count) { - const auto* data = reinterpret_cast(samples.data() + stream.cursor); - if (p.playback.submit(it->first, 1, {data, count * sizeof(std::int16_t)})) stream.cursor += count; - } - ++it; - } - p.playback.process(base); - if (ready && p.playback.available()) e.game_ms.store(now_ms(), std::memory_order_relaxed); - } catch (...) { - p.streams.clear(); - } -} -} diff --git a/Extension/BoardBreak/board_break_audio.h b/Extension/BoardBreak/board_break_audio.h deleted file mode 100644 index f9eff1b8..00000000 --- a/Extension/BoardBreak/board_break_audio.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include -#include -#include - -// Board Break's sounds: wave files out of a deck pack's `sounds` folder, over each other and at the -// loudness asked for, so a crack, the board's metal and the body's hits of one bail can all be heard. -// In a level they play through the game's own positioned sound, where the skater is, so the game's -// volume, mix and echo apply (16 kHz mono, as that sound takes). Elsewhere, or until the game's -// sound is ready, straight to the speakers: XAudio2 on a thread of its own, and without it (no -// sound device) the cracks one at a time with PlaySound. -namespace dingosdk::board_break::audio { -enum class Kind { light, medium, heavy, metal, body }; -inline constexpr std::size_t kinds = 5; -// The wave files of a kind (16-bit or float PCM, any rate and channels). Any thread; a kind is -// given its files once, later calls for it change nothing. -void load(Kind kind, const std::vector& files) noexcept; -bool has(Kind kind) noexcept; -// One of them, at random, at `volume` (0 to 1). Any thread; returns at once. -void play(Kind kind, float volume) noexcept; -// Client thread, every frame (Runtime/client_tick.cpp): plays what play() gave the game's sound, -// at the local skater. `ready`: a level is loaded and the skater can be read. -void tick_game_sound(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; -} diff --git a/Extension/BoardBreak/board_break_commands.cpp b/Extension/BoardBreak/board_break_commands.cpp deleted file mode 100644 index 6ae13c05..00000000 --- a/Extension/BoardBreak/board_break_commands.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "board_break.h" -#include "board_break_model.h" -#include "Engine/Vfs/mod_catalog.h" -#include "Extension/Console/commands.h" -#include - -// Board Break's console switches; the menu's (Mod Options > Player) send them too. -namespace dingosdk::console { -void register_board_break_commands(Commands ®istry) { - const auto unavailable = "Board Break is unavailable for this game build."; - auto on = variable("boardbreak", "A hard slam can snap your board at the nose, in the middle or at the tail " - "(needs one of the Board Break deck packs)", - Group::gameplay, argument("0|1", Type::boolean)); - on.inspect = [unavailable](const Model &) { - const auto deck = board_break::deck(); - return boolean_state(board_break::available(), board_break::enabled(), unavailable, - deck.empty() ? std::string("no Board Break deck is in the game") : "the " + board_break::deck_label(deck) + " deck"); - }; - on.run = [](const Model &, const Values &args, const Output &) { board_break::set_enabled(std::get(args[0])); }; - registry.add(std::move(on)); - - auto which = argument("deck", Type::text, true); - which.complete = [](const Model &, auto) { - auto names = mods::installed_board_break_decks(); - names.insert(names.begin(), "next"); - return names; - }; - auto deck = action("boardbreak deck", "Which deck every board has, by its name or its number, or next; the level loads again with it", - Group::gameplay, {std::move(which)}); - deck.run = [](const Model &, const Values &args, const Output &out) { - if (!args.empty() && !std::get(args[0]).empty()) { - out(board_break::choose_deck(std::get(args[0]))); - return; - } - // No deck named: which one is in the game, and which there are. The decks the feature was - // made with keep their numbers; the others follow. - const auto installed = mods::installed_board_break_decks(); - const auto &known = mods::board_break_decks; - std::string list; - std::size_t number{}; - const auto add = [&](std::string_view name, bool there) { - const auto place = ++number; - list += (place > 1 ? ", " : "") + std::to_string(place) + " " + board_break::deck_label(name) + (there ? "" : " (not installed)"); - }; - for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end()); - for (const auto &name : installed) - if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true); - out("In the game: " + board_break::deck_label(board_break::deck()) + ". Decks: " + list + "."); - }; - registry.add(std::move(deck)); - - auto where = argument("nose|mid|tail", Type::text, true); - where.complete = [](const Model &, auto) { - return std::vector(board_break::place_names.begin(), board_break::place_names.end()); - }; - auto snap = action("boardbreak snap", "Break your board now, at the nose, in the middle, at the tail or wherever, to look at a break: " - "it stays broken until `boardbreak mend` or a respawn. A piece only comes off while you ride", - Group::gameplay, {std::move(where)}); - snap.run = [](const Model &, const Values &args, const Output &out) { - const auto wanted = args.empty() ? std::string{} : std::get(args[0]); - const auto &names = board_break::place_names; - const auto named = std::find(names.begin(), names.end(), wanted); - board_break::snap(named == names.end() ? -1 : static_cast(named - names.begin())); - out(board_break::deck().empty() ? "No Board Break deck is in the game: the board holds." : "Snapped."); - }; - registry.add(std::move(snap)); - - auto mend = action("boardbreak mend", "Make a broken board whole again", Group::gameplay); - mend.run = [](const Model &, const Values &, const Output &out) { - board_break::mend(); - out("Mended."); - }; - registry.add(std::move(mend)); - // The player's settings (board_break_feel.h), kept: with no value each says what it is. - struct Setting { - const char *name, *description; - }; - const Setting settings[]{ - {"always", "Board Break: every slam breaks the board, and every slide that comes down onto a rail (for replay clips)"}, - {"sensitivity", "Board Break: how easily a board breaks, percent (100; 200 takes half the force, 50 twice as much)"}, - {"joined", "Board Break: percent of breaks the grip tape holds together (50)"}, - {"rails", "Board Break: a boardslide or darkslide that comes down too hard onto a rail can snap the board"}, - {"wear", "Board Break: boards wear out with every hit, handle worse and break more easily as they do"}, - {"wearrate", "Board Break: how fast boards wear, percent (100)"}, - {"cracks", "Board Break: the crack (and metal) sound of a break"}, - {"bodysounds", "Board Break: the body's hits in a bail, where and as hard as it hits"}, - }; - for (const auto &setting : settings) { - auto entry = action(std::string("boardbreak ") + setting.name, setting.description, Group::gameplay, - {argument("value", Type::text, true)}); - entry.run = [name = std::string(setting.name)](const Model &, const Values &values, const Output &out) { - const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); - if (!text || text->empty()) { - out(name + " is " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "."); - return; - } - int value{}; - if (*text == "on" || *text == "yes") value = 1; - else if (*text == "off" || *text == "no") value = 0; - else { - try { - value = std::stoi(*text); - } catch (...) { - out("Give a number (or on/off)."); - return; - } - } - out(board_break::set_setting(name, value) ? name + " is now " + std::to_string(board_break::setting_value(board_break::settings(), name)) + "." - : "No such setting."); - }; - registry.add(std::move(entry)); - } - - auto worn = action("boardbreak worn", "How worn the board of the deck in the game is, percent; 0 is a new board", Group::gameplay, - {argument("percent", Type::text, true)}); - worn.run = [](const Model &, const Values &values, const Output &out) { - const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); - if (text && !text->empty()) { - try { - board_break::set_wear(std::stof(*text) / 100.0f); - } catch (...) { - out("Give a percent, 0 to 100."); - return; - } - } - const auto wear = board_break::wear(); - out("The board is " + std::string(board_break::condition_name(wear)) + ", " + std::to_string(static_cast(wear * 100.0f + 0.5f)) + "% worn."); - }; - registry.add(std::move(worn)); - - auto status = action("boardbreak status", "Everything Board Break is set to, and how worn the board is", Group::gameplay); - status.run = [](const Model &, const Values &, const Output &out) { out(board_break::status()); }; - registry.add(std::move(status)); - - auto metal = action("boardbreak metal", "Which materials a board breaking on them sounds like metal on: mark (what you grind now), unmark, clear, list", - Group::gameplay, {argument("mark|unmark|clear|list", Type::text, true)}); - metal.run = [](const Model &, const Values &values, const Output &out) { - const auto *text = values.empty() ? nullptr : std::get_if(&values[0]); - out(board_break::metal_command(text ? *text : std::string("list"))); - }; - registry.add(std::move(metal)); - - auto materials = action("boardbreak materials", "The level's materials your skater touched in the last 3 seconds, by number (to find a rail's)", - Group::gameplay); - materials.run = [](const Model &, const Values &, const Output &out) { out(board_break::materials_report()); }; - registry.add(std::move(materials)); -} -} // namespace dingosdk::console diff --git a/Extension/BoardBreak/board_break_feel.h b/Extension/BoardBreak/board_break_feel.h deleted file mode 100644 index 57828b45..00000000 --- a/Extension/BoardBreak/board_break_feel.h +++ /dev/null @@ -1,178 +0,0 @@ -#pragma once -#include "board_break_model.h" -#include -#include -#include -#include - -// How a board wears and how hard it has to be hit to break, apart from the game: the player's -// settings, the damage each kind of hit does, what a worn board does to its handling, and what kind -// of contact a board makes with a rail. Plain numbers, so the tests can hold them. -namespace dingosdk::board_break { -// ---- the player's settings ---------------------------------------------------------------------- -// Each is kept in %LOCALAPPDATA%\ReSkate\BoardBreak.settings and set from the console as -// `boardbreak ` (the menus send the same). -struct Settings { - bool always = false; // every slam breaks the board, for replay clips - int sensitivity = 100; // percent: at 200 half the force breaks a board, at 50 twice as much - int joined = 50; // percent of breaks the grip tape holds together - bool rails = true; // a hard boardslide or darkslide onto a rail or ledge can break it - bool wear = true; // boards wear out, handle worse and break more easily as they do - int wear_rate = 100; // percent: how fast they wear - bool crack_sounds = true; - bool body_sounds = true; // the body's hits during a bail -}; -inline constexpr int sensitivity_min = 10, sensitivity_max = 400, rate_min = 0, rate_max = 500; -struct SettingInfo { - std::string_view name; - bool on_off; // else a percent - int low, high; -}; -inline constexpr std::array setting_info{{ - {"always", true, 0, 1}, {"sensitivity", false, sensitivity_min, sensitivity_max}, {"joined", false, 0, 100}, - {"rails", true, 0, 1}, {"wear", true, 0, 1}, {"wearrate", false, rate_min, rate_max}, - {"cracks", true, 0, 1}, {"bodysounds", true, 0, 1}, -}}; -// The setting `name` as a number (on/off as 1/0), or -1 for no such setting. -inline int setting_value(const Settings& s, std::string_view name) noexcept { - if (name == "always") return s.always; - if (name == "sensitivity") return s.sensitivity; - if (name == "joined") return s.joined; - if (name == "rails") return s.rails; - if (name == "wear") return s.wear; - if (name == "wearrate") return s.wear_rate; - if (name == "cracks") return s.crack_sounds; - if (name == "bodysounds") return s.body_sounds; - return -1; -} -// Sets it, kept within its range; false for no such setting. -inline bool set_setting_value(Settings& s, std::string_view name, int value) noexcept { - for (const auto& info : setting_info) { - if (info.name != name) continue; - value = std::clamp(value, info.low, info.high); - if (name == "always") s.always = value != 0; - else if (name == "sensitivity") s.sensitivity = value; - else if (name == "joined") s.joined = value; - else if (name == "rails") s.rails = value != 0; - else if (name == "wear") s.wear = value != 0; - else if (name == "wearrate") s.wear_rate = value; - else if (name == "cracks") s.crack_sounds = value != 0; - else s.body_sounds = value != 0; - return true; - } - return false; -} - -// ---- how hard a slam has to be ------------------------------------------------------------------ -// The slam forces measured in play run from about 2 (a stumble) to 10 and more (a hard slam). -inline constexpr float heavy_slam = 10.0f; -// A worn board takes less: at the end of its life about half the force it took new. -inline constexpr float worn_strength = 0.5f; -// How likely a slam of `force` breaks the board, with the player's sensitivity (percent) and the -// board's wear (0 new, 1 worn out). A new board at 100%: never under 6, half the time at 8, always -// from 10. A bail whose force is not known (0) never breaks it. -inline float slam_chance(float force, int sensitivity, float wear) noexcept { - if (!(force > 0.0f)) return 0.0f; - const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f; - const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f); - return break_chance(force, heavy_slam * strength / s); -} - -// ---- wear and tear ------------------------------------------------------------------------------ -// A board's wear runs from 0 (new) to 1 (worn out). Every hit adds some, at the player's rate -// (percent of these). A fresh board lasts a good while: a few hundred hard landings, or some -// dozens of big drops, or half an hour of boardslides. -// A landing, by how fast the board was coming down as it touched (m/s): nothing up to an ollie off -// a curb's speed, then more and more. -inline constexpr float landing_free = 3.5f; -inline float landing_wear(float down_speed) noexcept { - const float over = (std::max)(0.0f, down_speed - landing_free); - return (std::min)(0.0008f * over * over, 0.04f); -} -// Onto a rail or ledge: the board's own hit, harder on a slide (the deck takes it) than on the trucks. -inline float rail_wear(float down_speed, bool slide) noexcept { - const float hit = landing_wear(down_speed + (slide ? 1.0f : 0.0f)); - return hit + (slide ? 0.001f : 0.0003f); -} -// Every second of grinding or sliding. -inline float grind_wear_per_second(bool slide) noexcept { return slide ? 0.0005f : 0.00015f; } -// A slam, by its force. -inline float slam_wear(float force) noexcept { return std::clamp((force - 4.0f) / 6.0f, 0.0f, 1.0f) * 0.025f; } -inline float add_wear(float wear, float amount, int rate) noexcept { - return std::clamp(wear + amount * std::clamp(rate, rate_min, rate_max) / 100.0f, 0.0f, 1.0f); -} -// What wear does to the board's handling, as factors on the game's own tuning: the steering answers -// less, the trucks' springs and their damping go soft (the board wobbles and leans further), and -// they twist further. Nothing shows until a board is a fifth worn. -struct Handling { - float steering = 1, spring = 1, damping = 1, twist = 1; - bool operator==(const Handling&) const = default; -}; -inline Handling handling(float wear) noexcept { - const float w = std::clamp((wear - 0.2f) / 0.8f, 0.0f, 1.0f); - const float eased = w * w * (3.0f - 2.0f * w); - return {1.0f - 0.25f * eased, 1.0f - 0.6f * eased, 1.0f - 0.5f * eased, 1.0f + 0.4f * eased}; -} -// What a board's state is called on the menus. A board is "done" from `done_wear` on. -inline constexpr float done_wear = 0.85f; -inline std::string_view condition_name(float wear) noexcept { - return wear < 0.15f ? "new" : wear < 0.4f ? "used" : wear < 0.65f ? "worn" : wear < done_wear ? "beat" : "done"; -} -// A done board gives way under the rider on a landing (the nose goes, and they ride on): from a -// big drop (6.5 m/s coming down) as it becomes done, down to any real drop (3.5 m/s, a curb's) -// when it is worn right out. Never a board less worn, and never on flat ground riding: a board -// breaks from something the rider did. Sensitivity as for a slam. -inline bool gives_way_on_landing(float down_speed, int sensitivity, float wear) noexcept { - if (wear < done_wear) return false; - const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f; - const float t = std::clamp((wear - done_wear) / (1.0f - done_wear), 0.0f, 1.0f); - return down_speed >= (6.5f - 3.0f * t) / s; -} - -// ---- the board on a rail ------------------------------------------------------------------------ -enum class Contact { grind, slide, darkslide }; -constexpr std::array contact_names{"grind", "slide", "darkslide"}; -// From the board's long axis and its up axis in the world, and how it moves: a board lying across -// its way (a boardslide, a lipslide) slides, one on its grip is in a darkslide, the rest grind on -// their trucks. A board that hardly moves grinds. -inline Contact contact_of(Vec along, Vec up, Vec velocity) noexcept { - if (up.y < -0.35f) return Contact::darkslide; - const float a = std::hypot(along.x, along.z), v = std::hypot(velocity.x, velocity.z); - if (a < 0.2f || v < 0.5f) return Contact::grind; - const float cosine = std::abs((along.x * velocity.x + along.z * velocity.z) / (a * v)); - return cosine < 0.5f ? Contact::slide : Contact::grind; -} -// How likely a board snaps as it comes down onto a rail in a slide, by how fast it was coming down -// (m/s): from 3 m/s up (a hop onto a rail is about 2), certain 3 m/s above that; a darkslide on the -// grip goes sooner. Grinds on the trucks do not snap a board. Sensitivity and wear as for a slam. -inline float rail_chance(float down_speed, Contact contact, int sensitivity, float wear) noexcept { - if (contact == Contact::grind) return 0.0f; - const float s = std::clamp(sensitivity, sensitivity_min, sensitivity_max) / 100.0f; - const float strength = 1.0f - (1.0f - worn_strength) * std::clamp(wear, 0.0f, 1.0f); - const float start = (contact == Contact::darkslide ? 2.2f : 3.0f) * strength / s; - const float span = 3.0f * strength / s; - return std::clamp((down_speed - start) / span, 0.0f, 1.0f); -} - -// ---- where a board breaks ----------------------------------------------------------------------- -// From how the board came down: nose first it breaks at the nose, tail first at the tail, flat (or -// across a rail) in the middle. `slope` is how far the nose points down (the board's long axis' -// downward part, -1 to 1); `roll` is 0 to 1, chance. Flat landings still break at an end now and -// then, as real boards do. -inline Place place_of_break(float slope, bool across_rail, float roll) noexcept { - if (across_rail) return Place::mid; - if (slope > 0.35f) return roll < 0.75f ? Place::nose : Place::mid; - if (slope < -0.35f) return roll < 0.75f ? Place::tail : Place::mid; - return roll < 0.5f ? Place::mid : roll < 0.75f ? Place::nose : Place::tail; -} - -// ---- a body's hit, for its sound ----------------------------------------------------------------- -// The ragdoll's hits along their normal (Extension/HallOfMeat/hall_of_meat_model.h): a graze is 2 to -// 3 m/s, a fall on flat ground 3 to 9, a drop from height 13 and more. Below `body_hit` nothing is -// heard; the loudness rises to full at `body_full`. -inline constexpr float body_hit = 3.0f, body_full = 11.0f; -inline float body_loudness(float speed) noexcept { - if (speed < body_hit) return 0.0f; - return std::clamp(0.3f + 0.7f * (speed - body_hit) / (body_full - body_hit), 0.3f, 1.0f); -} -} diff --git a/Extension/BoardBreak/board_break_model.h b/Extension/BoardBreak/board_break_model.h deleted file mode 100644 index 1f17ac62..00000000 --- a/Extension/BoardBreak/board_break_model.h +++ /dev/null @@ -1,225 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include - -// Board Break's rules and geometry, apart from the game: where a board breaks, what is left of it, -// and where each part of a broken board lies. Everything is measured on the deck, in the deck -// model's own axes: metres, y up, z along the board with the nose at +z. -namespace dingosdk::board_break { -// ---- rigid motions ---------------------------------------------------------------------------- -// Quaternions are x, y, z, w (Hamilton), as the game's poses store them. -struct Vec { - float x{}, y{}, z{}; -}; -struct Quat { - float x{}, y{}, z{}, w{1.0f}; -}; -constexpr Vec operator+(Vec a, Vec b) noexcept { return {a.x + b.x, a.y + b.y, a.z + b.z}; } -constexpr Vec operator-(Vec a, Vec b) noexcept { return {a.x - b.x, a.y - b.y, a.z - b.z}; } -constexpr Vec operator*(Vec a, float s) noexcept { return {a.x * s, a.y * s, a.z * s}; } -inline float length(Vec a) noexcept { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); } -constexpr Quat operator*(Quat a, Quat b) noexcept { // b first, then a - return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, - a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z}; -} -constexpr Quat conj(Quat q) noexcept { return {-q.x, -q.y, -q.z, q.w}; } -inline Quat unit(Quat q) noexcept { - const auto n = std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w); - return n > 1e-6f ? Quat{q.x / n, q.y / n, q.z / n, q.w / n} : Quat{}; -} -inline Quat about(Vec axis, float radians) noexcept { - const auto s = std::sin(radians / 2); - return {axis.x * s, axis.y * s, axis.z * s, std::cos(radians / 2)}; -} -constexpr Vec turn(Quat q, Vec v) noexcept { - const Vec u{q.x, q.y, q.z}; - const Vec c{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x}; - const Vec d{u.y * c.z - u.z * c.y, u.z * c.x - u.x * c.z, u.x * c.y - u.y * c.x}; - return v + c * (2 * q.w) + d * 2; -} -// Turned, then shifted. -struct Rigid { - Quat facing{}; - Vec at{}; -}; - -// ---- breaks ----------------------------------------------------------------------------------- -// A deck breaks across one of three rows: just ahead of the front bolts, in the middle, just behind -// the back bolts. That cuts it into four stretches; each truck rides on the stretch its bolts are in. -enum Piece : std::uint32_t { nose = 1, front = 2, back = 4, tail = 8, front_truck = 16, back_truck = 32 }; -constexpr std::uint32_t whole_board = nose | front | back | tail | front_truck | back_truck; -constexpr std::uint32_t stretches = nose | front | back | tail; -enum class Place { nose, mid, tail }; -constexpr std::array place_names{"nose", "mid", "tail"}; -constexpr float nose_row = 0.260f, mid_row = 0.009f, tail_row = -0.260f; -// How the grip tape holds a break together: not at all, or with the nose hanging, the board folded -// in the middle, the tail hanging. -enum class Hinge { none, nose, fold, tail }; -// What a break leaves: the piece the board keeps, the piece that comes off and flies (none when -// the grip tape holds), and how the two hang together. -struct Break { - std::uint32_t kept{}, loose{}; - Hinge hinge{}; -}; -constexpr Break broken_at(Place place, bool joined) noexcept { - if (joined) return {whole_board, 0, place == Place::nose ? Hinge::nose : place == Place::mid ? Hinge::fold : Hinge::tail}; - // A clean break: the board keeps the larger piece, and of two halves the front one. - switch (place) { - case Place::nose: return {whole_board & ~static_cast(nose), nose, Hinge::none}; - case Place::mid: return {nose | front | front_truck, back | tail | back_truck, Hinge::none}; - default: return {whole_board & ~static_cast(tail), tail, Hinge::none}; - } -} -// How big a break is, for its sound. An end that hangs on by the grip tape is a small one; an end -// that comes off, or a board that folds a little, is in between; a board that folds right down or -// comes apart in the middle is the biggest. `fold_depth` is 0 to 1, how far a fold sags. -enum class Size { light, medium, heavy }; -constexpr std::array size_names{"light", "medium", "heavy"}; -constexpr Size break_size(Place place, bool joined, float fold_depth) noexcept { - if (place == Place::mid) return !joined || fold_depth >= 0.7f ? Size::heavy : Size::medium; - return joined ? Size::light : Size::medium; -} -// How likely a slam of `force` breaks the board: never up to 60% of `heavy`, always from `heavy` up, -// rising smoothly in between. A board only breaks from a hit the player can feel was a big one. -constexpr float break_chance(float force, float heavy) noexcept { - if (heavy <= 0 || force >= heavy) return 1.0f; - const auto low = 0.6f * heavy; - if (force <= low) return 0.0f; - const auto t = (force - low) / (heavy - low); - return t * t * (3.0f - 2.0f * t); -} - -// ---- where the parts of a broken board lie ------------------------------------------------------ -constexpr float axle_height = 0.028f, front_axle = 0.2226f, back_axle = -0.2225f; -// How far each half of a board tips: a touch short of where its first splinter meets the ground -// (0.351 and 0.325 rad, measured on the mesh), so the end rests on the ground instead of digging in. -constexpr float front_tip = 0.340f, back_tip = 0.315f; -// Half a board on one truck does not stand level: it tips about its axle until the broken end lies -// on the ground. `towards` is +1 when the broken end is on the nose side of that axle. -inline Rigid tipped_on_axle(float axle_z, float towards, float angle) noexcept { - const auto turned = about({1, 0, 0}, towards * angle); - const Vec axle{0, axle_height, axle_z}; - return {turned, axle - turn(turned, axle)}; -} -// A piece that hangs on by the grip tape: turned about the break line just under the deck's top, -// and drawn a hair away from the board, so the tape opens into a ragged crack with the wood of the -// broken end in it. `towards` is +1 for the nose side of the break. -constexpr float crack = 0.0025f; // how far the tape gapes along the board (m) -constexpr float grip_height = 0.0935f; -inline Rigid hung_on_grip(float break_z, float towards, float angle) noexcept { - const auto turned = about({1, 0, 0}, towards * angle); - const Vec hinge{0, grip_height - 0.004f, break_z}; - return {turned, hinge - turn(turned, hinge) + Vec{0, 0, towards * crack * 0.5f}}; -} -// A board folded in the middle: each half turns about its own axle, the front by `depth` of the -// way to the ground and the back as far as brings its grip to the same height, and the two move -// along the board until the grip of both all but meets again at the break, where the tape holds. -inline void folded(float depth, Rigid& front_half, Rigid& back_half) noexcept { - constexpr float grip_y = 0.095f; - constexpr float rise = grip_y - axle_height, ahead = front_axle - mid_row, behind = mid_row - back_axle; - const float a = front_tip * std::clamp(depth, 0.0f, 1.0f); - const float height = rise * std::cos(a) - ahead * std::sin(a); // of the grip at the break, above the axles - const float b = std::acos(std::clamp(height / std::hypot(rise, behind), -1.0f, 1.0f)) - std::atan2(behind, rise); - front_half = tipped_on_axle(front_axle, -1.0f, a); - back_half = tipped_on_axle(back_axle, 1.0f, b); - const float front_end = front_axle - (rise * std::sin(a) + ahead * std::cos(a)); - const float back_end = back_axle + (rise * std::sin(b) + behind * std::cos(b)); - front_half.at.z += (back_end - front_end + crack) * 0.5f; // and a crack's width more: the tape gapes - back_half.at.z -= (back_end - front_end + crack) * 0.5f; -} -// The deck sits on the board's rig a quarter turn about the upright axis (the rig's mount carries -// it), and the stretches hang on the rig's root: a motion worked out on the deck, carried over to -// the root's axes. -inline Rigid on_root(Quat mount, const Rigid& motion) noexcept { - return {unit(mount * motion.facing * conj(mount)), turn(mount, motion.at)}; -} - -// Where something goes that is not to be seen: far below the board. The game's replay keeps where -// a bone is and how it is turned, and nothing of its scale, so a part is never hidden by shrinking. -constexpr Vec away{0.0f, -2000.0f, 0.0f}; -// One of the four stretches of deck (nose, front, back, tail, in that order everywhere): whether -// it shows, and its motion from the place it has in a whole board. -struct Stretch { - bool shown{}; - Rigid motion{}; -}; -// A broken board as it lies: its stretches and its trucks, in the rig root's axes. -struct Layout { - std::array stretch{}; - bool front_truck{}, back_truck{}; - Rigid front_motion{}, back_motion{}; -}; -// The piece a board keeps (`kept`, Piece bits), hanging together as `hinge` says. `angle` is how far -// an end droops (radians) or how deep a fold sags (0 to 1); `up` is how far the board is the right -// way up (1 on its wheels, -1 on its grip); `mount` is the rig's mount turn. -inline Layout lay_out(std::uint32_t kept, Hinge hinge, float angle, float up, Quat mount) noexcept { - // `ahead` moves the front of the board (and all of it unless told otherwise), `rear` the back half. - Rigid ahead{}, rear{}, hung{}; - if (hinge == Hinge::fold) { - // The break sags towards the ground under the board. On its grip the fold would push nose - // and tail into the ground instead, so there it stays shallow. - const auto level = std::clamp((up + 0.2f) / 0.8f, 0.0f, 1.0f); - folded(angle * (0.3f + 0.7f * level), ahead, rear); - } else if (hinge == Hinge::nose) hung = hung_on_grip(nose_row, 1.0f, angle); - else if (hinge == Hinge::tail) hung = hung_on_grip(tail_row, -1.0f, angle); - else if (static_cast(kept & front_truck) != static_cast(kept & back_truck)) { - // Half a deck on one truck: while the board is the right way up it tips onto its broken end - // instead of standing level in the air. - const auto level = std::clamp((up - 0.3f) / 0.5f, 0.0f, 1.0f); - const auto amount = level * level * (3.0f - 2.0f * level); - ahead = kept & front_truck ? tipped_on_axle(front_axle, -1.0f, front_tip * amount) : tipped_on_axle(back_axle, 1.0f, back_tip * amount); - } - ahead = on_root(mount, ahead); - hung = on_root(mount, hung); - rear = hinge == Hinge::fold ? on_root(mount, rear) : ahead; - Layout out; - out.stretch = {{{(kept & nose) != 0, hinge == Hinge::nose ? hung : ahead}, - {(kept & front) != 0, ahead}, - {(kept & back) != 0, rear}, - {(kept & tail) != 0, hinge == Hinge::fold ? rear : hinge == Hinge::tail ? hung : ahead}}}; - out.front_truck = (kept & front_truck) != 0; - out.back_truck = (kept & back_truck) != 0; - out.front_motion = ahead; - out.back_motion = rear; - return out; -} -// What goes into the stretches' bones. The first three hang on the rig's root; the tail's hangs on -// the front stretch's, so its place is given from there. A stretch that does not show goes away. -inline std::array bones_of(std::array stretch) noexcept { - for (auto& one : stretch) - if (!one.shown) one.motion = {{}, away}; - const auto& from = stretch[1].motion; - const auto& to = stretch[3].motion; - return {{stretch[0].motion, stretch[1].motion, stretch[2].motion, - {unit(conj(from.facing) * to.facing), turn(conj(from.facing), to.at - from.at)}}}; -} -// A truck hangs on the deck's bone, which is moved `away` to hide the real deck, and that bone sits -// on the mount. To move the truck with a piece: the piece's motion seen from the deck, the deck's -// move taken back. `steer` is the truck's own turn, which it keeps; `place` where it sits on the deck. -constexpr Vec back_truck_place{0.0005f, -0.0183f, -0.2345f}, front_truck_place{0.0005f, -0.0183f, 0.2347f}; -inline Rigid truck_bone(const Rigid& motion, Quat mount, Vec mount_at, Quat steer, Vec place) noexcept { - const auto facing = unit(conj(mount) * motion.facing * mount); - const auto at = turn(conj(mount), turn(motion.facing, mount_at) + motion.at - mount_at); - return {unit(facing * steer), turn(facing, place) + at - away}; -} -// The trucks' baseplates. The game's own truck model has them on the deck's bone, which goes away -// with the real deck when the board breaks, and they went with it. A deck pack's truck model has -// each baseplate on its truck's effector instead, a bone under the truck's that no model uses: -// wherever a piece carries the truck, its baseplate goes along. This is where that effector goes -// so that the baseplate sits on the deck however the truck steers: the truck's own turn and place -// taken back, its rest put in. `truck` is the truck's bone as the animation left it this frame. -struct TruckRest { - Quat facing; // the truck's bone on the deck's, at rest - Vec at; - Vec effector; // its effector on the truck's bone, at rest (not turned) -}; -constexpr TruckRest back_truck_rest{{-0.3641458f, -0.3641458f, -0.6061335f, 0.6061335f}, {0.0005361f, -0.0183003f, -0.2344986f}, {0.0395021f, 0.0f, 0.0f}}; -constexpr TruckRest front_truck_rest{{-0.6061674f, 0.6061674f, 0.3640894f, 0.3640894f}, {0.0005361f, -0.0182917f, 0.2346680f}, {0.0395677f, 0.0f, 0.0f}}; -inline Rigid baseplate_bone(const Rigid& truck, const TruckRest& rest) noexcept { - const auto back = conj(truck.facing); - return {unit(back * rest.facing), turn(back, rest.at + turn(rest.facing, rest.effector) - truck.at)}; -} -} // namespace dingosdk::board_break diff --git a/Extension/Console/commands.cpp b/Extension/Console/commands.cpp index 13c16d16..dc8a999e 100644 --- a/Extension/Console/commands.cpp +++ b/Extension/Console/commands.cpp @@ -229,8 +229,7 @@ const Commands &game_commands() { register_item_commands(*result); register_trainer_commands(*result); register_hall_of_meat_commands(*result); - register_road_rash_commands(*result); - register_board_break_commands(*result); + register_breakable_deck_commands(*result); register_grind_exit_commands(*result); register_replay_keyframes_commands(*result); register_plugin_commands(*result); diff --git a/Extension/Console/commands.h b/Extension/Console/commands.h index 01761b4b..c0ed9264 100644 --- a/Extension/Console/commands.h +++ b/Extension/Console/commands.h @@ -27,8 +27,7 @@ void start_memory_trace_from_environment() noexcept; void register_item_commands(Commands &); void register_trainer_commands(Commands &); void register_hall_of_meat_commands(Commands &); -void register_road_rash_commands(Commands &); -void register_board_break_commands(Commands &); +void register_breakable_deck_commands(Commands &); void register_grind_exit_commands(Commands &); void register_replay_keyframes_commands(Commands &); void register_plugin_commands(Commands &); diff --git a/Extension/Multiplayer/Hud/custom_nametags.cpp b/Extension/Multiplayer/Hud/custom_nametags.cpp index 54c66f84..90476a68 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.cpp +++ b/Extension/Multiplayer/Hud/custom_nametags.cpp @@ -57,7 +57,6 @@ 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.extra = player.extra; tag.extra_color = player.extra_color; tag.bubbles.reserve(player.bubbles.size()); diff --git a/Extension/Multiplayer/Hud/custom_nametags.h b/Extension/Multiplayer/Hud/custom_nametags.h index 80871d9f..578f2efd 100644 --- a/Extension/Multiplayer/Hud/custom_nametags.h +++ b/Extension/Multiplayer/Hud/custom_nametags.h @@ -24,7 +24,6 @@ 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) // A plugin's short tag for the player (reskate.session.tag), above the name; "" for none. std::string extra; std::uint32_t extra_color{0xffffffffU}; diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index d738b97c..7fbd0f17 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -38,6 +38,8 @@ void update_party_position(const Pose *pose) noexcept; // Points the party Spectate camera at a session player's skater from code (the // throwdown turn camera). 0 stops a spectate this function started. Game thread. void spectate_party_member(std::uint64_t id) noexcept; +// Whether Spectate holds the camera, or is giving it back: a plugin's camera waits. Game thread. +bool party_spectating() noexcept; // Returns only a record owned by this adapter, validated against this manager. std::uint64_t native_party_player_info(std::uintptr_t manager, std::size_t slot) noexcept; std::string native_party_status(); diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index 398724c8..3c3e77a9 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -873,6 +873,10 @@ void spectate_party_member(std::uint64_t id) noexcept { } } catch (...) {} } +bool party_spectating() noexcept { + auto &s = state(); + return s.spectating.load(std::memory_order_acquire) != 0 || s.camera_owned; +} void tick_native_party_actions(std::uintptr_t base, std::uintptr_t client, bool ready, bool camera_phase) noexcept { auto &s = state(); if (!s.installed.load(std::memory_order_acquire)) return; diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index aa743ca6..edf875a8 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -422,9 +422,6 @@ 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) @@ -500,8 +497,7 @@ 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::drone) + p.kind != PacketKind::next_maps && p.kind != PacketKind::typing) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -642,11 +638,6 @@ 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()); @@ -758,7 +749,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(1, 1); // was whether players saw each other's FPV drones; read and passed over 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. @@ -1021,14 +1012,6 @@ 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) @@ -1215,9 +1198,7 @@ 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; + if (r.integer(1) > 1) return {}; // the FPV drones' old switch 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 50b06183..99be6cf3 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -151,26 +151,8 @@ enum class PacketKind : std::uint16_t { // 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, - // 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 + // 36 was the FPV drone's marker, before the drone became a plugin: not used again. }; -// 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 @@ -351,9 +333,6 @@ 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{}; @@ -399,9 +378,6 @@ 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/Remote/drone_sound.cpp b/Extension/Multiplayer/Remote/drone_sound.cpp deleted file mode 100644 index ff9ebdf3..00000000 --- a/Extension/Multiplayer/Remote/drone_sound.cpp +++ /dev/null @@ -1,161 +0,0 @@ -#include "drone_sound.h" -#include "native_prop.h" -#include "Engine/Core/Log/logging.h" -#include "Extension/Multiplayer/Voice/native_voice.h" -#include "Extension/Skater/client_source_spawn.h" -#include "Extension/Skater/drone_audio.h" -#include "Extension/Skater/drone_beacon.h" -#include -#include -#include -#include -#include -#include -#include - -namespace dingosdk::multiplayer { -namespace { -using Clock = std::chrono::steady_clock; -// How far away a drone is heard: a 5-inch at full power carries well past a skate spot. -constexpr float hearing_distance = 120.0f; -// Other players' drones a little under the pilot's own motor at the same volume setting. -constexpr float remote_level = 0.3f; -constexpr float own_level = 1.0f; -// Full volume only this close; from there it falls off with distance (about -6 dB each time the -// distance doubles), faded to nothing over the last quarter of the hearing distance. -constexpr float full_volume_distance = 1.5f; -float drone_gain(const std::array &ear, const std::array &at) { - float squared = 0.0f; - for (std::size_t i = 0; i < 3; ++i) squared += (ear[i] - at[i]) * (ear[i] - at[i]); - const float distance = std::sqrt(squared); - if (!std::isfinite(distance) || distance >= hearing_distance) return 0.0f; - float gain = full_volume_distance / std::max(distance, full_volume_distance); - const float fade_start = hearing_distance * 0.75f; - if (distance > fade_start) gain *= 1.0f - (distance - fade_start) / (hearing_distance - fade_start); - return gain; -} -constexpr std::uint64_t drone_source_tag = 0x52534b4400000000ULL; // "RSKD" -// The dronemodeltest prop's sound (players are keyed by their nonzero ids). -constexpr std::uint64_t test_drone = 0; -// The pilot's own drone, played here too so it goes through the game's sound like everything else. -constexpr std::uint64_t own_drone = ~0ULL; -// Sound made ahead of time for a drone just heard, so the first blocks do not run dry. -constexpr double preroll_seconds = 0.06; -// At most this much sound made in one frame (a long hitch is not caught up). -constexpr float longest_frame = 0.1f; - -struct Voice { - drone::audio::MotorSynth synth{static_cast(native_voice_rate)}; - double owed{}; // samples due and not yet made (the fraction carries to the next frame) -}; -struct Sounds { - NativeVoicePlayback playback{drone_source_tag}; - std::map voices; - bool started{}, announced{}; - Clock::time_point last{}, test_started{}; - std::mutex status_mutex; - std::string status = "No other player's drone is in the air."; -}; -// Never destroyed: its sounds belong to the game, which is gone by then. -Sounds &sounds() { - static auto *s = new Sounds; - return *s; -} -void set_status(Sounds &s, std::string text) { - std::lock_guard lock(s.status_mutex); - s.status = std::move(text); -} -struct Source { - std::uint64_t id; - std::array position; - float throttle, speed; - float pitch = 1.0f; - float gain = -1.0f; // set: this loud wherever it is (the pilot's own); else by its distance -}; -} // namespace - -void tick_drone_sounds(std::uintptr_t base, std::uintptr_t client, bool ready, bool show) noexcept { - auto &s = sounds(); - try { - const auto now = Clock::now(); - if (!s.started) { - s.playback.reset(1); - s.started = true; - s.last = now; - } - const float elapsed = std::clamp(std::chrono::duration(now - s.last).count(), 0.0f, longest_frame); - s.last = now; - const float volume = drone::knobs().value[drone::kVolume].load(std::memory_order_relaxed); - std::vector sources; - std::array camera{}; - if (ready && volume > 0.001f && read_local_camera_transform(base, client, camera)) { - // The pilot's own drone: the view is its camera, so it sits just under and ahead of it. - if (drone::flying() && drone::telemetry().armed.load(std::memory_order_relaxed)) { - const auto &t = drone::telemetry(); - const std::array at{camera[12] - camera[8] * 0.2f, camera[13] - camera[9] * 0.2f - 0.1f, - camera[14] - camera[10] * 0.2f}; - sources.push_back({own_drone, at, t.throttle.load(std::memory_order_relaxed), t.speed.load(std::memory_order_relaxed), - drone::knobs().value[drone::kMotorPitch].load(std::memory_order_relaxed), own_level}); - } - if (show) - for (const auto &drone : drone::beacon::snapshot()) - sources.push_back({drone.id, drone.position, drone.throttle, drone.speed}); - if (const auto at = prop_test_position()) { - // Going round its circle with the throttle rising and falling, as a drone would. - if (!s.voices.contains(test_drone)) s.test_started = now; - const float t = std::chrono::duration(now - s.test_started).count(); - sources.push_back({test_drone, *at, 0.35f + 0.25f * std::sin(t * 1.3f), 3.1f}); - } - } - const std::array ear{camera[12], camera[13], camera[14]}; - std::erase_if(s.voices, [&](const auto &item) { - return std::none_of(sources.begin(), sources.end(), [&](const Source &x) { return x.id == item.first; }); - }); - std::vector samples; - for (const auto &source : sources) { - const float gain = volume * (source.gain >= 0.0f ? source.gain : remote_level * drone_gain(ear, source.position)); - if (!(gain > 0.0f)) { - s.voices.erase(source.id); - continue; // its sound is released once it has had no position for a moment - } - auto [found, added] = s.voices.try_emplace(source.id); - auto &voice = found->second; - if (added) voice.owed = preroll_seconds * native_voice_rate; - voice.owed += static_cast(elapsed) * native_voice_rate; - const auto count = static_cast(std::min(std::floor(voice.owed), static_cast(native_voice_rate) * longest_frame)); - voice.owed = std::max(0.0, voice.owed - count); - s.playback.position(source.id, 1, source.position, gain); - if (count <= 0) continue; - samples.resize(static_cast(count)); - voice.synth.render(samples.data(), count, source.throttle, source.speed, 1.0f, source.pitch); - s.playback.submit(source.id, 1, {reinterpret_cast(samples.data()), samples.size() * 2}); - } - s.playback.process(base); - // The game's sound has the pilot's motor: the drone's own Windows output stays quiet. - if (s.playback.available() && s.voices.contains(own_drone)) - drone::audio::game_sound_ms().store(drone::now_ms(), std::memory_order_relaxed); - if (!s.voices.empty() && !s.announced && s.playback.available()) { - s.announced = true; - logging::log(logging::Level::info, logging::Channel::runtime, - "Other players' drones are heard through the game's positioned voice sound."); - } - const auto others = s.voices.size() - (s.voices.contains(own_drone) ? 1 : 0); - if (others == 0) - set_status(s, "No other player's drone is in the air within earshot."); - else if (!s.playback.available()) - set_status(s, std::format("{} drone(s) within earshot, but the game's positioned voice sound is not ready, so they are silent.", - others)); - else - set_status(s, std::format("{} drone(s) heard.", others)); - } catch (const std::exception &e) { - s.voices.clear(); - set_status(s, std::string("Drone sounds stopped: ") + e.what()); - } -} - -std::string drone_sounds_status() { - auto &s = sounds(); - std::lock_guard lock(s.status_mutex); - return s.status; -} -} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/drone_sound.h b/Extension/Multiplayer/Remote/drone_sound.h deleted file mode 100644 index 74c405b4..00000000 --- a/Extension/Multiplayer/Remote/drone_sound.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include -#include - -// Other players' FPV drones heard where they fly. Each drone's motor is synthesised here -// (Skater/drone_audio.h, at its estimated throttle and speed: drone_beacon.h) and played through -// the game's positioned voice sound, the one proximity chat uses (Voice/native_voice.h), so the -// game places it, muffles it behind walls and gives it the level's reverb. -namespace dingosdk::multiplayer { -// The client update thread, every frame (in a level or not: sounds stop when `ready` or `show` -// goes false). `show`: other players' drones are shown (DRONE page, LOBBY tab). The -// dronemodeltest prop, while it runs, is heard as a drone too. -void tick_drone_sounds(std::uintptr_t base, std::uintptr_t client, bool ready, bool show) noexcept; -std::string drone_sounds_status(); -} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_prop.cpp b/Extension/Multiplayer/Remote/native_prop.cpp deleted file mode 100644 index 5593cf45..00000000 --- a/Extension/Multiplayer/Remote/native_prop.cpp +++ /dev/null @@ -1,444 +0,0 @@ -#include "native_prop.h" -#include "native_skater_internal.h" -#include "native_creation_list.h" -#include "Engine/Core/Log/logging.h" -#include "Engine/Game/Abi/native_data.h" -#include "Engine/Game/Build/20260929/engine.h" -#include "Engine/Game/Build/20260929/world_props.h" -#include "Extension/Skater/drone_beacon.h" -#include -#include -#include - -namespace dingosdk::multiplayer { -using namespace native_skater_detail; -namespace props = addr::world_props; -namespace { -// ---- Placed props, and the engine taking them away -// Every prop entity placed and not yet destroyed. The engine's destruction hook takes an entity -// out before it is freed, under the same lock that every move and removal holds, so nothing -// moves a freed prop. (Recursive: a removal is a destruction of our own.) -struct Registry { - std::recursive_mutex mutex; - std::vector live; - std::atomic count{}; -}; -Registry ®istry() { - static auto *r = new Registry; - return *r; -} -bool tracked(std::uintptr_t entity) { - auto &r = registry(); - return entity && std::find(r.live.begin(), r.live.end(), entity) != r.live.end(); -} -void track(std::uintptr_t entity) { - auto &r = registry(); - r.live.push_back(entity); - r.count.store(r.live.size(), std::memory_order_release); -} -void untrack(std::uintptr_t entity) { - auto &r = registry(); - std::erase(r.live, entity); - r.count.store(r.live.size(), std::memory_order_release); -} - -struct Prop { - std::uintptr_t entity{}, parent{}, context{}; -}; -// A blueprint the way the cone's is one (world_props.h), or the skateboard's or the skater -// code's; `chain` gets what it was, for the log. -bool is_blueprint(std::uintptr_t base, std::uintptr_t object, std::string &chain) { - const auto vtable = ptr(object); - chain = std::format("vtable Skate+{:#x}", vtable - base); - if (vtable == base + props::object_blueprint_vtable || vtable == base + addr::engine::blueprint_vtable) return true; - auto type = ptr(object, 8); - for (int depth = 0; depth < 8 && type; ++depth) { - if (type == base + addr::engine::board_blueprint_type || type == base + entities::blueprint_type) return true; - const auto super = ptr(type, 0x20); - if (super == type) break; - type = super; - } - return false; -} -// A loaded named blueprint, from whichever asset domain holds it; 0 when none does. -std::uintptr_t find_named_blueprint(std::uintptr_t base, std::string_view name) { - const auto find = game::native_data().find_asset; - require(find != nullptr, "Native asset lookup is unavailable."); - const std::string text(name); - for (std::uint16_t domain = 0; domain < 0xbbf; ++domain) { - if (!ptr(base, addr::engine::domain_owners + domain * std::uintptr_t{8})) continue; - if (const auto asset = find(domain, text.c_str())) { - std::string chain; - if (!is_blueprint(base, asset, chain)) - throw std::runtime_error(std::format("{} is not a blueprint ({}).", name, chain)); - return asset; - } - } - return 0; -} -// Whether entities can be created now, on this thread: the hooks are in, the engine is ready -// and the client's job context is this one. Throws only when something is wrong; false is "wait", -// and `why` (when given) gets what is waited for. -bool can_create(std::uintptr_t base, const NativeFrame &local, std::string *why = nullptr) { - install(base); - require(shared().hooks, "The native entity hooks are not installed."); - require(GetCurrentThreadId() == shared().engine_thread, "Props are created on the client thread."); - const auto wait = [&](std::string reason) { - if (why) *why = std::move(reason); - return false; - }; - if (read(base, addr::engine::entity_creation_ready) == 0) return wait("the engine is not ready to create entities"); - const auto tls_index = read(base, addr::engine::tls_index); - require(tls_index <= 4095, "Native TLS index changed."); - const auto tls = ptr(static_cast(__readgsqword(0x58)), std::uintptr_t{tls_index} * 8); - if (read(tls, 0xb19) == 0) return wait("this thread may not create entities now"); - if (const auto context = ptr(tls, 0x550); context != local.context) - return wait(std::format("the thread's job context {:#x} is not the local skater's {:#x}", context, local.context)); - return true; -} -// As the remote skateboard is made (native_skater_spawn.cpp): a fresh entity through a creation -// list, the list initialized, then placed with its lifecycle callbacks. `kinds` gets the vtable -// of each entity the list made. Under the registry lock. -Prop create_prop(std::uintptr_t base, const NativeFrame &local, std::uintptr_t blueprint, const std::array &where, - std::string *kinds = nullptr) { - alignas(16) const auto matrix = where; - NativeCreationList list{}; - alignas(16) std::array descriptor{}; - using Init = void *(*)(void *, std::uintptr_t, std::uintptr_t, const void *); - reinterpret_cast(base + entities::descriptor_init)(descriptor.data(), 0, local.parent, matrix.data()); - struct DescriptorScope { - std::uintptr_t base; - void *data; - ~DescriptorScope() { reinterpret_cast(base + entities::descriptor_destroy)(static_cast(data) + 0x10); } - } scope{base, descriptor.data()}; - const std::uint32_t id = 255; // local, unassociated - std::memcpy(descriptor.data() + 0x38, &id, sizeof(id)); - descriptor[0x151] = 0; - auto *list_pointer = &list; - std::memcpy(descriptor.data() + 0x158, &list_pointer, sizeof(list_pointer)); - std::array result{}; - using Create = void *(*)(void *, void *, std::uintptr_t, std::uintptr_t, std::uintptr_t); - reinterpret_cast(base + entities::create_entity)(result.data(), descriptor.data(), blueprint, 0, 0); - if (result[1]) reinterpret_cast(base + entities::release_reference)(result[1]); - const auto created = read_native_creation_list(readable, list); - if (kinds) - for (const auto entity : created.entities) *kinds += std::format(" Skate+{:#x}", ptr(entity) - base); - if (!created.pages.empty()) reinterpret_cast(base + native::initialize_creation_list)(&list); - for (const auto page : created.pages) { - const std::uint32_t empty = 0; - (void)write(page + 8, &empty, sizeof(empty)); - reinterpret_cast(base + native::free_creation_page)(&list, page, 0x1f0); - } - require(result[0] != 0, "The engine did not create the prop."); - using InitializePlacement = void (*)(std::uintptr_t, const void *, std::uintptr_t, std::uint8_t); - reinterpret_cast(base + entities::initialize_placement)(result[0], matrix.data(), 0, 1); - track(result[0]); - return {result[0], ptr(result[0], 0x40), local.context}; -} -// The plain entity transform setter: what moves a prop smoothly (world_props.h). Under the lock. -void place(std::uintptr_t base, const Prop &prop, const std::array &where) { - alignas(16) const auto matrix = where; - reinterpret_cast(base + entities::place_entity)(prop.entity, matrix.data()); -} -// Under the lock. One the engine already took is only forgotten. -void destroy(const Prop &prop) { - if (!tracked(prop.entity)) return; - untrack(prop.entity); - if (shared().hooks && ptr(prop.entity, 0x20) == prop.context) shared().original_destroy(prop.entity, prop.parent); -} -std::array matrix_of(const std::array &position, const std::array &rotation) { - Transform t; - t.position = position; - t.rotation = rotation; - return to_matrix(t); -} - -// ---- Other players' drones -struct DroneModel { - std::uint64_t id{}; - Prop prop; -}; -struct Models { - std::vector list; // client thread - std::uintptr_t context{}, blueprint{}; // the map the models are on, and what they are made of - std::string blueprint_name; - ULONGLONG next_lookup{}; - ULONGLONG waiting_since{}; // since when a flying drone has had no model, 0 while none has - std::mutex status_mutex; - std::string status = "No other player is flying a drone."; -}; -Models &models() { - static auto *m = new Models; - return *m; -} -void set_models_status(std::string text, bool log) { - auto &m = models(); - { - std::lock_guard lock(m.status_mutex); - if (m.status == text) return; - m.status = text; - } - if (log) logging::log(logging::Level::info, logging::Channel::runtime, "Drone models: {}", text); -} - -// ---- The test that found how -struct Request { - std::string blueprint; // empty: stop - int mode{}; -}; -struct PropTest { - std::mutex mutex; - std::optional request; - std::optional fake_request; // start or stop the pretend other player's drone - bool fake{}; // client thread: it flies - std::array fake_anchor{}; - ULONGLONG fake_started{}; - std::string status = "Not running. Console: dronemodeltest [blueprint] [mode 0-2], dronemodeltest stop"; - Prop prop; // client thread - std::array anchor{}, position{}; - ULONGLONG started{}; - unsigned frames{}; - int mode{}; -}; -PropTest &test() { - static auto *t = new PropTest; - return *t; -} -void set_test_status(std::string text) { - logging::log(logging::Level::info, logging::Channel::runtime, "Drone model test: {}", text); - std::lock_guard lock(test().mutex); - test().status = std::move(text); -} -std::array yawed(const std::array &position, float yaw) { - return matrix_of(position, {0.0f, std::sin(yaw / 2), 0.0f, std::cos(yaw / 2)}); -} -void test_spawn(std::uintptr_t base, std::uintptr_t client, const Request &request) { - auto &t = test(); - const auto local = capture_local(base, client, false); - require(local.ready && local.context && local.parent && local.entity, "The local skater is not ready."); - require(can_create(base, local), "Waiting for the engine to allow creating entities; try again."); - const auto blueprint = find_named_blueprint(base, request.blueprint); - require(blueprint != 0, "That blueprint is not loaded on this map."); - const auto root = to_matrix(local.pose.root); - const std::array at{local.pose.root.position[0] - root[8] * 4.0f, local.pose.root.position[1] + 2.0f, - local.pose.root.position[2] - root[10] * 4.0f}; - std::string kinds; - t.prop = create_prop(base, local, blueprint, yawed(at, 0.0f), &kinds); - t.anchor = at; - t.started = GetTickCount64(); - t.frames = 0; - t.mode = request.mode; - set_test_status(std::format("spawned {} at ({:.1f}, {:.1f}, {:.1f}): entity {:#x} (vtable Skate+{:#x}), made:{}; moving it with mode {}.", - request.blueprint, at[0], at[1], at[2], t.prop.entity, ptr(t.prop.entity) - base, kinds, t.mode)); -} -// Round a 3 m circle every 6 s, turning to face along it. -void test_move(std::uintptr_t base) { - auto &t = test(); - const float seconds = static_cast(GetTickCount64() - t.started) / 1000.0f; - const float angle = seconds * 6.2831853f / 6.0f; - const std::array at{t.anchor[0] + 3.0f * std::cos(angle), t.anchor[1] + 0.5f * std::sin(angle * 2.0f), - t.anchor[2] + 3.0f * std::sin(angle)}; - const auto matrix = yawed(at, -angle); - t.position = at; - if (t.mode >= 1 && t.prop.parent) { - const auto transform = ptr(t.prop.parent, 0x18); - alignas(16) const auto copy = matrix; - if (transform && ptr(transform) == base + native::blueprint_transform_vtable) - reinterpret_cast(base + native::set_blueprint_transform)(transform, copy.data()); - } - if (t.mode <= 1) place(base, t.prop, matrix); - if (++t.frames == 120) set_test_status(std::format("moved 120 frames with mode {}. Is it going round a circle?", t.mode)); -} -// The pretend other player's drone (dronemodeltest remote): its position goes into the beacon as -// a received one would, so the models (tick_drone_models) and the markers show it with no second -// player. It goes round a 4 m circle every 8 s, in front of where the skater was. -constexpr std::uint64_t fake_remote_id = 1; -// Whether real players' drones get a model (tick_drone_models): off until their crash is found. -constexpr bool players_drone_models = false; -void fake_remote_start(std::uintptr_t base, std::uintptr_t client) { - auto &t = test(); - const auto local = capture_local(base, client, false); - require(local.ready && local.entity, "The local skater is not ready."); - const auto root = to_matrix(local.pose.root); - t.fake_anchor = {local.pose.root.position[0] - root[8] * 6.0f, local.pose.root.position[1] + 2.5f, - local.pose.root.position[2] - root[10] * 6.0f}; - t.fake_started = GetTickCount64(); - t.fake = true; - set_test_status(std::format("a pretend other player's drone flies round ({:.1f}, {:.1f}, {:.1f}). 'dronemodeltest' says whether its model is shown.", - t.fake_anchor[0], t.fake_anchor[1], t.fake_anchor[2])); -} -void fake_remote_fly() { - auto &t = test(); - const float angle = static_cast(GetTickCount64() - t.fake_started) / 1000.0f * 6.2831853f / 8.0f; - const drone::beacon::Position at{t.fake_anchor[0] + 4.0f * std::cos(angle), t.fake_anchor[1] + 0.5f * std::sin(angle * 2.0f), - t.fake_anchor[2] + 4.0f * std::sin(angle)}; - const float yaw = -angle; - drone::beacon::receive(fake_remote_id, "Test drone", at, {0.0f, std::sin(yaw / 2), 0.0f, std::cos(yaw / 2)}, true); -} -void fake_remote_stop() { - auto &t = test(); - if (!t.fake) return; - t.fake = false; - drone::beacon::receive(fake_remote_id, {}, t.fake_anchor, drone::beacon::no_rotation, false); -} -} // namespace - -void prop_entity_destroyed(std::uintptr_t entity) noexcept { - auto &r = registry(); - if (!r.count.load(std::memory_order_acquire)) return; // every destruction passes through here - std::lock_guard lock(r.mutex); - if (tracked(entity)) untrack(entity); -} - -void tick_drone_models(std::uintptr_t base, std::uintptr_t client, bool show) noexcept { - auto &m = models(); - try { - std::lock_guard lock(registry().mutex); - // The engine took some away (a map change takes them all). - std::erase_if(m.list, [](const DroneModel &model) { return !tracked(model.prop.entity); }); - auto drones = show ? drone::beacon::snapshot() : std::vector{}; - // Real players' drones are markers only for now: a model made for one crashed the game a - // few seconds later in the per-frame update of its shader sections (Skate.exe+0x505b04b, - // 2.1.0, 2026-10-18). The pretend one (dronemodeltest remote) still gets its model, to find why. - const auto players = drones.size(); - if (!players_drone_models) - std::erase_if(drones, [](const drone::beacon::Remote &d) { return d.id != fake_remote_id; }); - if (drones.empty() && m.list.empty()) { - m.waiting_since = 0; - set_models_status(!show ? "Other players' drones are hidden." - : players ? std::format("{} other player(s) flying; shown as markers only.", players) - : std::string("No other player is flying a drone."), false); - return; - } - const auto now = GetTickCount64(); - // A drone flying for a few seconds with no model: why, in the log once. - const auto waiting = [&](const std::string &why) { - if (!m.waiting_since) m.waiting_since = now; - if (now - m.waiting_since >= 3000) - set_models_status(std::format("{} other player(s) flying; {} model(s) shown; waiting: {}.", drones.size(), m.list.size(), why), true); - }; - const auto local = capture_local(base, client, false); - if (!local.ready || !local.context || !local.parent) { - waiting(std::format("the local skater is not ready (ready {}, context {:#x}, parent {:#x})", local.ready, local.context, local.parent)); - return; - } - if (local.context != m.context) { - // Another map: whatever was there went with it, and the blueprint is found again. - m.list.clear(); - m.context = local.context; - m.blueprint = 0; - m.next_lookup = 0; - } - // Those who landed or left. - for (auto it = m.list.begin(); it != m.list.end();) { - const bool flying = std::any_of(drones.begin(), drones.end(), [&](const auto &d) { return d.id == it->id; }); - if (flying) { - ++it; - continue; - } - destroy(it->prop); - it = m.list.erase(it); - } - // What the models are made of: the drone's own model, else the stand-in. - if (!m.blueprint && !drones.empty() && now >= m.next_lookup) { - m.next_lookup = now + 5000; - for (const auto name : {drone_model_blueprint, drone_model_stand_in}) - if ((m.blueprint = find_named_blueprint(base, name))) { - m.blueprint_name = std::string(name); - break; - } - if (!m.blueprint) - set_models_status("Neither the drone model nor its stand-in is loaded on this map: other players' drones show as markers only.", true); - } - // One new model a frame at most, so a lobby taking off together does not stall a frame. - bool spawned = false; - std::string blocked; - for (const auto &drone : drones) { - const auto matrix = matrix_of(drone.position, drone.rotation); - const auto found = std::find_if(m.list.begin(), m.list.end(), [&](const DroneModel &model) { return model.id == drone.id; }); - if (found != m.list.end()) { - place(base, found->prop, matrix); - } else if (m.blueprint && !spawned && blocked.empty() && m.list.size() < drone::beacon::max_remote && - can_create(base, local, &blocked)) { - m.list.push_back({drone.id, create_prop(base, local, m.blueprint, matrix)}); - spawned = true; - } - } - if (!blocked.empty()) { - waiting(blocked); - } else if (m.blueprint) { - if (m.list.size() >= drones.size()) m.waiting_since = 0; - set_models_status(std::format("{} other player(s) flying; {} model(s) shown, made of {}.", drones.size(), m.list.size(), - m.blueprint_name), spawned); - } - } catch (const std::exception &e) { - // Give up on what is there; the next frame starts over. - for (const auto &model : m.list) destroy(model.prop); - m.list.clear(); - m.blueprint = 0; - m.next_lookup = GetTickCount64() + 5000; - set_models_status(std::format("failed: {}", e.what()), true); - } -} -std::string drone_models_status() { - auto &m = models(); - std::lock_guard lock(m.status_mutex); - return m.status; -} - -void request_prop_test(std::string blueprint, int mode) { - std::lock_guard lock(test().mutex); - test().request = Request{std::move(blueprint), std::clamp(mode, 0, 2)}; -} -void request_fake_remote_drone(bool fly) { - std::lock_guard lock(test().mutex); - test().fake_request = fly; -} -std::optional> prop_test_position() noexcept { - const auto &t = test(); - if (!t.prop.entity) return std::nullopt; - return t.position; -} -std::string prop_test_status() { - std::lock_guard lock(test().mutex); - return test().status; -} -void tick_prop_test(std::uintptr_t base, std::uintptr_t client) noexcept { - auto &t = test(); - std::optional request; - std::optional fake_request; - { - std::lock_guard lock(t.mutex); - request.swap(t.request); - fake_request.swap(t.fake_request); - } - try { - if (fake_request) { - fake_remote_stop(); - if (*fake_request) fake_remote_start(base, client); - else set_test_status("the pretend other player's drone landed."); - } - if (t.fake) fake_remote_fly(); - } catch (const std::exception &e) { - fake_remote_stop(); - set_test_status(std::format("failed: {}", e.what())); - } - try { - std::lock_guard lock(registry().mutex); - if (t.prop.entity && !tracked(t.prop.entity)) { - t.prop = {}; - set_test_status("the engine took the prop away (a map change)."); - } - if (request) { - if (t.prop.entity) destroy(t.prop); - t.prop = {}; - if (request->blueprint.empty()) set_test_status("stopped; the prop is removed."); - else test_spawn(base, client, *request); - } - if (t.prop.entity) test_move(base); - } catch (const std::exception &e) { - t.prop = {}; - set_test_status(std::format("failed: {}", e.what())); - } -} -} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_prop.h b/Extension/Multiplayer/Remote/native_prop.h deleted file mode 100644 index c79637cd..00000000 --- a/Extension/Multiplayer/Remote/native_prop.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include - -// Models placed in the world by ReSkate: an object blueprint loaded on the map, created as a -// client-only entity and moved every frame (Engine/Game/Build//world_props.h). -namespace dingosdk::multiplayer { -// Other players' FPV drones. The drone's own model (made by ReSkate Studio from -// assets/models/fpv/drone.glb) when it is loaded; until then a stand-in prop of the game's. -inline constexpr std::string_view drone_model_blueprint = "reskate/drone/fpv_drone"; -inline constexpr std::string_view drone_model_stand_in = "world/props/common/construction/cmn_prp_trafficcone_01/cmn_prp_trafficcone_01"; - -// The client update thread, every frame while in a level: shows a model at each other player's -// flying drone (drone_beacon.h), moves it, and takes it away when they land or leave. `show` -// false (other players' drones hidden) takes them all away. -void tick_drone_models(std::uintptr_t base, std::uintptr_t client, bool show) noexcept; -std::string drone_models_status(); - -// The test that found how (dronemodeltest): spawns a named blueprint a few metres in front of the -// local skater and moves it round a circle. Mode 0 the entity transform setter (what works), -// 1 the blueprint transform then the setter, 2 the blueprint transform alone. Empty name: stop. -inline constexpr std::string_view prop_test_default = drone_model_stand_in; -void request_prop_test(std::string blueprint, int mode); -// A pretend other player flying a drone in front of the local skater (dronemodeltest remote), to -// see their drone's model and marker without a second player. false lands it. -void request_fake_remote_drone(bool fly); -void tick_prop_test(std::uintptr_t base, std::uintptr_t client) noexcept; -std::string prop_test_status(); -// Where the test's prop is now, while it runs (client update thread): drone_sound.cpp plays a -// motor there, so the sound can be heard without a second player. -std::optional> prop_test_position() noexcept; - -// The engine's entity destruction hook (native_skater.cpp): a prop the engine destroys (a map -// change takes them all) is forgotten before it is freed. Any thread; cheap with none placed. -void prop_entity_destroyed(std::uintptr_t entity) noexcept; -} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_skater.cpp b/Extension/Multiplayer/Remote/native_skater.cpp index 654802da..1e072604 100644 --- a/Extension/Multiplayer/Remote/native_skater.cpp +++ b/Extension/Multiplayer/Remote/native_skater.cpp @@ -1,10 +1,9 @@ #include "native_skater_internal.h" -#include "native_prop.h" #include "native_pose_layout.h" #include "puppet_cost.h" #include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/Multiplayer/Session/peer_slots.h" -#include "Extension/BoardBreak/board_break.h" +#include "Extension/Skater/board_pose.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/20260929/engine.h" @@ -336,6 +335,9 @@ void observe_destruction(std::uintptr_t entity) { } bool render_pose_hook(std::uintptr_t animation_interface, std::uintptr_t render_data) { const bool result = shared().original_render_pose(animation_interface, render_data); + // A pose override's board (skater_animation.cpp), with the renderer's own pointer to the pose. + if (const auto listener = shared().override_board_listener.load(std::memory_order_acquire); result && listener) + listener(animation_interface, render_data); if (const auto listener = shared().render_listener.load(std::memory_order_acquire); result && listener) listener(animation_interface); // A board holder's animation interface is holder + 0xc0. @@ -346,8 +348,12 @@ bool render_pose_hook(std::uintptr_t animation_interface, std::uintptr_t render_ const PeerScope scope(slot); apply_render_pose(animation_interface, render_data); } - // Last, so that what it does to a board's pose (another player's included) is what is drawn. - if (result) board_break::on_render_pose(animation_interface); + // A pose override of the local skater, after everything that ran since its animation update. + if (const auto listener = shared().override_render_listener.load(std::memory_order_acquire); result && listener) + listener(animation_interface); + // Last, so that what it does to a board's pose (another player's included) is what is drawn: + // the breakable deck's parts, and plugins' board (reskate.board). + if (result) board_pose::on_render_pose(animation_interface); return result; } void animation_hook(std::uintptr_t component, std::uintptr_t update) { @@ -382,11 +388,10 @@ void animation_hook(std::uintptr_t component, std::uintptr_t update) { shared().original_animation(component, update); if (const auto listener = shared().evaluated_listener.load(std::memory_order_acquire)) listener(component); + if (const auto listener = shared().override_listener.load(std::memory_order_acquire)) + listener(component); } void destroy_hook(std::uintptr_t entity, std::uintptr_t owner) { - // Other SDK-created actors share this hook; they must forget the entity - // before the engine frees it. - prop_entity_destroyed(entity); if (const auto listener = shared().destroyed_listener.load(std::memory_order_acquire)) listener(entity); // The first slot watching it as either skater or board. @@ -524,6 +529,8 @@ NativeFrame capture_local(std::uintptr_t base, std::uintptr_t client, bool captu std::lock_guard hidden_lock(hidden.mutex); if (hidden.component == component && hidden.joint < frame.pose.skater.size()) frame.pose.skater[hidden.joint].scale = hidden.scale; + if (const auto filter = shared().pose_filter.load(std::memory_order_acquire)) + filter(component, frame.pose.skater); frame.detail = frame.pose.skater.empty() ? "Waiting for skater animation buffers." : "Local pose ready."; } catch (const std::exception &e) { @@ -603,4 +610,16 @@ void set_render_pose_listener(RenderPosePublished listener) noexcept { void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept { shared().evaluated_listener.store(listener, std::memory_order_release); } +void set_local_pose_filter(LocalPoseFilter filter) noexcept { + shared().pose_filter.store(filter, std::memory_order_release); +} +void set_pose_override_listener(AnimationEvaluated listener) noexcept { + shared().override_listener.store(listener, std::memory_order_release); +} +void set_pose_override_render_listener(RenderPosePublished listener) noexcept { + shared().override_render_listener.store(listener, std::memory_order_release); +} +void set_pose_override_board_listener(BoardPosePublished listener) noexcept { + shared().override_board_listener.store(listener, std::memory_order_release); +} } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_skater.h b/Extension/Multiplayer/Remote/native_skater.h index 94fbf3f0..868c5753 100644 --- a/Extension/Multiplayer/Remote/native_skater.h +++ b/Extension/Multiplayer/Remote/native_skater.h @@ -39,10 +39,23 @@ void set_entity_destroyed_listener(EntityDestroyed listener) noexcept; // install_entity_hooks. Runs on the thread that evaluates animation. using AnimationEvaluated = void (*)(std::uintptr_t component) noexcept; void set_animation_evaluated_listener(AnimationEvaluated listener) noexcept; +// Runs on the client thread with the pose capture_local is about to return: a feature may change +// what other players receive of the local skater. +using LocalPoseFilter = void (*)(std::uintptr_t component, std::vector &skater) noexcept; +void set_local_pose_filter(LocalPoseFilter filter) noexcept; +// A pose override of the local skater (Extension/Skater/skater_animation.cpp): called after the +// listener above, so it sees (and may change) the pose first person left. +void set_pose_override_listener(AnimationEvaluated listener) noexcept; // Called after a skeleton's pose is handed to the renderer, with // its animation interface (animation holder + 0xc0), on the calling thread. using RenderPosePublished = void (*)(std::uintptr_t animation_interface) noexcept; void set_render_pose_listener(RenderPosePublished listener) noexcept; +// The pose override again, when a pose is handed to the renderer (after the listener above): what it +// writes then is what is drawn. And for the board, before the listener above, with the render data +// the pose was handed over in. +void set_pose_override_render_listener(RenderPosePublished listener) noexcept; +using BoardPosePublished = void (*)(std::uintptr_t animation_interface, std::uintptr_t render_data) noexcept; +void set_pose_override_board_listener(BoardPosePublished listener) noexcept; // Far-player savings (puppet_cost.cpp): native work a remote player's skater and skateboard // skip while far away. Within 200 m of the local skater or the camera nothing changes. // Each is on by default; the setter is thread-safe and applies at each player's next diff --git a/Extension/Multiplayer/Remote/native_skater_internal.h b/Extension/Multiplayer/Remote/native_skater_internal.h index 07bd7b1f..85d9476d 100644 --- a/Extension/Multiplayer/Remote/native_skater_internal.h +++ b/Extension/Multiplayer/Remote/native_skater_internal.h @@ -57,6 +57,11 @@ struct HookState { RenderPose original_render_pose{}; std::atomic destroyed_listener{}; std::atomic evaluated_listener{}; + std::atomic pose_filter{}; + // A pose override of the local skater (Extension/Skater/skater_animation.cpp), after the one above. + std::atomic override_listener{}; + std::atomic override_render_listener{}; + std::atomic override_board_listener{}; std::atomic render_listener{}; }; // Keys the per-entity hooks match, one contiguous array per kind in slot order. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index c6f58ab1..a85d8136 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,6 +1,5 @@ #include "session_internal.h" #include "Engine/Core/Hooks/hooks.h" -#include "Extension/Skater/drone_beacon.h" #include "Extension/Plugins/plugin_runtime.h" #include "Extension/Plugins/plugin_wire.h" #include "Extension/Plugins/plugin_session_feed.h" @@ -87,8 +86,6 @@ 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(); @@ -417,7 +414,12 @@ void relay_throwdowns(Session &s, bool in_world) { // Plugin messages ride in throwdown packets too, told apart by their first byte // (Extension/Plugins/plugin_wire.h); they go to the plugins, never to the relay. for (const auto &[sender, message] : s.throwdown_inbox) { - if (plugins::wire::is_plugin(message)) plugins::receive_message(sender, message); + if (plugins::wire::is_plugin(message)) { + // The authority's words (a plugin's server copy) come from the lobby's host or the + // server only; a host hears none from its guests. + if (plugins::wire::from_authority(message) && (s.mode == Mode::host || sender != s.host_id)) continue; + plugins::receive_message(sender, message); + } else receive_throwdown_relay(sender, message); } s.throwdown_inbox.clear(); @@ -529,7 +531,6 @@ 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); if (const auto extra = plugin_feed::tag_of(p.member.id)) { tag.extra = extra->text; tag.extra_color = extra->colour; @@ -769,6 +770,15 @@ MultiplayerChat chat() { std::lock_guard lock(s.mutex); return s.chat_view; } +std::string plugin_chat(std::string_view plugin, std::string_view text, std::uint32_t text_color) { + auto &s = session(); + if (s.mode == Mode::off) return "not in a session"; + s.chat.push_back({++s.chat_sequence, 0, now_us(), std::string(plugin), std::string(text), false, 0xffd9a441u, "Plugin", {}, false, + text_color}); + while (s.chat.size() > multiplayer_chat_history) s.chat.pop_front(); + publish_chat(s); + return {}; +} void host_map_change(std::string_view destination) { auto &s = session(); if (s.mode != Mode::host || (!s.started_map && !s.travelling)) return; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 21e00cdc..aa071694 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -1,8 +1,10 @@ #pragma once #include "Engine/Game/Multiplayer/session_model.h" +#include #include #include #include +#include #include namespace dingosdk::multiplayer { @@ -70,4 +72,12 @@ unsigned next_maps_serial() noexcept; void set_map_package_lookup(MapPackageLookup lookup) noexcept; // The session's chat lines for the overlay. Thread-safe and cheap: callable every frame. MultiplayerChat chat(); +// A chat line only this player sees, from a plugin: shown under the plugin's name with a +// "Plugin" tag, so it can't pass for a player or the server. Client thread; "" when shown, +// otherwise why not (no session). +std::string plugin_chat(std::string_view plugin, std::string_view text, std::uint32_t text_color = 0); +// A lobby host's plugins (their server copies): drops a player from the session (they cannot +// rejoin it), or moves their skater. "" when done, otherwise why not. Client thread. +std::string host_kick(std::uint64_t player, std::string_view why); +std::string host_teleport(std::uint64_t player, const std::array& at); } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index cc5fc45a..2ae2f3f9 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -1143,4 +1143,24 @@ std::string command(std::string_view action, std::string_view argument, std::str return s.status; } } +std::string host_kick(std::uint64_t player, std::string_view why) { + auto &s = session(); + if (s.mode != Mode::host) return "only the lobby's host can"; + if (player == s.host_id) return "the host cannot be kicked"; + const auto *peer = find_peer(s, player); + if (!peer || !peer->handshaken) return "that player is not in the session"; + s.banned.insert(player); + disconnect(s, player, why.empty() ? std::string("You were removed from the session.") : "You were removed from the session: " + std::string(why)); + s.next_party_update = 0; + return {}; +} +std::string host_teleport(std::uint64_t player, const std::array &at) { + auto &s = session(); + if (s.mode != Mode::host) return "only the lobby's host can"; + auto *peer = find_peer(s, player); + if (!peer || !peer->handshaken || !peer->world_ready) return "that player is not in the world"; + auto p = packet(s, PacketKind::teleport, now_us()); + p.teleport = at; + return send_packet(s, player, p, true, false) ? std::string{} : "it could not be sent"; +} } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 3e3e6171..1c8fd018 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -53,6 +53,9 @@ struct ChatBudget { }; // Throwdown messages per sender per 5 s: offers, joins, starts and ~4 scores a second. inline constexpr unsigned throwdown_burst = 60; +// Plugin messages (Extension/Plugins/plugin_wire.h) ride the same packets on a budget of their own: +// a plugin may send a few a second for a whole session (a drone's place, a game mode's state). +inline constexpr unsigned plugin_burst = 200; // The party the echo test shows its mirrored player in (a real lobby has none until its // players form one). inline constexpr std::uint32_t lobby_party = 1; @@ -137,7 +140,7 @@ struct Peer { std::uint64_t at{}; }; std::vector chat_proofs, chat_waiting; - ChatBudget throwdown_budget, party_budget; + ChatBudget throwdown_budget, party_budget, plugin_budget; // Host: how the guest's mods change trick scoring, as they reported it (Engine/Vfs/mod_scoring.h): // nothing until the report arrives, 0 for the game's own. member.scoring is the verdict. std::optional scoring; @@ -168,7 +171,6 @@ 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 82c6cc2a..ebc078db 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -1,7 +1,7 @@ #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 "Extension/Plugins/plugin_wire.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Build/supported_build.h" #include @@ -286,8 +286,6 @@ 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 apply_object_scaling(s, !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; @@ -378,10 +376,6 @@ 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) { @@ -1107,13 +1101,16 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { if (direct_link || !sender || !sender->handshaken || p.map != s.map || p.world != s.world || !routed_source(p, sender->member, message.peer, s.mode == Mode::host, s.host_id)) continue; - if (!sender->throwdown_budget.accept(now, throwdown_burst)) continue; + if (!(plugins::wire::is_plugin(p.throwdown) ? sender->plugin_budget.accept(now, plugin_burst) + : sender->throwdown_budget.accept(now, throwdown_burst))) + continue; sender->last_packet = now; // A player whose mods change scoring or physics takes part in nothing linked: the host // passes none of theirs on. if (sender->member.scoring) continue; if (s.throwdown_inbox.size() < 256) s.throwdown_inbox.emplace_back(p.source, p.throwdown); - if (s.mode == Mode::host) broadcast(s, p, true, false, now, p.source); + // A plugin message for the authority alone stays here (plugin_wire.h). + if (s.mode == Mode::host && plugins::wire::relayed(p.throwdown)) broadcast(s, p, true, false, now, p.source); continue; } // A host-ready notice and its fresh cosmetics can share one receive diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index af95a2bc..4c2833cb 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -8,7 +8,6 @@ #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 @@ -360,7 +359,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::drone; + packet.kind == PacketKind::effects; for (auto &p : active_peers(s)) { if (!p.handshaken || p.member.id == except || (s.mode == Mode::host && gameplay && !p.world_ready) || @@ -369,7 +368,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 || packet.kind == PacketKind::drone) && s.mode == Mode::join && + packet.kind == PacketKind::typing) && s.mode == Mode::join && p.member.id != s.host_id) continue; if (packet.kind == PacketKind::voice) { @@ -767,14 +766,6 @@ 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 99313a62..94ee58bf 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 || kind == PacketKind::drone ? TrafficLane::gameplay + : kind == PacketKind::pose || kind == PacketKind::audio || kind == PacketKind::effects ? 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 1908a592..f331c2c4 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -588,10 +588,6 @@ 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"); @@ -986,36 +982,13 @@ 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. + // 36 was the FPV drone's marker: no game sends it now, and none is made. { 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}; + d.kind = static_cast(36); d.session = 9; d.epoch = 10; d.map = 11; d.source = 12; 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"); + check(refused, "The old drone packet 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(), diff --git a/Extension/Multiplayer/Voice/native_voice.h b/Extension/Multiplayer/Voice/native_voice.h index bcbc7493..f6a1bb14 100644 --- a/Extension/Multiplayer/Voice/native_voice.h +++ b/Extension/Multiplayer/Voice/native_voice.h @@ -5,7 +5,7 @@ namespace dingosdk::multiplayer { class NativeVoicePlayback { public: - // Players' voices; other sounds played this way (remote drones) take their own tag, so their + // Players' voices; other sounds played this way (plugins') take their own tag, so their // streams never share an id with a voice's in the game's one sample provider. static constexpr std::uint64_t voice_source_tag = 0x52534b5600000000ULL; // "RSKV" explicit NativeVoicePlayback(std::uint64_t source_tag = voice_source_tag); diff --git a/Extension/Music/local_music_playback.cpp b/Extension/Music/local_music_playback.cpp index b9e32052..de1525f4 100644 --- a/Extension/Music/local_music_playback.cpp +++ b/Extension/Music/local_music_playback.cpp @@ -501,6 +501,13 @@ std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintpt // next while a Spotify station is on. // Only of the queue the station is on: the others (a speaker's in the world, the one a song // grabbed from a speaker plays on) go on to their next songs for reasons of their own. + // Without a station, the player's own channel's queue is kept for plugins' next + // (spotify_station::game_next). + if (std::uint8_t asked{}; !spotify_station::active() && dingosdk::memory::peek(queue + 0xf1, asked)) { + std::uint32_t channel{}; + if (const auto base = image_base.load(std::memory_order_acquire); base && native_queue_channel(base, queue, channel)) + spotify_station::plain_song_chosen(queue, channel, asked != 0); + } if (std::uint8_t asked{}; spotify_station::active() && dingosdk::memory::peek(queue + 0xf1, asked)) { std::uint32_t channel{}; if (const auto base = image_base.load(std::memory_order_acquire); base && native_queue_channel(base, queue, channel)) { diff --git a/Extension/Music/spotify_station.cpp b/Extension/Music/spotify_station.cpp index 5a367a57..61edc821 100644 --- a/Extension/Music/spotify_station.cpp +++ b/Extension/Music/spotify_station.cpp @@ -34,6 +34,10 @@ void left_at_store() noexcept; // flagged when the player asked for the next song, and when this last asked it itself. std::atomic player_queue{}, player_queue_table{}; // (the table a queue begins with: by it the queue is known to be there still) std::atomic asked_flag{1}; +// The same of the player's own channel without a station (game_next), and how a press of the +// player's flagged it. +std::atomic plain_queue{}, plain_queue_table{}; +std::atomic plain_asked_flag{1}; std::atomic self_asked{}; constexpr std::size_t queue_asked = 0xf1, queue_start = 0xf2; @@ -216,7 +220,9 @@ void bound(std::uint32_t channel, bool music_channel, std::uint32_t playlist, st } const auto before = station_channel.load(std::memory_order_relaxed); if (station < 0) { + // A queue is made anew when its channel is bound again (see song_chosen). plain_channel.store(channel, std::memory_order_relaxed); + plain_queue.store(0, std::memory_order_relaxed); plain_at.store(GetTickCount64(), std::memory_order_relaxed); } if (station >= 0 || before == channel) player_queue.store(0, std::memory_order_relaxed); @@ -286,6 +292,55 @@ void refresh_now_playing() noexcept { self_asked.store(0, std::memory_order_relaxed); } } +void plain_song_chosen(std::uintptr_t queue, std::uint32_t channel, bool asked) noexcept { + if (channel != plain_channel.load(std::memory_order_relaxed)) return; + if (std::uintptr_t table{}; memory::peek(queue, table)) { + plain_queue_table.store(table, std::memory_order_relaxed); + plain_queue.store(queue, std::memory_order_relaxed); + } + if (std::uint8_t start{}; asked && memory::peek(queue + queue_start, start)) plain_asked_flag.store(start, std::memory_order_relaxed); +} +std::uint32_t plain() noexcept { return plain_channel.load(std::memory_order_relaxed); } +std::optional game_song() noexcept { + if (active()) return std::nullopt; // a station's songs read as Spotify's + const auto channel = plain_channel.load(std::memory_order_relaxed); + std::uintptr_t metadata{}; + { + std::lock_guard lock(mutex); + if (channel >= channel_song.size()) return std::nullopt; + metadata = channel_song[channel]; + } + std::uintptr_t artist{}, title{}; + char artist_text[128]{}, title_text[128]{}; + if (!metadata || !memory::peek(metadata + artist_at, artist) || !memory::peek(metadata + title_at, title) || + memory::peek_cstring(title, title_text, sizeof(title_text)) <= 0) + return std::nullopt; + if (memory::peek_cstring(artist, artist_text, sizeof(artist_text)) < 0) artist_text[0] = 0; + try { + return GameSong{artist_text, title_text}; + } catch (...) { + return std::nullopt; + } +} +bool game_next() noexcept { + if (active()) return false; + const auto queue = plain_queue.load(std::memory_order_relaxed); + if (!queue) return false; + if (std::uintptr_t table{}; !memory::peek(queue, table) || table != plain_queue_table.load(std::memory_order_relaxed)) { + plain_queue.store(0, std::memory_order_relaxed); + return false; + } + std::uint8_t asked{}; + if (!memory::peek(queue + queue_asked, asked) || asked) return false; // it is choosing one as it is + __try { + *reinterpret_cast(queue + queue_start) = plain_asked_flag.load(std::memory_order_relaxed); + *reinterpret_cast(queue + queue_asked) = 1; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + plain_queue.store(0, std::memory_order_relaxed); + return false; + } +} bool active() noexcept { return station_channel.load(std::memory_order_relaxed) != no_channel; } std::atomic screen_open{}; void set_music_screen(bool open) noexcept { screen_open.store(open, std::memory_order_relaxed); } diff --git a/Extension/Music/spotify_station.h b/Extension/Music/spotify_station.h index 46131681..f9ef636a 100644 --- a/Extension/Music/spotify_station.h +++ b/Extension/Music/spotify_station.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include @@ -94,6 +95,17 @@ void grabbed() noexcept; bool recent() noexcept; // The music channel it is on, or ~0u. std::uint32_t channel() noexcept; +// The game's own soundtrack on the player's channel (the one bound to a playlist that is none of +// them): what it is playing, as its song's asset reads, and a next as the player's own press asks +// it (plugins: reskate.music.game_*). Without a station on. The client thread. +struct GameSong { + std::string artist, title; +}; +std::optional game_song() noexcept; +bool game_next() noexcept; +// The player's channel's queue, as the game chose a song for it (local_music_playback.cpp). +void plain_song_chosen(std::uintptr_t queue, std::uint32_t channel, bool asked) noexcept; +std::uint32_t plain() noexcept; // The client thread, each frame: starts or pauses the outside player as one is entered or left. void tick() noexcept; // For the console. diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0ebd4017..b918c88a 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include using namespace dingosdk::plugins; @@ -34,6 +36,40 @@ struct FakeGame final : Game { std::vector released; std::optional physics_state() override { return state; } std::uint64_t wipeouts() override { return wipe; } + std::string outfit_set(std::string_view plugin, std::string_view slot, std::string_view item, std::vector placement, bool covers) override { + calls.push_back(std::format("outfit {} {} {} {} {}", plugin, slot, item, placement.size(), covers)); + return {}; + } + void outfit_clear(std::string_view plugin, std::string_view slot) override { calls.push_back(std::format("outfit clear {} {}", plugin, slot)); } + std::set> installed; // items found by their whole ending, as Pack/ + std::string outfit_find(std::string_view ending) override { + if (installed.contains(ending)) return "Pack/" + std::string(ending); + return ending == "roadrash_leg1" ? "RoadRash_Leg1" : ""; + } + std::vector outfit_slots() override { return {{"tattoo_legl", "", {0.5f, 1}}, {"moles", "moles_empty", {}}}; } + std::optional> board_bones() override { + std::array bones{}; + bones[3].position = {0, 0.1f, 0}; + return bones; + } + std::string board_set(std::string_view plugin, int bone, const BoardBone& value, bool world) override { + calls.push_back(std::format("board {} {} {:.1f} {}", plugin, bone, value.position[1], world ? "world" : "local")); + return {}; + } + std::string board_hide(std::string_view plugin, int bone) override { calls.push_back(std::format("board hide {} {}", plugin, bone)); return {}; } + void board_park(std::string_view plugin, std::vector bones) override { calls.push_back(std::format("board park {} {}", plugin, bones.size())); } + std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3&, float ground) override { + calls.push_back(std::format("board toss {} {} {:.0f} {:.0f}", plugin, bones.size(), velocity[1], ground)); + return {}; + } + SkaterNews news; + SkaterNews take_skater_news() override { return std::exchange(news, {}); } + std::optional board_velocity() override { return Vec3{1, -2, 3}; } + std::optional last_slam() override { return SlamNews{7.5f, 12, 100}; } + std::string wipeout(float force) override { + calls.push_back(std::format("wipeout {:.1f}", force)); + return {}; + } std::string control(std::string_view action) override { calls.push_back("control " + std::string(action)); return {}; } void hide_hud(std::string_view plugin, bool hidden) override { calls.push_back(std::string("hud ") + std::string(plugin) + (hidden ? " on" : " off")); } std::vector object_catalog() override { return {{"bench_01", "Bench", "Benches"}}; } @@ -62,7 +98,79 @@ struct FakeGame final : Game { return result; } std::string send_chat(std::string_view text) override { calls.push_back("chat " + std::string(text)); return {}; } + std::string local_chat(std::string_view plugin, std::string_view text, std::uint32_t colour) override { + calls.push_back(std::format("notice {} {} {:08x}", plugin, text, colour)); + return {}; + } ThrowdownState throwdowns() override { return throwdown; } + ClipRef recorded; + std::string play_clip(std::string_view plugin, ClipRef clip, float speed, float blend, bool loop) override { + calls.push_back(std::format("play {} {} frames {:.1f} {:.2f} {}", plugin, clip->times.size(), speed, blend, loop)); + return {}; + } + void stop_clip(std::string_view plugin) override { calls.push_back("stop " + std::string(plugin)); } + std::string replace_trick(std::string_view plugin, std::string trick, std::string pop, ClipRef clip, float) override { + calls.push_back(std::format("replace {} {} {} {}", plugin, trick, pop.empty() ? "any" : pop, clip ? "clip" : "none")); + return {}; + } + std::string record_clip(std::string_view plugin, float seconds) override { + calls.push_back(std::format("record {} {:.1f}", plugin, seconds)); + auto clip = std::make_shared(); + clip->joints = dingosdk::anim_studio::skeleton_joints; + clip->times = {0, 0.25f, 0.5f}; + clip->frames.resize(clip->times.size() * clip->joints); + recorded = std::move(clip); + return {}; + } + ClipRef take_recording(std::string_view) override { return std::exchange(recorded, nullptr); } + std::string current_trick() override { return "kickflip"; } + std::optional flight_input(std::string_view) override { return FlightInput{1, 0, 0.5f, 3, 4, true}; } + std::optional gamepad() override { return PadInput{0.25f, 0, 0, 0, 0, 1, 0x1000, false}; } + std::optional joystick() override { + StickInput stick; + stick.axes = {0, -1, 0.5f, 0, 0, 0}; + stick.buttons = 0x4; + stick.name = "Radio"; + return stick; + } + std::string capture_input(std::string_view plugin, bool on) override { + calls.push_back(std::format("capture {} {}", plugin, on ? "on" : "off")); + return {}; + } + std::uint64_t sound_ids{}; + std::uint64_t play_sound(std::string_view plugin, SoundStart sound) override { + calls.push_back(std::format("sound {} {} {:.2f} {:.2f} {}{}", plugin, sound.wave ? std::to_string(sound.wave->samples.size()) : "motor", sound.volume, + sound.pitch, sound.loop, sound.position ? std::format(" at {} {} {}", (*sound.position)[0], (*sound.position)[1], (*sound.position)[2]) : "")); + return ++sound_ids; + } + bool change_sound(std::string_view plugin, std::uint64_t id, const SoundChange& change) override { + calls.push_back(std::format("change {} {} {:.1f}{}", plugin, id, change.throttle.value_or(-1), + change.position && !*change.position ? " skater" : "")); + return true; + } + void stop_sound(std::string_view plugin, std::uint64_t id) override { calls.push_back(std::format("stop sound {} {}", plugin, id)); } + bool sound_playing(std::string_view, std::uint64_t id) override { return id <= sound_ids; } + std::string host_throwdown(std::string_view plugin, std::string_view series, std::vector players, int most) override { + calls.push_back(std::format("host {} {} {} players max {}", plugin, series, players.size(), most)); + return {}; + } + std::string throwdown_status(std::string_view) override { return "open"; } + std::string kick(std::uint64_t player, std::string_view why) override { calls.push_back(std::format("kick {} {}", player, why)); return {}; } + std::string announce(std::string_view plugin, std::string_view text) override { + calls.push_back(std::format("announce {} {}", plugin, text)); + return {}; + } + std::optional camera_view() override { return CameraView{{1, 2, 3}, {0, 0, 1}, 60}; } + std::string set_camera(std::string_view plugin, const std::array& pose, float fov) override { + calls.push_back(std::format("camera {} {:.1f} {:.1f} {:.1f} back {:.2f} {:.2f} {:.2f} fov {:.0f}", plugin, pose[12], pose[13], pose[14], + pose[8], pose[9], pose[10], fov)); + return {}; + } + void release_camera(std::string_view plugin) override { calls.push_back("camera release " + std::string(plugin)); } + std::string freeze(std::string_view plugin, bool on) override { + calls.push_back(std::format("freeze {} {}", plugin, on ? "on" : "off")); + return {}; + } Track track() override { return now_playing; } std::string music(std::string_view command) override { calls.push_back("music " + std::string(command)); return {}; } Progress progress() override { return {true, {{"historic", "Historic", 4}}, {{"c1", "line", "historic", 2, 1, 3}}, 1500, 9000, 2}; } @@ -607,6 +715,35 @@ void tricks_reach_plugins_that_ask_for_them() { running(host, "tricks", environment); } +void plugins_draw_pictures() { + TestEnvironment environment; + Host host(environment, Side::client); + const std::string main = R"( + reskate.on("draw", function(d) + d:image("icons/flag.png", vector.create(0, 0, 0), vector.create(64, 64, 0), {colour = 0xffffff80}) + d:world_image("icons/flag.png", vector.create(0, 2, -10), 1.5, {uv = {0, 0, 0.5, 0.25}}) + assert(not pcall(d.image, d, "icons/flag.png", vector.create(0, 0, 0), vector.create(1, 1, 0), {uv = {0, 0, 2, 1}}), "uv 0 to 1") + assert(not pcall(d.image, d, "notes.txt", vector.create(0, 0, 0), vector.create(1, 1, 0)), "only pictures") + assert(not pcall(d.image, d, "../other/flag.png", vector.create(0, 0, 0), vector.create(1, 1, 0)), "only its own package") + assert(not pcall(d.image, d, "icons/missing.png", vector.create(0, 0, 0), vector.create(1, 1, 0)), "only files there") + assert(not pcall(d.world_image, d, "icons/flag.png", vector.create(0, 0, 0), -1), "a height") + reskate.log("drew") + end) + )"; + host.apply({package("pictures", {{manifest_file, manifest(R"("overlay")")}, {"plugin/main.lua", main}, {"icons/flag.png", "not really a png"}, + {"notes.txt", "x"}})}); + ticks(host, environment, 1); + running(host, "pictures", environment); + check(environment.logged("[pictures] drew") && environment.layers.size() == 1 && environment.layers[0].shapes.size() == 2, + "A plugin draws pictures from its package\n" + environment.all()); + const auto& picture = environment.layers[0].shapes[0]; + check(picture.kind == plugin_ui::Shape::Kind::image && picture.text.ends_with("flag.png") && picture.style.colour == 0x80ffffff, + "as the picture's file, tinted"); + check(environment.layers[0].shapes[1].kind == plugin_ui::Shape::Kind::world_image && environment.layers[0].shapes[1].radius == 1.5f && + environment.layers[0].shapes[1].style.uv[2] == 0.5f && environment.layers[0].shapes[1].style.uv[3] == 0.25f, + "and in the world, so many metres tall, a part of it"); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -679,6 +816,8 @@ void plugins_have_menu_panels() { state.on = ui:toggle("on", "Switch", state.on) state.speed = ui:slider("speed", "Speed", state.speed, 0, 10) state.mode = ui:dropdown("mode", "Mode", {"one", "two", "three"}, state.mode) + ui:bar("Throttle", 0.25, 0, 1) + assert(not pcall(ui.bar, ui, "Bad", 0, 1, 0), "a bar's range") assert(not pcall(ui.button, ui, "go", "Again"), "two widgets with one id") end) assert(not pcall(reskate.menu.panel, "Settings", function() end), "two panels with one title") @@ -691,7 +830,8 @@ void plugins_have_menu_panels() { check(environment.panels.empty(), "Panels are built only while the PLUGINS tab shows them"); environment.panels_open = true; ticks(host, environment, 10); - check(environment.panels.size() == 1 && environment.panels[0].title == "Settings" && environment.panels[0].widgets.size() == 7, + check(environment.panels.size() == 1 && environment.panels[0].title == "Settings" && environment.panels[0].widgets.size() == 8 && + environment.panels[0].widgets[7].kind == plugin_ui::Widget::Kind::bar && environment.panels[0].widgets[7].value == 0.25f, "Then its panel is built\n" + environment.all()); using A = plugin_ui::Action; environment.actions = {A{"panels", "Settings", "go"}, A{"panels", "Settings", "on", true}, A{"panels", "Settings", "speed", false, 99}, @@ -703,6 +843,57 @@ void plugins_have_menu_panels() { check(widgets[4].on && widgets[5].value == 10 && widgets[6].chosen == 3 && widgets[6].choices.size() == 3, "and shows in the next build"); } +// A 16-bit mono WAVE file of `samples` at `rate`. +std::string wave_file(unsigned rate, std::size_t samples) { + std::string out = "RIFF"; + const auto put = [&](std::uint32_t v, int bytes) { + for (int i = 0; i < bytes; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xff)); + }; + put(static_cast(36 + samples * 2), 4); + out += "WAVEfmt "; + put(16, 4); + put(1, 2); + put(1, 2); + put(rate, 4); + put(rate * 2, 4); + put(2, 2); + put(16, 2); + out += "data"; + put(static_cast(samples * 2), 4); + for (std::size_t i = 0; i < samples; ++i) put(static_cast(static_cast((i % 20) * 1000)) & 0xffff, 2); + return out; +} + +void plugins_play_their_own_sounds() { + std::string error; + const auto bytes = wave_file(8000, 4000); + const auto wave = decode_wave(std::span(reinterpret_cast(bytes.data()), bytes.size()), 30, error); + check(wave && wave->samples.size() == 8000, "Half a second at 8 kHz is half a second at 16 kHz: " + error); + check(!decode_wave(std::span(reinterpret_cast(bytes.data()), bytes.size()), 0.25f, error), + "and a sound longer than allowed is refused"); + TestEnvironment environment; + Host host(environment, Side::client); + host.apply({package("noisy", {{manifest_file, manifest(R"("console", "audio")")}, {"sounds/beep.wav", bytes}, {"notes.txt", "x"}, + {"plugin/main.lua", R"( + reskate.console.command("noise", "x", function() + local beep = assert(reskate.audio.play("sounds/beep.wav", {volume = 0.5, pitch = 2, loop = true, position = vector.create(1, 2, 3)})) + local motor = assert(reskate.audio.play("motor", {throttle = 0.4, speed = 10})) + assert(reskate.audio.set(motor, {throttle = 0.9, position = "skater"})) + assert(reskate.audio.playing(beep)) + reskate.audio.stop(beep) + assert(not pcall(reskate.audio.play, "notes.txt"), "a wave") + assert(not pcall(reskate.audio.play, "../x/beep.wav"), "its own package") + assert(not pcall(reskate.audio.play, "sounds/beep.wav", {volume = 3}), "a sane volume") + reskate.log("sounded") + end) + )"}})}); + running(host, "noisy", environment); + host.run_console("noise"); + check(environment.logged("[noisy] sounded"), "A plugin plays its own sounds\n" + environment.all()); + for (const auto* call : {"sound noisy 8000 0.50 2.00 true at 1 2 3", "sound noisy motor 1.00 1.00 false", "change noisy 2 0.9 skater", "stop sound noisy 1"}) + check(environment.fake.called(call), std::string("The game was asked: ") + call + "\n" + environment.all()); +} + void plugins_play_sounds_and_read_the_mods() { TestEnvironment environment; environment.mod_list = {{"Owner-Map", "A Map", true}, {"OldThing", "Old Thing", false}}; @@ -791,6 +982,108 @@ void plugins_follow_the_session_and_message_each_other() { running(host_a, "chat-a", a); } +// A game mode's rules in the plugin's server copy: players' copies talk to it, it answers everyone. +void plugins_server_copies_are_the_authority() { + TestEnvironment server, player; + server.session_now = {true, false, {{1, "Ann", true, true}, {2, "Bob", false, false}}}; + player.session_now = {true, false, {{2, "Bob", true, false}, {1, "Ann", false, true}}}; + const std::string main = R"( + reskate.log("side " .. reskate.side) + if reskate.side == "server" then + reskate.on("message", function(from, text) + reskate.log("server heard " .. text .. " from " .. from.name) + assert(reskate.net.send("all", "state " .. text)) + assert(reskate.server.kick(from, "testing")) + assert(reskate.server.say("round over")) + assert(not pcall(reskate.net.send, "server", "x"), "it is the server") + end) + else + reskate.on("message", function(from, text) reskate.log("player heard " .. text .. " server " .. tostring(from.server)) end) + reskate.console.command("join", "x", function() + assert(reskate.net.send("server", "hello")) + local ok, err = pcall(reskate.server.kick, "1") + assert(not ok and err:find("server copy"), err) + end) + end + )"; + const auto mode = [&](const char* id) { + return package(id, {{manifest_file, manifest(R"("console", "net.session", "server")", "both")}, {"manifest.json", R"({"name": "Mode"})"}, + {"plugin/main.lua", main}}); + }; + Host server_host(server, Side::server), player_host(player, Side::client); + server_host.apply({mode("mode-s")}); + player_host.apply({mode("mode-p")}); + ticks(server_host, server, 1); + ticks(player_host, player, 1); + check(server.logged("[mode-s] side server") && player.logged("[mode-p] side client"), "Each copy knows its side\n" + server.all() + player.all()); + player_host.run_console("join"); + check(player.sent.size() == 1 && wire::to_of(player.sent[0]) == wire::authority && !wire::from_authority(player.sent[0]) && + !wire::relayed(player.sent[0]), "A player's copy speaks to the authority, and no relay passes that on\n" + player.all()); + player_host.apply({}); + player_host.apply({mode("mode-p")}); + ticks(player_host, player, 1); + player.incoming.push_back({2, player.sent[0]}); + ticks(player_host, player, 1); + check(!player.logged("player heard hello"), "What is for the authority is not heard by players' copies"); + server.incoming.push_back({2, player.sent[0]}); + ticks(server_host, server, 1); + check(server.logged("[mode-s] server heard hello from Bob"), "The server copy hears it, from the player\n" + server.all()); + check(server.fake.called("kick 2 testing") && server.fake.called("announce Mode round over"), "and rules: a kick, a line to everyone"); + check(server.sent.size() == 1 && wire::from_authority(server.sent[0]) && !wire::relayed(server.sent[0]), "Its answer is the authority's"); + player.incoming.push_back({1, server.sent[0]}); + ticks(player_host, player, 1); + check(player.logged("[mode-p] player heard state hello server true"), "and players' copies hear it from the server\n" + player.all()); + running(server_host, "mode-s", server); + running(player_host, "mode-p", player); +} + +// Clips on the skater's body: from the package or recorded, played, replacing a trick. +void plugins_animate_the_skater() { + TestEnvironment environment; + Host host(environment, Side::client); + dingosdk::anim_studio::Clip clip; + clip.joints = dingosdk::anim_studio::skeleton_joints; + clip.times = {0, 0.5f}; + clip.frames.resize(clip.times.size() * clip.joints); + clip.takeoff = 0.1f; + clip.landing = 0.4f; + const auto bytes = dingosdk::anim_studio::clip_bytes(clip); + const std::string main = R"( + reskate.console.command("go", "x", function() + assert(reskate.animation.play("clips/spin.fas", {speed = 2, blend = 0.3, loop = true})) + assert(not pcall(reskate.animation.play, "clips/missing.fas"), "a clip in the package") + assert(not pcall(reskate.animation.play, "notes.txt"), "a clip file") + assert(not pcall(reskate.animation.play, "../other/spin.fas"), "its own package only") + assert(not pcall(reskate.animation.play, "clips/spin.fas", {speed = 99}), "a sane speed") + assert(reskate.animation.replace("Kickflip", "clips/spin.fas", {pop = "high"})) + assert(not pcall(reskate.animation.replace, "kick;flip", "clips/spin.fas"), "a trick's name") + assert(not pcall(reskate.animation.replace, "ollie", "clips/spin.fas", {pop = "huge"}), "a pop") + assert(reskate.animation.record(2)) + assert(not pcall(reskate.animation.record, 60), "at most 10 s") + local rec = reskate.animation.recorded() + assert(rec == "rec:1", tostring(rec)) + assert(reskate.animation.recorded() == nil, "one recording, once") + for i = 1, 3 do reskate.animation.play(rec) end + local fifth, why = reskate.animation.play(rec) + assert(not fifth and why:find("4 times"), "4 a second") + assert(reskate.animation.trick() == "kickflip") + reskate.animation.forget(rec) + assert(not pcall(reskate.animation.play, rec), "a forgotten recording is gone") + reskate.animation.stop() + reskate.log("danced") + end) + )"; + host.apply({package("dancer", {{manifest_file, manifest(R"("console", "skater.animation")")}, {"plugin/main.lua", main}, + {"clips/spin.fas", std::string(bytes.begin(), bytes.end())}, {"notes.txt", "x"}})}); + running(host, "dancer", environment); + host.run_console("go"); + check(environment.logged("[dancer] danced"), "A plugin animates the skater\n" + environment.all()); + for (const auto* call : {"play dancer 2 frames 2.0 0.30 true", "replace dancer kickflip high clip", "record dancer 2.0", "play dancer 3 frames 1.0 0.15 false", + "stop dancer"}) + check(environment.fake.called(call), std::string("The game was asked: ") + call + "\n" + environment.all()); + running(host, "dancer", environment); +} + void plugins_call_and_hear_each_other() { TestEnvironment environment; Host host(environment, Side::client); @@ -879,7 +1172,37 @@ void the_wider_set_reaches_the_game() { Host host(environment, Side::client); host.apply({script("wide", R"( reskate.on("skater_state", function(now, before) reskate.log("state " .. before .. " to " .. now) end) - reskate.on("bail", function(e) reskate.log("bail " .. e.count) end) + reskate.on("bail", function(e) reskate.log("bail " .. e.count .. " force " .. tostring(e.force) .. " cause " .. tostring(e.cause)) end) + reskate.on("board_landed", function(e) reskate.log(string.format("landed %.1f onto %s", e.down_speed, e.onto)) end) + reskate.on("body_hit", function(e) reskate.log(string.format("hit %s %.0f", e.bone, e.impact)) end) + reskate.on("bail_end", function(e) reskate.log(string.format("fall %.1f s left %.0f thigh %.0f", e.seconds, e.left, e.bones["Left thigh"].impact)) end) + reskate.console.command("outfit", "x", function() + assert(reskate.outfit.find("roadrash_leg1") == "RoadRash_Leg1" and reskate.outfit.find("nothing") == nil) + local slots = reskate.outfit.slots() + assert(#slots == 2 and slots[1].slot == "tattoo_legl" and slots[1].parameters[1] == 0.5) + assert(reskate.outfit.set("tattoo_legl", "RoadRash_Leg1", {placement = {0.1, 0.2, 0.3, 0.4, 0.5}, covers = true})) + assert(not pcall(reskate.outfit.set, "tattoo_legl", "x", {placement = {1, 2, 3, 4, 5, 6, 7, 8, 9}}), "at most 8 numbers") + reskate.outfit.clear("tattoo_legl") + reskate.log("outfit fine") + end) + reskate.console.command("deck", "x", function() + local bones = reskate.board.bones() + assert(math.abs(bones[3].position.y - 0.1) < 1e-6 and bones[0] and bones[15] and #bones[3].rotation == 4, "the board's bones") + assert(reskate.board.set(12, {position = vector.create(0, 2, 0), rotation = {0, 0, 0, 2}, space = "world"})) + assert(not pcall(reskate.board.set, 1, {position = vector.create(0, 0, 0)}), "bone 1 places the board") + assert(reskate.board.hide(3)) + reskate.board.park({12, 13, 14, 15}) + assert(reskate.board.toss({12, 4}, {velocity = vector.create(1, 3, 0), spin = vector.create(0, 5, 0), ground = -2})) + reskate.log("deck fine") + end) + reskate.console.command("physics", "x", function() + assert(reskate.board.motion().velocity.y == -2) + assert(reskate.skater.wipeout(2)) + local again, two = reskate.skater.wipeout(2) + assert(not again and two:find("2 s"), "one wipeout every 2 s") + assert(not pcall(reskate.skater.wipeout, 10), "a gentle force") + reskate.log("physics fine") + end) reskate.on("chat", function(line) reskate.log("chat " .. line.name .. ": " .. line.text) end) reskate.on("track_changed", function(t) reskate.log("track " .. t.title .. " by " .. t.artist) end) reskate.on("throwdown", function(e) reskate.log("throwdown " .. e.phase) end) @@ -916,7 +1239,32 @@ void the_wider_set_reaches_the_game() { local soon, three = reskate.chat.send("again") assert(not soon and three:find("3 s")) assert(not pcall(reskate.chat.send, "/kick someone"), "no commands") + for i = 1, 5 do assert(reskate.chat.notice("note " .. i, 0x00ff00ff)) end + local sixth, five = reskate.chat.notice("too many") + assert(not sixth and five:find("5 lines"), "5 lines a second") + assert(not pcall(reskate.chat.notice, "two\nlines"), "one line") + assert(reskate.camera.look(vector.create(0, 5, 0), vector.create(0, 5, 10), 70)) + assert(not pcall(reskate.camera.look, vector.create(1, 1, 1), vector.create(1, 1, 1)), "a view needs a direction") + assert(not pcall(reskate.camera.look, vector.create(0, 0, 0), vector.create(0, 0, 1), 400), "a sane field of view") + assert(reskate.camera.get().fov == 60 and reskate.camera.get().position.y == 2) + assert(reskate.camera.set(vector.create(0, 5, 0), vector.create(0, 0, -1), vector.create(1, 1, 0), 80), "a rolled view") + assert(not pcall(reskate.camera.set, vector.create(0, 0, 0), vector.create(0, 1, 0), vector.create(0, 2, 0)), "up along forward") + local f = reskate.input.flight() + assert(f.forward == 0.5 and f.look_x == 3 and f.boost, "the flight keys and mouse") + local pad = reskate.input.gamepad() + assert(pad.buttons.a and not pad.buttons.b and pad.left_x == 0.25 and pad.right_trigger == 1, "the gamepad") + local stick = reskate.input.joystick() + assert(stick.axes[2] == -1 and stick.buttons[3] and not stick.buttons[1] and stick.name == "Radio", "the radio") + assert(reskate.input.capture(true)) + reskate.camera.release() + assert(reskate.skater.freeze(true)) assert(reskate.throwdowns.state().active == false) + assert(reskate.throwdowns.host("skate", {"7", {id = "8"}, "7"}, {max = 4})) + local again, five = reskate.throwdowns.host("jam", {"7"}) + assert(not again and five:find("5 s"), "one drop every 5 s") + assert(not pcall(reskate.throwdowns.host, "bowling", {"7"}), "a linked mode") + assert(not pcall(reskate.throwdowns.host, "jam", {}), "someone to play") + assert(reskate.throwdowns.hosting() == "open") assert(reskate.music.next()) local p = reskate.progression.get() assert(p.districts[1].rank == 4 and p.challenges[1].completed_goals == 1 and p.score == 1500) @@ -925,7 +1273,7 @@ void the_wider_set_reaches_the_game() { reskate.log("wide fine") end) )", R"("console", "events.skater", "skater.read", "skater.control", "trainer", "hud", "world.objects", "world.environment", "travel", - "world.ai", "session.skaters", "session.nametags", "voice.control", "chat.read", "chat.send", "throwdowns", "music", + "world.ai", "session.skaters", "session.nametags", "voice.control", "chat.read", "chat.send", "chat.local", "camera", "throwdowns", "music", "audio", "outfit", "board", "progression.read", "settings.read", "input", "ui.screen")")}); running(host, "wide", environment); game.state = 100; @@ -936,7 +1284,9 @@ void the_wider_set_reaches_the_game() { check(environment.logged("[wide] wide fine"), "Every wider-set call works\n" + environment.all()); for (const auto* call : {"control noclip_on", "control boost_up", "hud wide on", "place 0 bench_01 5.0", "move 42 6.0", "delete 42", "env tod night", "env control fog 1", "env park historic skatepark_02", "travel location bam", "ai spawn 2", - "ai clear", "tag wide 7 IT ff0000ff", "voice 7 0.0", "chat hello all", "music next"}) + "ai clear", "tag wide 7 IT ff0000ff", "voice 7 0.0", "chat hello all", "notice wide note 5 ff00ff00", "camera wide 0.0 5.0 0.0 back 0.00 0.00 -1.00 fov 70", + "camera wide 0.0 5.0 0.0 back -0.00 -0.00 1.00 fov 80", "capture wide on", + "camera release wide", "freeze wide on", "host wide ThrowdownSkate 2 players max 4", "music next"}) check(game.called(call), std::string("The game was asked: ") + call); check(environment.teleports.size() == 1, "The save point teleports back"); check(game.binds["wide/jump"] == combo_of("Ctrl+K"), "Its bind is held by the game"); @@ -953,8 +1303,24 @@ void the_wider_set_reaches_the_game() { "And its events, but not chat from before it listened\n" + environment.all()); ticks(host, environment, 1); game.wipe = 2; + game.news.landings.push_back({4.5f, 6, true}); + game.news.hits.push_back({"Head", 9, false, false}); + game.news.falls.push_back({1.5f, 12, 3, {{"Left thigh", 8, 0.5f}}}); ticks(host, environment, 1); - check(environment.logged("[wide] bail 2"), "A wipeout is a bail"); + check(environment.logged("[wide] bail 2 force 7.5 cause 12"), "A wipeout is a bail, with the slam's force and cause\n" + environment.all()); + check(environment.logged("[wide] landed 4.5 onto rail") && environment.logged("[wide] hit Head 9") && + environment.logged("[wide] fall 1.5 s left 12 thigh 8"), + "and the board's landings, the body's hits and the fall are heard\n" + environment.all()); + host.run_console("outfit"); + check(environment.logged("[wide] outfit fine") && game.called("outfit wide tattoo_legl RoadRash_Leg1 5 true") && + game.called("outfit clear wide tattoo_legl"), + "A plugin wears items over the outfit\n" + environment.all()); + host.run_console("deck"); + check(environment.logged("[wide] deck fine") && game.called("board wide 12 2.0 world") && game.called("board hide wide 3") && + game.called("board park wide 4") && game.called("board toss wide 2 3 -2"), + "A plugin changes what is drawn of the board\n" + environment.all()); + host.run_console("physics"); + check(environment.logged("[wide] physics fine") && game.called("wipeout 2.0"), "The board's motion reads, and a wipeout is asked for\n" + environment.all()); ticks(host, environment, 10); const auto screen = std::find_if(environment.panels.begin(), environment.panels.end(), [](const auto& p) { return p.screen; }); check(screen != environment.panels.end() && screen->title == "Big" && screen->widgets.size() == 2, "Its open screen is built, without the PLUGINS tab"); @@ -1079,7 +1445,7 @@ void reskate_test_passes_its_own_checks() { check(host.run_console("ptest all"), "ptest is its command"); ticks(host, environment, 120); // its later checks run on timers const auto output = printed_lines(environment); - check(output.find("ptest: 67 passed, 0 failed, 9 skipped") != std::string::npos && + check(output.find("ptest: 79 passed, 0 failed, 10 skipped") != std::string::npos && output.find("FAIL") == std::string::npos, "Every reskate-test check passes:\n" + output + environment.all()); check(host.run_console("ptest echo a b") && environment.printed("ptest echo: a b"), "and its echo command answers"); // With a skater on the ground, its skater checks run too. @@ -1112,6 +1478,216 @@ void reskate_test_passes_its_own_checks() { running(host, "reskate-test", environment); } +// Road Rash as a mod (contrib/plugins/road-rash): its own checks of its rules pass, falls on one +// side mark that side's leg first, and every mark goes on, at the 4 changes a second ReSkate takes. +void road_rash_marks_the_skater_as_it_bails() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "road-rash"); + auto& game = environment.fake; + for (const auto* item : {"roadrash_leg_1", "roadrash_leg_2", "roadrash_leg_3_blood", "roadrash_leg_3_dry", "roadrash_arm_1", "roadrash_arm_2", + "roadrash_arm_3_blood", "roadrash_face_1", "roadrash_face_2_blood"}) + game.installed.insert(item); + Host host(environment, Side::client); + host.apply({contributed("road-rash")}); + running(host, "road-rash", environment); + check(host.run_console("roadrash test"), "roadrash test"); + check(environment.printed("roadrash: 11 passed, 0 failed") && !environment.printed("FAIL"), + "Road Rash's own checks pass:\n" + printed_lines(environment) + environment.all()); + // Ten falls on the left, and one that is the tenth entering the wipeout again. + for (int i = 0; i < 10; ++i) { + game.news.falls.push_back({1.0f, 12, 1, {}}); + environment.now += 2000; + ticks(host, environment, 1); + } + game.news.falls.push_back({0.4f, 12, 1, {}}); + ticks(host, environment, 20); + environment.lines.clear(); + check(host.run_console("roadrash"), "roadrash"); + check(environment.printed("roadrash: on, 10 bail(s) counted (left leg 1, left arm 0, right leg 0, right arm 0, face 0)") && + environment.printed("1 mark(s) worn"), + "Ten falls on the left mark the left leg:\n" + printed_lines(environment)); + check(game.called("outfit road-rash tattoo_legl Pack/roadrash_leg_1 5 false") && !game.called("tattoo_arml"), "and it wears the first leg item"); + // Every part at its worst: five items, more than ReSkate takes in a second. + game.calls.clear(); + check(host.run_console("roadrash bails 25"), "roadrash bails"); + ticks(host, environment, 120); + for (const auto* call : {"outfit clear road-rash ", "outfit road-rash tattoo_legl Pack/roadrash_leg_3_blood 5 false", + "outfit road-rash tattoo_arml Pack/roadrash_arm_3_blood 5 false", "outfit road-rash tattoo_legr Pack/roadrash_leg_3_blood 5 false", + "outfit road-rash tattoo_armr Pack/roadrash_arm_3_blood 5 false", "outfit road-rash moles Pack/roadrash_face_2_blood 0 false"}) + check(game.called(call), std::string("Road Rash asked: ") + call + "\n" + environment.all()); + environment.lines.clear(); + check(host.run_console("roadrash"), "roadrash"); + check(environment.printed("5 mark(s) worn"), "All five are worn:\n" + printed_lines(environment)); + // Dry: the arm has no dry worst, so it wears the level below; the face too. Covering tattoos. + game.calls.clear(); + check(host.run_console("roadrash blood 0") && host.run_console("roadrash tattoos 1"), "roadrash blood and tattoos"); + ticks(host, environment, 240); // 1.5 s off first, then five items over two seconds + for (const auto* call : {"outfit road-rash tattoo_legl Pack/roadrash_leg_3_dry 5 true", "outfit road-rash tattoo_arml Pack/roadrash_arm_2 5 true", + "outfit road-rash moles Pack/roadrash_face_1 0 true"}) + check(game.called(call), std::string("Road Rash asked: ") + call + "\n" + environment.all()); + // Healed, nothing goes back on; switched off, no bail counts. + game.calls.clear(); + check(host.run_console("roadrash heal") && environment.printed("roadrash: healed"), "roadrash heal"); + ticks(host, environment, 30); + check(game.called("outfit clear road-rash ") && !game.called("outfit road-rash"), "Healing takes every mark off"); + check(host.run_console("roadrash 0"), "roadrash 0"); + for (int i = 0; i < 12; ++i) { + game.news.falls.push_back({1.0f, 12, 1, {}}); + environment.now += 2000; + ticks(host, environment, 1); + } + environment.lines.clear(); + check(host.run_console("roadrash"), "roadrash"); + check(environment.printed("roadrash: off, 0 bail(s) counted"), "Off, it counts nothing:\n" + printed_lines(environment)); + // The switches are kept. + host.apply({}); + host.apply({contributed("road-rash")}); + running(host, "road-rash", environment); + environment.lines.clear(); + check(host.run_console("roadrash"), "roadrash"); + check(environment.printed("roadrash: off, 0 bail(s) counted") && environment.printed("blood off, covers tattoos on"), + "Its switches are kept:\n" + printed_lines(environment)); + // Its panel. + environment.panels_open = true; + ticks(host, environment, 10); + check(environment.panels.size() == 1 && environment.panels[0].title == "Road Rash" && environment.panels[0].widgets.size() == 6, + "Its panel is built while the PLUGINS tab shows"); + environment.actions = {plugin_ui::Action{"road-rash", "Road Rash", "on", true}}; + ticks(host, environment, 10); + environment.lines.clear(); + check(host.run_console("roadrash"), "roadrash"); + check(environment.printed("roadrash: on"), "and its toggle switches it on:\n" + printed_lines(environment)); +} + +// Board Break as a mod (contrib/plugins/board-break), in a copy with one crack among its sounds as +// the deck pack brings them: its own checks pass, the stretches are kept off every board, a slam +// breaks the board (the real deck goes away, the crack sounds) and riding again makes it whole. +void board_break_snaps_the_board_on_a_slam() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "board-break"); + auto& game = environment.fake; + const auto directory = scratch / "mods" / "board-break"; + std::filesystem::remove_all(directory); + std::filesystem::copy(std::filesystem::path(RESKATE_SOURCE_DIR) / "contrib" / "plugins" / "board-break", directory, + std::filesystem::copy_options::recursive); + write(directory / "sounds" / "heavy" / "snap.wav", wave_file(8000, 4000)); + Host host(environment, Side::client); + host.apply({{"board-break", directory, "1", check_package_files(directory)}}); + running(host, "board-break", environment); + check(host.run_console("boardbreak test"), "boardbreak test"); + check(environment.printed("boardbreak: 31 passed, 0 failed") && !environment.printed("FAIL"), + "Board Break's own checks pass:\n" + printed_lines(environment) + environment.all()); + check(game.called("board park board-break 4"), "The stretches are kept off every board\n" + environment.all()); + // Riding, then a slam; set to always break, it goes. + check(host.run_console("boardbreak always 1") && environment.printed("boardbreak: always is 1"), "boardbreak always"); + game.state = 100; + ticks(host, environment, 2); + game.state = 300; + game.wipe = 1; + ticks(host, environment, 1); + check(game.called("board board-break 3 -949.9 local"), "The real deck goes away\n" + environment.all()); + check(game.called("sound board-break 8000 1.00 1.00 false"), "and the crack sounds\n" + environment.all()); + environment.lines.clear(); + check(host.run_console("boardbreak status") && environment.printed(", broken;"), "It is broken:\n" + printed_lines(environment)); + // Riding again a moment later, it is a new board. + game.state = 100; + ticks(host, environment, 100); + environment.lines.clear(); + check(host.run_console("boardbreak status") && environment.printed("the board is new (0% worn);"), + "Riding again it is whole and new:\n" + printed_lines(environment) + environment.all()); + // Its panel, whose toggle switches it off; that is kept. + environment.panels_open = true; + ticks(host, environment, 10); + check(environment.panels.size() == 1 && environment.panels[0].title == "Board Break", "Its panel is built while the PLUGINS tab shows"); + environment.actions = {plugin_ui::Action{"board-break", "Board Break", "on", false}}; + ticks(host, environment, 10); + host.apply({}); + host.apply({{"board-break", directory, "1", check_package_files(directory)}}); + running(host, "board-break", environment); + environment.lines.clear(); + check(host.run_console("boardbreak") && environment.printed("boardbreak: off;"), "Switched off, it stays off:\n" + printed_lines(environment)); +} + +// The FPV drone as a mod (contrib/plugins/fpv-drone): its own tests of the flight maths pass, and +// over the fake world (flat ground at y = 0) it flies the camera, keeps the game off the input, +// plays its motor, follows the skater and tells the session where it is, within its 2 ms a tick. +void fpv_drone_flies_and_follows() { + TestEnvironment environment; + std::filesystem::remove_all(scratch / "storage" / "fpv-drone"); + auto& game = environment.fake; + environment.session_now = {true, false, {{1, "Me", true, true, false}, {2, "Other", false, false, false}}}; + Host host(environment, Side::client); + host.apply({contributed("fpv-drone")}); + running(host, "fpv-drone", environment); + check(host.run_console("fpv test"), "fpv test"); + ticks(host, environment, 150); // one test every 50 ms + check(environment.printed("fpv test: 27 passed, 0 failed") && !environment.printed("FAIL"), + "The drone's own tests pass:\n" + printed_lines(environment) + environment.all()); + running(host, "fpv-drone", environment); + // No level: it does not take off. + environment.lines.clear(); + check(host.run_console("fpv fly") && environment.printed("fpv: Load into a level to fly."), "No flight without a level"); + host.set_level("sanvan"); + environment.local_skater = SkaterState{{0, 0, 0}, 0, 0, 0, false, 0}; + environment.lines.clear(); + check(host.run_console("fpv fly") && environment.printed("fpv: flying."), "fpv fly:\n" + printed_lines(environment)); + // The fake's keys: full right, half forward, the mouse moving, boost. It must keep off the ground. + environment.sent.clear(); + ticks(host, environment, 600); + running(host, "fpv-drone", environment); + check(game.called("camera fpv-drone") && game.called("capture fpv-drone on"), "It flies the camera and keeps the game off the input\n" + environment.all()); + check(game.called("sound fpv-drone motor"), "Its motor plays\n" + environment.all()); + check(game.called("outfit fpv-drone eyewear Own_EyewearGlasses_Gen_SunglassesVisorLarge_00001"), "The pilot puts on goggles"); + check(game.called("hud fpv-drone on"), "and the game's HUD is hidden"); + check(environment.sent.size() >= 100 && environment.sent.size() <= 140, "It tells the session where it is about 12 times a second: " + + std::to_string(environment.sent.size())); + const auto flying_ms = status_of(host, "fpv-drone").tick_ms; + const auto flying_message = environment.sent[5]; + environment.lines.clear(); + check(host.run_console("fpv status") && environment.printed("fpv: fly, "), "fpv status:\n" + printed_lines(environment)); + // Follow me, behind a skater riding along +Z. + check(host.run_console("fpv follow"), "fpv follow"); + for (int i = 0; i < 600; ++i) { + environment.local_skater->position[2] += 5 * 0.0167f; + environment.local_skater->speed = 5; + ticks(host, environment, 1); + } + running(host, "fpv-drone", environment); + const auto following_ms = status_of(host, "fpv-drone").tick_ms; + const auto& last = game.calls; + const auto camera = std::find_if(last.rbegin(), last.rend(), [](const auto& c) { return c.starts_with("camera fpv-drone "); }); + check(camera != last.rend(), "Following flies the camera"); + float x{}, y{}, z{}; + std::istringstream words(camera->substr(std::string_view("camera fpv-drone ").size())); + check(static_cast(words >> x >> y >> z), "the camera's position: " + *camera); + const float skater_z = environment.local_skater->position[2]; + check(y > 0.5f && z < skater_z - 1 && z > skater_z - 12, std::format("The drone is behind the skater ({:.1f}, {:.1f}, {:.1f}), the skater at z {:.1f}", + x, y, z, skater_z)); + environment.lines.clear(); + game.calls.clear(); + check(host.run_console("fpv stop") && environment.printed("fpv: landed."), "fpv stop"); + ticks(host, environment, 120); + check(game.called("camera release fpv-drone") && game.called("outfit clear fpv-drone eyewear") && game.called("hud fpv-drone off"), + "Landing gives the camera, the eyewear and the HUD back\n" + environment.all()); + std::cout << std::format("fpv-drone: {:.3f} ms a tick flying, {:.3f} ms following (its budget is 2 ms)\n", flying_ms, following_ms); + check(flying_ms < 2 && following_ms < 2, "It stays within its time"); + // Another player's drone: a marker, a tag on their nametag and their motor; gone once not heard from. + game.calls.clear(); + environment.incoming.push_back({2, flying_message}); + ticks(host, environment, 2); + check(game.called("tag fpv-drone 2 FLYING DRONE") && game.called("sound fpv-drone motor"), "Their drone is tagged and heard\n" + environment.all()); + const bool marked = std::any_of(environment.layers.begin(), environment.layers.end(), [](const auto& layer) { + return std::any_of(layer.shapes.begin(), layer.shapes.end(), [](const auto& shape) { return shape.text.find(" m") != std::string::npos; }); + }); + check(marked, "and marked with how far it is"); + ticks(host, environment, 120); + check(game.called("tag fpv-drone 2 ") && game.called("stop sound fpv-drone"), "Not heard from, it is gone\n" + environment.all()); + environment.panels_open = true; + ticks(host, environment, 5); + check(environment.panels.size() == 5, "Its five panels are built while the PLUGINS tab shows: " + std::to_string(environment.panels.size())); + environment.panels_open = false; +} + void reskate_hostile_is_stopped_or_refused_every_time() { TestEnvironment environment; Host host(environment, Side::client); @@ -1125,7 +1701,9 @@ void reskate_hostile_is_stopped_or_refused_every_time() { {"recursion", false, 1}, {"escapes", false, 1}, {"teleport-spam", false, 1}, {"raycast-flood", false, 1}, {"trick-loop", true, 0, true}, {"draw-flood", false, 2}, {"toast-spam", false, 1}, {"sound-spam", false, 1}, {"message-flood", false, 1}, {"panel-loop", true, 2, false, true}, {"chat-spam", false, 1}, {"edit-spam", false, 1}, {"travel-spam", false, 1}, {"web-elsewhere", false, 1}, - {"bind-spam", false, 1}, {"screen-loop", true, 10}, + {"bind-spam", false, 1}, {"screen-loop", true, 10}, {"notice-spam", false, 1}, {"notice-spoof", false, 1}, + {"freeze-spam", false, 1}, {"server-spoof", false, 1}, {"picture-escape", false, 20}, {"clip-garbage", false, 1}, {"outfit-anywhere", false, 1}, + {"throwdown-spam", false, 1}, }; for (const auto& item : cases) { check(host.reload("reskate-hostile").find("reloaded") != std::string::npos, std::string("reloads before ") + item.attack); @@ -1168,6 +1746,10 @@ int main() { the_skater_can_be_read_moved_and_the_world_probed(); tricks_reach_plugins_that_ask_for_them(); plugins_draw_on_the_overlay(); + plugins_draw_pictures(); + plugins_server_copies_are_the_authority(); + plugins_animate_the_skater(); + plugins_play_their_own_sounds(); plugins_have_menu_panels(); plugins_play_sounds_and_read_the_mods(); plugins_follow_the_session_and_message_each_other(); @@ -1180,6 +1762,9 @@ int main() { a_package_with_a_program_or_on_the_wrong_side_is_not_loaded(); discovery_finds_the_plugins_among_the_mods_left_on(); reskate_test_passes_its_own_checks(); + road_rash_marks_the_skater_as_it_bails(); + board_break_snaps_the_board_on_a_slam(); + fpv_drone_flies_and_follows(); reskate_hostile_is_stopped_or_refused_every_time(); std::filesystem::remove_all(scratch); std::cout << "plugin tests passed\n"; diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 498345a2..0e091ec8 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,12 +17,13 @@ namespace { struct Event { std::string_view name, permission; }; -constexpr std::array events{{ +constexpr std::array events{{ {"load", {}}, {"unload", {}}, {"tick", {}}, {"level_loaded", {}}, {"level_unloading", {}}, {"trick_started", "events.tricks"}, {"trick_landed", "events.tricks"}, {"line_broken", "events.tricks"}, {"draw", "overlay"}, {"player_joined", "session.read"}, {"player_left", "session.read"}, {"message", "net.session"}, {"skater_state", "events.skater"}, {"bail", "events.skater"}, {"chat", "chat.read"}, {"track_changed", "music"}, {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, + {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; @@ -184,6 +185,8 @@ Vec3 position_of(lua_State* L, int index, const char* what) { void push_position(lua_State* L, const Vec3& v) { lua_pushvector(L, v[0], v[1], v[2]); } } +std::filesystem::path package_file(lua_State* L, Plugin& plugin, std::string_view path) { return package_path(L, plugin, path, false); } + struct ApiFunctions { static int log(lua_State* L) { auto& plugin = plugin_of(L); @@ -521,6 +524,12 @@ struct ApiFunctions { lua_setfield(L, -2, "multiplier"); lua_pushboolean(L, now->in_trick); lua_setfield(L, -2, "in_trick"); + lua_pushlstring(L, now->trick.data(), now->trick.size()); + lua_setfield(L, -2, "trick"); + lua_pushlstring(L, now->detail.data(), now->detail.size()); + lua_setfield(L, -2, "detail"); + lua_pushboolean(L, now->manual); + lua_setfield(L, -2, "manual"); return 1; } // Modules come from the plugin's own package, beside its entry: require("./rules") or @@ -587,6 +596,9 @@ void Api::install(Plugin& plugin, lua_State* L) { lua_setfield(L, -2, "api_version"); lua_pushlstring(L, plugin.id().data(), plugin.id().size()); lua_setfield(L, -2, "id"); + // "client" for the copy on a player's game, "server" for the session authority's copy. + lua_pushstring(L, plugin.services_.authority() ? "server" : "client"); + lua_setfield(L, -2, "side"); set_function(L, "log", ApiFunctions::log); set_function(L, "on", ApiFunctions::on); @@ -682,5 +694,7 @@ void push_player(lua_State* L, const SessionPlayer& player) { lua_setfield(L, -2, "me"); lua_pushboolean(L, player.host); lua_setfield(L, -2, "host"); + lua_pushboolean(L, player.server); + lua_setfield(L, -2, "server"); } } diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index ed7c063a..127a557e 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -19,6 +19,8 @@ void push_player(lua_State* L, const SessionPlayer& player); // A file's bytes when it is a regular file no larger than `most`; otherwise empty, and `error` // says why ("not found" when there is none). std::string read_package_file(const std::filesystem::path& file, std::size_t most, std::string& error); +// A file of the plugin's own package by its path there; a Lua error for one outside it. +std::filesystem::path package_file(lua_State* L, Plugin& plugin, std::string_view path); // JSON and Lua values, both ways. Throws std::runtime_error on a value JSON cannot hold. Json lua_to_json(lua_State* L, int index); void push_json(lua_State* L, const Json& value); diff --git a/Extension/Plugins/plugin_api_more.cpp b/Extension/Plugins/plugin_api_more.cpp index 860de4b7..efff9a87 100644 --- a/Extension/Plugins/plugin_api_more.cpp +++ b/Extension/Plugins/plugin_api_more.cpp @@ -104,9 +104,8 @@ struct MoreApi { Json data = lua_isnoneornil(L, 2) ? Json{} : lua_to_json(L, 2); if (data.dump().size() > plugin.limits_.event_bytes) luaL_error(L, "an event's data is at most %d KB as JSON", static_cast(plugin.limits_.event_bytes >> 10)); - const double now = plugin.services_.now_ms(); - if (now != plugin.emit_tick_) { - plugin.emit_tick_ = now; + if (plugin.tick_number_ != plugin.emit_tick_) { + plugin.emit_tick_ = plugin.tick_number_; plugin.emitted_ = 0; } if (++plugin.emitted_ > plugin.limits_.events_per_tick) diff --git a/Extension/Plugins/plugin_api_ui.cpp b/Extension/Plugins/plugin_api_ui.cpp index 06d3bd67..301759fb 100644 --- a/Extension/Plugins/plugin_api_ui.cpp +++ b/Extension/Plugins/plugin_api_ui.cpp @@ -3,6 +3,7 @@ #include "lua.h" #include "lualib.h" #include +#include #include #include @@ -80,6 +81,18 @@ plugin_ui::Style style_of(lua_State* L, int index) { lua_pop(L, 1); if (field("shadow")) style.shadow = lua_toboolean(L, -1) != 0; lua_pop(L, 1); + // A picture's source rectangle, {u0, v0, u1, v1} from 0 to 1: one glyph of a font atlas, a tile. + if (field("uv")) { + luaL_checktype(L, -1, LUA_TTABLE); + for (int i = 0; i < 4; ++i) { + lua_rawgeti(L, -1, i + 1); + const float v = finite(L, -1, "a uv corner"); + if (v < 0 || v > 1) luaL_error(L, "uv corners are 0 to 1"); + style.uv[static_cast(i)] = v; + lua_pop(L, 1); + } + } + lua_pop(L, 1); if (field("align")) { const std::string align = luaL_checkstring(L, -1); if (align == "left") style.align = 0; @@ -109,8 +122,13 @@ struct UiApi { static void add(lua_State* L, Plugin& plugin, plugin_ui::Shape shape) { plugin.shape_text_ += shape.text.size(); if (plugin.shapes_.size() >= plugin.limits_.shapes || plugin.shape_text_ > plugin.limits_.shape_text_bytes) { - if (!std::exchange(plugin.shapes_warned_, true)) - plugin.log(LogLevel::warn, "more than " + std::to_string(plugin.limits_.shapes) + " shapes in a frame: the rest are left out"); + if (const double now = plugin.services_.now_ms(); now - plugin.shapes_warned_at_ >= 10000) { + plugin.shapes_warned_at_ = now; + plugin.log(LogLevel::warn, plugin.shapes_.size() >= plugin.limits_.shapes + ? "more than " + std::to_string(plugin.limits_.shapes) + " shapes in a frame: the rest are left out" + : "more than " + std::to_string(plugin.limits_.shape_text_bytes >> 10) + + " KB of text in a frame's shapes: the rest are left out"); + } return; } (void)L; @@ -134,6 +152,38 @@ struct UiApi { add(L, plugin, {kind, {point(L, 2, "the circle's centre")}, radius, {}, style_of(L, 4)}); return 0; } + // A picture of the package's own (PNG or JPG), as the overlay loads it: its file's full path. + static std::string image_file(lua_State* L, Plugin& plugin, int index) { + const auto path = checked_text(L, index, 200, "a picture's path"); + if (const auto found = plugin.image_files_.find(path); found != plugin.image_files_.end()) return found->second; + if (plugin.image_files_.size() >= plugin.limits_.images) + luaL_error(L, "a plugin draws at most %d different pictures", static_cast(plugin.limits_.images)); + auto extension = std::filesystem::path(path).extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (extension != ".png" && extension != ".jpg" && extension != ".jpeg") luaL_error(L, "a picture is a .png or .jpg file"); + const auto file = package_file(L, plugin, path); + std::error_code code; + if (!std::filesystem::is_regular_file(file, code)) luaL_error(L, "\"%s\" is not a file in the plugin's package", path.c_str()); + const auto utf8 = file.u8string(); + return plugin.image_files_.emplace(path, std::string(utf8.begin(), utf8.end())).first->second; + } + // d:image(path, top_left, bottom_right, style): style's colour tints it, rounding rounds it. + static int image(lua_State* L) { + auto& plugin = drawing(L); + auto file = image_file(L, plugin, 2); + add(L, plugin, {plugin_ui::Shape::Kind::image, {point(L, 3, "a picture's top left"), point(L, 4, "a picture's bottom right")}, 0, + std::move(file), style_of(L, 5)}); + return 0; + } + // d:world_image(path, position, height): standing at a world position, `height` metres tall. + static int world_image(lua_State* L) { + auto& plugin = drawing(L); + auto file = image_file(L, plugin, 2); + const float height = finite(L, 4, "the picture's height"); + if (!(height > 0 && height <= 1000)) luaL_argerror(L, 4, "a height is more than 0 and at most 1000 metres"); + add(L, plugin, {plugin_ui::Shape::Kind::world_image, {point(L, 3, "the picture's place")}, height, std::move(file), style_of(L, 5)}); + return 0; + } static int text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::text); } static int world_text(lua_State* L) { return text_shape(L, plugin_ui::Shape::Kind::world_text); } static int rect(lua_State* L) { return two_points(L, plugin_ui::Shape::Kind::rect); } @@ -228,6 +278,18 @@ struct UiApi { static int heading(lua_State* L) { return plain(L, plugin_ui::Widget::Kind::heading); } static int label(lua_State* L) { return plain(L, plugin_ui::Widget::Kind::label); } static int separator(lua_State* L) { return plain(L, plugin_ui::Widget::Kind::separator); } + // ui:bar(text, value, minimum, maximum): a gauge, read only. + static int bar(lua_State* L) { + auto& plugin = building(L); + plugin_ui::Widget widget{plugin_ui::Widget::Kind::bar}; + widget.text = checked_text(L, 2, 60, "a bar's text"); + widget.minimum = finite(L, 4, "the minimum"); + widget.maximum = finite(L, 5, "the maximum"); + if (!(widget.maximum > widget.minimum)) luaL_argerror(L, 5, "the maximum must be above the minimum"); + widget.value = std::clamp(finite(L, 3, "the value"), widget.minimum, widget.maximum); + plugin.building_->widgets.push_back(std::move(widget)); + return 0; + } // Each returns its value: what the player set since the last build, else what it was given. static int button(lua_State* L) { auto& plugin = building(L); @@ -386,8 +448,11 @@ struct UiApi { if (std::from_chars(id.data(), id.data() + id.size(), to).ec != std::errc{} || !to) luaL_argerror(L, 1, "not a player"); } else { const std::string text = luaL_checkstring(L, 1); - if (text != "all" && (std::from_chars(text.data(), text.data() + text.size(), to).ec != std::errc{} || !to)) - luaL_argerror(L, 1, "send to \"all\", a player's id, or a player"); + if (text == "server") { + if (plugin.services_.authority()) luaL_argerror(L, 1, "the server copy is the server: send to \"all\" or a player"); + to = wire::authority; + } else if (text != "all" && (std::from_chars(text.data(), text.data() + text.size(), to).ec != std::errc{} || to <= wire::authority)) + luaL_argerror(L, 1, "send to \"all\", \"server\", a player's id, or a player"); } std::string payload; if (lua_isbuffer(L, 2)) { @@ -430,6 +495,8 @@ void UiApi::install(Plugin& plugin, lua_State* L) { set_function(L, "world_text", UiApi::world_text); set_function(L, "world_line", UiApi::world_line); set_function(L, "world_circle", UiApi::world_circle); + set_function(L, "image", UiApi::image); + set_function(L, "world_image", UiApi::world_image); set_function(L, "size", UiApi::size); set_function(L, "world_to_screen", UiApi::world_to_screen); readonly(); @@ -444,6 +511,7 @@ void UiApi::install(Plugin& plugin, lua_State* L) { set_function(L, "toggle", UiApi::toggle); set_function(L, "slider", UiApi::slider); set_function(L, "dropdown", UiApi::dropdown); + set_function(L, "bar", UiApi::bar); readonly(); plugin.ui_table_ = lua_ref(L, -1); lua_pop(L, 1); diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index 48203d61..73f86692 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include // The wider set's game parts of the reskate API: the skater's state, control and save points, the // HUD, park editor objects, the environment, travel, AI skaters, other players' skaters, nametags, @@ -101,6 +103,124 @@ struct WorldApi { luaL_argerror(L, 1, "a boost is \"forward\", \"up\" or \"offboard\""); return 0; } + static int freeze(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.freeze"); + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).freeze(plugin.id(), lua_toboolean(L, 1))); + } + // wipeout(force): the skater bails as from a slam of that force (0.5 to 3); one every 2 s. + static int wipeout(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.control", "reskate.skater.wipeout"); + const double force = luaL_optnumber(L, 1, 2.5); + if (!(force >= 0.5 && force <= 3)) luaL_argerror(L, 1, "a wipeout's force is 0.5 to 3"); + const double now = plugin.services_.now_ms(); + if (now - plugin.wipeout_at_ < 2000) return result(L, "one wipeout every 2 s"); + plugin.wipeout_at_ = now; + return result(L, game(plugin).wipeout(static_cast(force))); + } + // board.motion(): the board's velocity in the last physics step, or nil. + static int board_motion(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.read", "reskate.board.motion"); + const auto v = game(plugin).board_velocity(); + if (!v) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 1); + push_position(L, *v); + lua_setfield(L, -2, "velocity"); + return 1; + } + // ---- camera ------------------------------------------------------------------------------------- + static float fov_at(lua_State* L, int index) { + const double fov = luaL_optnumber(L, index, 0); + if (!(fov == 0 || (fov >= 10 && fov <= 150))) luaL_argerror(L, index, "a field of view is 10 to 150 degrees (0 or none: the camera's own)"); + return static_cast(fov); + } + // look(position, target, fov): the view from position towards target. + static int camera_look(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.look"); + const auto eye = position_of(L, 1, "the camera's position"); + const auto target = position_of(L, 2, "what the camera looks at"); + const float fov = fov_at(L, 3); + Vec3 back{eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]}; + const float length = std::sqrt(back[0] * back[0] + back[1] * back[1] + back[2] * back[2]); + if (!(length > 0.01f)) luaL_error(L, "the camera's position and what it looks at are the same point"); + for (auto& v : back) v /= length; + Vec3 right{back[2], 0, -back[0]}; + const float flat = std::sqrt(right[0] * right[0] + right[2] * right[2]); + if (flat > 0.001f) { + right[0] /= flat; + right[2] /= flat; + } else right = {1, 0, 0}; // straight up or down + const Vec3 up{back[1] * right[2] - back[2] * right[1], back[2] * right[0] - back[0] * right[2], back[0] * right[1] - back[1] * right[0]}; + const std::array pose{right[0], right[1], right[2], 0, up[0], up[1], up[2], 0, + back[0], back[1], back[2], 0, eye[0], eye[1], eye[2], 1}; + return result(L, game(plugin).set_camera(plugin.id(), pose, fov)); + } + // set(position, forward, up, fov): the view from position along forward, `up` giving its roll. + static int camera_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.set"); + const auto eye = position_of(L, 1, "the camera's position"); + const auto forward = position_of(L, 2, "the camera's forward"); + const auto up_hint = position_of(L, 3, "the camera's up"); + const float fov = fov_at(L, 4); + const auto length = [](const Vec3& v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); }; + const auto cross = [](const Vec3& a, const Vec3& b) { + return Vec3{a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; + }; + Vec3 back{-forward[0], -forward[1], -forward[2]}; + const float lb = length(back); + if (!(lb > 1e-4f)) luaL_error(L, "the camera's forward has no length"); + for (auto& v : back) v /= lb; + auto right = cross(up_hint, back); + const float lr = length(right); + if (!(lr > 1e-4f)) luaL_error(L, "the camera's up is along its forward"); + for (auto& v : right) v /= lr; + const auto up = cross(back, right); + const std::array pose{right[0], right[1], right[2], 0, up[0], up[1], up[2], 0, + back[0], back[1], back[2], 0, eye[0], eye[1], eye[2], 1}; + return result(L, game(plugin).set_camera(plugin.id(), pose, fov)); + } + static int camera_follow(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.follow"); + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).follow_player(plugin.id(), lua_isnoneornil(L, 1) ? 0 : player_of(L, 1))); + } + static int camera_release(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.release"); + game(plugin).release_camera(plugin.id()); + return 0; + } + static int camera_held(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.held"); + return result(L, game(plugin).camera_status(plugin.id())); + } + static int camera_get(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "camera", "reskate.camera.get"); + const auto view = game(plugin).camera_view(); + if (!view) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + push_position(L, view->position); + lua_setfield(L, -2, "position"); + push_position(L, view->forward); + lua_setfield(L, -2, "forward"); + lua_pushnumber(L, view->fov); + lua_setfield(L, -2, "fov"); + return 1; + } // ---- trainer: one save point of the plugin's own ------------------------------------------- static int set_point(lua_State* L) { auto& plugin = plugin_of(L); @@ -203,10 +323,19 @@ struct WorldApi { object.item = checked_text(L, 1, 200, "an item's key"); return edit(L, "place", object); } + // move(id, position, heading?, scale?): what is not given stays as the object has it. static int move(lua_State* L) { - need(L, plugin_of(L), "world.objects", "reskate.objects.move"); - auto object = pose(L, 2, 3, 0); + auto& plugin = plugin_of(L); + need(L, plugin, "world.objects", "reskate.objects.move"); + auto object = pose(L, 2, 3, 4); object.id = object_id(L, 1); + if (lua_isnoneornil(L, 3) || lua_isnoneornil(L, 4)) + for (const auto& existing : game(plugin).objects()) + if (existing.id == object.id) { + if (lua_isnoneornil(L, 3)) object.rotation = existing.rotation; + if (lua_isnoneornil(L, 4)) object.scale = existing.scale; + break; + } return edit(L, "move", object); } static int remove(lua_State* L) { @@ -325,16 +454,17 @@ struct WorldApi { std::string text = lua_isnoneornil(L, 2) ? std::string{} : checked_text(L, 2, 16, "a nametag's tag"); for (const char c : text) if (c < 0x20 || c > 0x7e) luaL_error(L, "a nametag's tag is printable text"); - std::uint32_t colour = 0xffffffff; - if (!lua_isnoneornil(L, 3)) { - const double value = luaL_checknumber(L, 3); - if (!(value >= 0 && value <= 4294967295.0) || value != std::floor(value)) luaL_argerror(L, 3, "a colour number is 0xRRGGBBAA"); - const auto rgba = static_cast(value); - colour = (rgba >> 24) | ((rgba >> 16) & 0xff) << 8 | ((rgba >> 8) & 0xff) << 16 | (rgba & 0xff) << 24; - } - game(plugin).set_nametag(plugin.id(), player, std::move(text), colour); + game(plugin).set_nametag(plugin.id(), player, std::move(text), colour_at(L, 3, 0xffffffff)); return 0; } + // 0xRRGGBBAA from Lua, as the overlay draws it (IM_COL32: 0xAABBGGRR). + static std::uint32_t colour_at(lua_State* L, int index, std::uint32_t fallback) { + if (lua_isnoneornil(L, index)) return fallback; + const double value = luaL_checknumber(L, index); + if (!(value >= 0 && value <= 4294967295.0) || value != std::floor(value)) luaL_argerror(L, index, "a colour number is 0xRRGGBBAA"); + const auto rgba = static_cast(value); + return (rgba >> 24) | ((rgba >> 16) & 0xff) << 8 | ((rgba >> 8) & 0xff) << 16 | (rgba & 0xff) << 24; + } static int voice_volume(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "voice.control", "reskate.voice.set_volume"); @@ -355,7 +485,518 @@ struct WorldApi { plugin.chat_at_ = now; return result(L, game(plugin).send_chat(text)); } + // A line only this player sees, under the plugin's name: no / commands, 5 a second. + static int chat_local(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "chat.local", "reskate.chat.notice"); + const auto text = checked_text(L, 1, 200, "a chat line"); + if (text.empty()) luaL_argerror(L, 1, "a chat line, not empty"); + for (const unsigned char c : text) + if (c < 0x20) luaL_argerror(L, 1, "a chat line is one line of text"); + const auto colour = colour_at(L, 2, 0); + if (!within(plugin.services_.now_ms(), plugin.local_chat_second_, plugin.local_chats_, 5)) return result(L, "more than 5 lines in a second"); + return result(L, game(plugin).local_chat(plugin.id(), text, colour)); + } + // ---- skater.animation ----------------------------------------------------------------------------- + static std::size_t pose_bytes(const anim_studio::Clip& clip) { return clip.times.size() * clip.joints * 40 + clip.board.size() * 40; } + // A clip by its path in the package (an Animation Studio clip file, FAS1 to FAS3), or "rec:". + static Game::ClipRef clip_at(lua_State* L, Plugin& plugin, int index) { + const auto name = checked_text(L, index, 200, "a clip"); + if (const auto found = plugin.clips_.find(name); found != plugin.clips_.end()) return found->second; + if (name.starts_with("rec:")) luaL_error(L, "there is no recording %s", name.c_str()); + std::string error; + const auto bytes = read_package_file(package_file(L, plugin, name), 8u << 20, error); + if (bytes.empty()) luaL_error(L, "the clip %s cannot be read: %s", name.c_str(), error.empty() ? "it is empty" : error.c_str()); + // Its size as poses, from the header (magic, joints, frames), before anything is made of it. + if (bytes.size() >= 12) { + std::uint32_t joints{}, frames{}; + std::memcpy(&joints, bytes.data() + 4, 4); + std::memcpy(&frames, bytes.data() + 8, 4); + if (joints > anim_studio::skeleton_joints) luaL_error(L, "the clip %s has more joints than the skater", name.c_str()); + if (std::uint64_t{joints + 1} * frames * 40 + plugin.clip_bytes_ > plugin.limits_.clip_bytes) + luaL_error(L, "a plugin's clips hold at most %d MB of poses", static_cast(plugin.limits_.clip_bytes >> 20)); + } + std::shared_ptr clip; + try { + clip = std::make_shared(anim_studio::clip_from_bytes( + std::span(reinterpret_cast(bytes.data()), bytes.size()))); + } catch (const std::exception& e) { + luaL_error(L, "the clip %s is not one: %s", name.c_str(), e.what()); + } + if (clip->empty()) luaL_error(L, "the clip %s has no frames", name.c_str()); + plugin.clip_bytes_ += pose_bytes(*clip); + return plugin.clips_.emplace(name, std::move(clip)).first->second; + } + static float number_field(lua_State* L, int table, const char* name, float fallback, float least, float most) { + if (lua_isnoneornil(L, table)) return fallback; + luaL_checktype(L, table, LUA_TTABLE); + lua_getfield(L, table, name); + float value = fallback; + if (!lua_isnil(L, -1)) { + const double v = luaL_checknumber(L, -1); + if (!(v >= least && v <= most)) luaL_error(L, "%s is %g to %g", name, static_cast(least), static_cast(most)); + value = static_cast(v); + } + lua_pop(L, 1); + return value; + } + static bool flag_field(lua_State* L, int table, const char* name) { + if (lua_isnoneornil(L, table)) return false; + luaL_checktype(L, table, LUA_TTABLE); + lua_getfield(L, table, name); + const bool value = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return value; + } + static Plugin& animating(lua_State* L, const char* what) { + auto& plugin = plugin_of(L); + need(L, plugin, "skater.animation", what); + return plugin; + } + // play(clip, {speed, blend, loop}): the clip on the skater's body from now, in place. + static int animation_play(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.play"); + auto clip = clip_at(L, plugin, 1); + const float speed = number_field(L, 2, "speed", 1, 0.1f, 4), blend = number_field(L, 2, "blend", 0.15f, 0, 2); + const bool loop = flag_field(L, 2, "loop"); + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).play_clip(plugin.id(), std::move(clip), speed, blend, loop)); + } + static int animation_stop(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.stop"); + game(plugin).stop_clip(plugin.id()); + return 0; + } + static int animation_playing(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.playing"); + lua_pushboolean(L, game(plugin).clip_playing(plugin.id())); + return 1; + } + // replace(trick, clip or nil, {pop, speed}): the game's own trick plays as the clip. + static int animation_replace(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.replace"); + auto trick = checked_text(L, 1, 48, "a trick's name"); + for (auto& c : trick) { + c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == ' ' || c == '-')) + luaL_argerror(L, 1, "a trick's name is the game's (\"kickflip\", \"ollie\")"); + } + auto clip = lua_isnoneornil(L, 2) ? Game::ClipRef{} : clip_at(L, plugin, 2); + std::string pop; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "pop"); + if (!lua_isnil(L, -1)) pop = luaL_checkstring(L, -1); + lua_pop(L, 1); + if (pop != "" && pop != "low" && pop != "mid" && pop != "high") luaL_argerror(L, 3, "a pop is \"low\", \"mid\" or \"high\""); + } + const float speed = number_field(L, 3, "speed", 1, 0.1f, 4); + return result(L, game(plugin).replace_trick(plugin.id(), std::move(trick), std::move(pop), std::move(clip), speed)); + } + // record(seconds): the skater's pose for so long (0.2 to 10 s), as a clip recorded() hands back. + static int animation_record(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.record"); + const double seconds = luaL_checknumber(L, 1); + if (!(seconds >= 0.2 && seconds <= 10)) luaL_argerror(L, 1, "a recording is 0.2 to 10 seconds"); + if (plugin.clip_bytes_ + 10u * 60 * 396 * 40 > plugin.limits_.clip_bytes) + return result(L, "its clips hold too much already (each recording can be 10 s of poses)"); + return result(L, game(plugin).record_clip(plugin.id(), static_cast(seconds))); + } + // The last recording, once it is done, as a clip's name ("rec:1"), or nil. + static int animation_recorded(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.recorded"); + auto clip = game(plugin).take_recording(plugin.id()); + if (!clip) { + lua_pushnil(L); + return 1; + } + const auto name = "rec:" + std::to_string(++plugin.recordings_); + plugin.clip_bytes_ += pose_bytes(*clip); + plugin.clips_.emplace(name, std::move(clip)); + lua_pushlstring(L, name.data(), name.size()); + return 1; + } + // Lets a clip go (its memory with it); one still playing plays on. + static int animation_forget(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.forget"); + const auto name = checked_text(L, 1, 200, "a clip"); + if (const auto found = plugin.clips_.find(name); found != plugin.clips_.end()) { + plugin.clip_bytes_ -= std::min(plugin.clip_bytes_, pose_bytes(*found->second)); + plugin.clips_.erase(found); + } + return 0; + } + static int animation_trick(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.trick"); + const auto name = game(plugin).current_trick(); + if (name.empty()) lua_pushnil(L); + else lua_pushlstring(L, name.data(), name.size()); + return 1; + } + // ---- board: the local skateboard's 16 bones as drawn ----------------------------------------------------- + static int bone_number(lua_State* L, int index) { + const double b = luaL_checknumber(L, index); + if (!(b >= 2 && b <= 15) || b != std::floor(b)) luaL_argerror(L, index, "a board's bone is 2 to 15 (0 and 1 place the board)"); + return static_cast(b); + } + static std::vector bone_list(lua_State* L, int index) { + luaL_checktype(L, index, LUA_TTABLE); + std::vector out; + const int count = lua_objlen(L, index); + if (count > 16) luaL_argerror(L, index, "a board has 16 bones"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, index, i); + out.push_back(bone_number(L, lua_gettop(L))); + lua_pop(L, 1); + } + return out; + } + static std::array quaternion_at(lua_State* L, int index) { + luaL_checktype(L, index, LUA_TTABLE); + std::array q{}; + for (int i = 0; i < 4; ++i) { + lua_rawgeti(L, index, i + 1); + const double v = luaL_checknumber(L, -1); + if (!std::isfinite(v)) luaL_error(L, "a rotation is 4 finite numbers {x, y, z, w}"); + q[static_cast(i)] = static_cast(v); + lua_pop(L, 1); + } + const float n = std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + if (!(n > 1e-4f)) luaL_error(L, "a rotation {x, y, z, w} has a length"); + for (auto& v : q) v /= n; + return q; + } + static void push_quaternion(lua_State* L, const std::array& q) { + lua_createtable(L, 4, 0); + for (int i = 0; i < 4; ++i) { + lua_pushnumber(L, q[static_cast(i)]); + lua_rawseti(L, -2, i + 1); + } + } + // bones(): {[0] = {position, rotation = {x, y, z, w}, scale}, ... [15]} as last drawn, or nil. + static int board_bones(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.bones"); + const auto bones = game(plugin).board_bones(); + if (!bones) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 15, 1); + for (int i = 0; i < 16; ++i) { + const auto& b = (*bones)[static_cast(i)]; + lua_createtable(L, 0, 3); + push_position(L, b.position); + lua_setfield(L, -2, "position"); + push_quaternion(L, b.rotation); + lua_setfield(L, -2, "rotation"); + push_position(L, b.scale); + lua_setfield(L, -2, "scale"); + lua_rawseti(L, -2, i); + } + return 1; + } + // set(bone, {position, rotation, space = "local" or "world"}). + static int board_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.set"); + const int bone = bone_number(L, 1); + luaL_checktype(L, 2, LUA_TTABLE); + Game::BoardBone value; + lua_getfield(L, 2, "position"); + value.position = position_of(L, lua_gettop(L), "a bone's position"); + lua_pop(L, 1); + lua_getfield(L, 2, "rotation"); + if (!lua_isnil(L, -1)) value.rotation = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, 2, "space"); + const std::string space = lua_isnil(L, -1) ? "local" : luaL_checkstring(L, -1); + lua_pop(L, 1); + if (space != "local" && space != "world") luaL_argerror(L, 2, "space is \"local\" or \"world\""); + return result(L, game(plugin).board_set(plugin.id(), bone, value, space == "world")); + } + static int board_hide(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.hide"); + return result(L, game(plugin).board_hide(plugin.id(), bone_number(L, 1))); + } + static int board_clear(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.clear"); + game(plugin).board_clear(plugin.id(), lua_isnoneornil(L, 1) ? -1 : bone_number(L, 1)); + return 0; + } + // park({12, 13, 14, 15}): these bones out of sight on every board, other players' too. + static int board_park(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.park"); + game(plugin).board_park(plugin.id(), bone_list(L, 1)); + return 0; + } + // toss({bones}, {velocity, spin, ground}): a piece of the board comes off and falls. + static int board_toss(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "board", "reskate.board.toss"); + auto bones = bone_list(L, 1); + luaL_checktype(L, 2, LUA_TTABLE); + lua_getfield(L, 2, "velocity"); + const auto velocity = position_of(L, lua_gettop(L), "the piece's velocity"); + lua_pop(L, 1); + lua_getfield(L, 2, "spin"); + const auto spin = lua_isnil(L, -1) ? Vec3{} : position_of(L, lua_gettop(L), "the piece's spin"); + lua_pop(L, 1); + lua_getfield(L, 2, "ground"); + const double ground = luaL_checknumber(L, -1); + lua_pop(L, 1); + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).board_toss(plugin.id(), std::move(bones), velocity, spin, static_cast(ground))); + } + // ---- outfit ------------------------------------------------------------------------------------------ + // set(slot, item, {placement = {...}, covers = bool}): the item worn over the outfit in that slot. + static int outfit_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.set"); + const auto slot = checked_text(L, 1, 48, "a slot's name"); + const auto item = checked_text(L, 2, 200, "an item's name"); + // Only the face and skin layers, and eyewear (Extension/Skater/outfit_overlay.h). + std::string lowered = slot; + for (auto& c : lowered) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); + if (lowered.find("tattoo") == std::string::npos && lowered.find("moles") == std::string::npos && lowered.find("eyewear") == std::string::npos) + return result(L, "only tattoo, moles and eyewear slots"); + std::vector placement; + bool covers{}; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "covers"); + covers = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + lua_getfield(L, 3, "placement"); + if (!lua_isnil(L, -1)) { + luaL_checktype(L, -1, LUA_TTABLE); + const int count = lua_objlen(L, -1); + if (count > 8) luaL_error(L, "a placement is at most 8 numbers"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, -1, i); + placement.push_back(static_cast(luaL_checknumber(L, -1))); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + } + if (!within(plugin.services_.now_ms(), plugin.control_second_, plugin.controls_, 4)) return result(L, "asked more than 4 times in a second"); + return result(L, game(plugin).outfit_set(plugin.id(), slot, item, std::move(placement), covers)); + } + static int outfit_clear(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.clear"); + game(plugin).outfit_clear(plugin.id(), lua_isnoneornil(L, 1) ? std::string{} : checked_text(L, 1, 48, "a slot's name")); + return 0; + } + // find(ending): the installed item whose name ends so (any case), or nil. + static int outfit_find(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.find"); + const auto name = game(plugin).outfit_find(checked_text(L, 1, 200, "an item's name")); + if (name.empty()) lua_pushnil(L); + else lua_pushlstring(L, name.data(), name.size()); + return 1; + } + static int outfit_slots(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.slots"); + const auto slots = game(plugin).outfit_slots(); + lua_createtable(L, static_cast(slots.size()), 0); + for (std::size_t i = 0; i < slots.size(); ++i) { + lua_createtable(L, 0, 3); + lua_pushlstring(L, slots[i].name.data(), slots[i].name.size()); + lua_setfield(L, -2, "slot"); + lua_pushlstring(L, slots[i].item.data(), slots[i].item.size()); + lua_setfield(L, -2, "item"); + lua_createtable(L, static_cast(slots[i].parameters.size()), 0); + for (std::size_t k = 0; k < slots[i].parameters.size(); ++k) { + lua_pushnumber(L, std::isfinite(slots[i].parameters[k]) ? slots[i].parameters[k] : 0.0f); + lua_rawseti(L, -2, static_cast(k + 1)); + } + lua_setfield(L, -2, "parameters"); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + static int outfit_status(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "outfit", "reskate.outfit.status"); + const auto status = game(plugin).outfit_status(); + lua_pushlstring(L, status.data(), status.size()); + return 1; + } + // ---- audio -------------------------------------------------------------------------------------------- + static std::shared_ptr wave_at(lua_State* L, Plugin& plugin, const std::string& path) { + if (const auto found = plugin.waves_.find(path); found != plugin.waves_.end()) return found->second; + if (plugin.waves_.size() >= plugin.limits_.sounds) luaL_error(L, "a plugin plays at most %d different sounds", static_cast(plugin.limits_.sounds)); + std::string error; + const auto bytes = read_package_file(package_file(L, plugin, path), 8u << 20, error); + if (bytes.empty()) luaL_error(L, "the sound %s cannot be read: %s", path.c_str(), error.empty() ? "it is empty" : error.c_str()); + auto wave = decode_wave(std::span(reinterpret_cast(bytes.data()), bytes.size()), 30, error); + if (!wave) luaL_error(L, "the sound %s cannot be played: %s", path.c_str(), error.c_str()); + return plugin.waves_.emplace(path, std::move(wave)).first->second; + } + // volume, pitch, range, throttle, speed, loop, position (a vector, or "skater"). + static void sound_options(lua_State* L, int index, SoundStart* start, SoundChange* change) { + if (lua_isnoneornil(L, index)) return; + luaL_checktype(L, index, LUA_TTABLE); + const auto number = [&](const char* name, float least, float most, auto&& set) { + lua_getfield(L, index, name); + if (!lua_isnil(L, -1)) { + const double v = luaL_checknumber(L, -1); + if (!(v >= least && v <= most)) luaL_error(L, "%s is %g to %g", name, static_cast(least), static_cast(most)); + set(static_cast(v)); + } + lua_pop(L, 1); + }; + number("volume", 0, 1, [&](float v) { if (start) start->volume = v; else change->volume = v; }); + number("pitch", 0.25f, 4, [&](float v) { if (start) start->pitch = v; else change->pitch = v; }); + number("range", 1, 500, [&](float v) { if (start) start->range = v; else change->range = v; }); + number("throttle", 0, 1, [&](float v) { if (start) start->throttle = v; else change->throttle = v; }); + number("speed", 0, 200, [&](float v) { if (start) start->speed = v; else change->speed = v; }); + if (start) { + lua_getfield(L, index, "loop"); + start->loop = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + } + lua_getfield(L, index, "position"); + if (!lua_isnil(L, -1)) { + std::optional at; + if (lua_isstring(L, -1) && std::string_view(lua_tostring(L, -1)) == "skater") at = std::nullopt; + else at = position_of(L, lua_gettop(L), "a sound's position"); + if (start) start->position = at; + else change->position = at; + } + lua_pop(L, 1); + } + // play(path or "motor", options): a sound in the game where it is, or at the skater; its id. + static int audio_play(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "audio", "reskate.audio.play"); + const auto what = checked_text(L, 1, 200, "a sound"); + SoundStart start; + if (what != "motor") start.wave = wave_at(L, plugin, what); + sound_options(L, 2, &start, nullptr); + if (!within(plugin.services_.now_ms(), plugin.wave_second_, plugin.wave_starts_, 20)) return result(L, "more than 20 sounds in a second"); + const auto id = game(plugin).play_sound(plugin.id(), std::move(start)); + if (!id) return result(L, "it plays 8 sounds already, or there is no level"); + lua_pushnumber(L, static_cast(id)); + return 1; + } + static std::uint64_t sound_id(lua_State* L, int index) { + const double id = luaL_checknumber(L, index); + if (!(id >= 1 && id < 9007199254740992.0) || id != std::floor(id)) luaL_argerror(L, index, "not a sound's id"); + return static_cast(id); + } + static int audio_set(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "audio", "reskate.audio.set"); + const auto id = sound_id(L, 1); + SoundChange change; + sound_options(L, 2, nullptr, &change); + lua_pushboolean(L, game(plugin).change_sound(plugin.id(), id, change)); + return 1; + } + static int audio_stop(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "audio", "reskate.audio.stop"); + game(plugin).stop_sound(plugin.id(), sound_id(L, 1)); + return 0; + } + static int audio_playing(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "audio", "reskate.audio.playing"); + lua_pushboolean(L, game(plugin).sound_playing(plugin.id(), sound_id(L, 1))); + return 1; + } + // ---- server: what only the session's authority does ---------------------------------------------- + static Plugin& authority(lua_State* L, const char* what) { + auto& plugin = plugin_of(L); + need(L, plugin, "server", what); + if (!plugin.services_.authority()) + luaL_error(L, "%s is for the plugin's server copy (reskate.side is \"server\": \"side\" \"server\" or \"both\" in reskate-plugin.json)", what); + if (!within(plugin.services_.now_ms(), plugin.server_second_, plugin.server_calls_, 20)) luaL_error(L, "more than 20 server calls in a second"); + return plugin; + } + static int server_kick(lua_State* L) { + auto& plugin = authority(L, "reskate.server.kick"); + const auto player = player_of(L, 1); + const auto why = lua_isnoneornil(L, 2) ? std::string{} : checked_text(L, 2, 120, "why"); + return result(L, game(plugin).kick(player, why)); + } + static int server_say(lua_State* L) { + auto& plugin = authority(L, "reskate.server.say"); + const auto text = checked_text(L, 1, 200, "a chat line"); + if (text.empty() || text.front() == '/') luaL_argerror(L, 1, "a chat line, not empty and not a / command"); + const double now = plugin.services_.now_ms(); + if (now - plugin.chat_at_ < 1000) return result(L, "one line a second"); + plugin.chat_at_ = now; + return result(L, game(plugin).announce(plugin.channel_name(), text)); + } + static int server_tell(lua_State* L) { + auto& plugin = authority(L, "reskate.server.tell"); + const auto player = player_of(L, 1); + const auto text = checked_text(L, 2, 200, "a chat line"); + if (text.empty()) luaL_argerror(L, 2, "a chat line, not empty"); + return result(L, game(plugin).tell(plugin.channel_name(), player, text)); + } + static int server_move(lua_State* L) { + auto& plugin = authority(L, "reskate.server.move"); + const auto player = player_of(L, 1); + return result(L, game(plugin).move_player(player, position_of(L, 2, "where the player goes"))); + } // ---- throwdowns ------------------------------------------------------------------------------------- + // host(mode, players, {max}): a drop where the skater stands for these players ("jam", + // "spot_battle" or "skate"); it starts once they all join. One every 5 s. + static int throwdown_host(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.host"); + const std::string mode = luaL_checkstring(L, 1); + const char* series = mode == "jam" || mode == "JamSession" ? "JamSession" + : mode == "spot_battle" || mode == "SpotBattle" ? "SpotBattle" + : mode == "skate" || mode == "ThrowdownSkate" ? "ThrowdownSkate" : nullptr; + if (!series) luaL_argerror(L, 1, "a throwdown is \"jam\", \"spot_battle\" or \"skate\""); + luaL_checktype(L, 2, LUA_TTABLE); + std::vector players; + const int count = lua_objlen(L, 2); + if (count < 1 || count > 7) luaL_argerror(L, 2, "a drop is for 1 to 7 other players"); + for (int i = 1; i <= count; ++i) { + lua_rawgeti(L, 2, i); + const auto player = player_of(L, lua_gettop(L)); + lua_pop(L, 1); + if (std::find(players.begin(), players.end(), player) == players.end()) players.push_back(player); + } + int most = 0; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "max"); + if (!lua_isnil(L, -1)) most = static_cast(luaL_checkinteger(L, -1)); + lua_pop(L, 1); + if (most != 0 && (most < 2 || most > 8)) luaL_argerror(L, 3, "max is 2 to 8 players"); + } + const double now = plugin.services_.now_ms(); + if (now - plugin.host_at_ < 5000) return result(L, "one drop every 5 s"); + plugin.host_at_ = now; + return result(L, game(plugin).host_throwdown(plugin.id(), series, std::move(players), most)); + } + static int throwdown_cancel(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.cancel"); + game(plugin).cancel_throwdown(plugin.id()); + return 0; + } + static int throwdown_hosting(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "throwdowns", "reskate.throwdowns.hosting"); + const auto status = game(plugin).throwdown_status(plugin.id()); + if (status.empty()) lua_pushnil(L); + else lua_pushlstring(L, status.data(), status.size()); + return 1; + } static int throwdown_state(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "throwdowns", "reskate.throwdowns.state"); @@ -412,6 +1053,21 @@ struct WorldApi { static int play(lua_State* L) { return music(L, "play"); } static int pause(lua_State* L) { return music(L, "pause"); } static int next(lua_State* L) { return music(L, "next"); } + // The game's own soundtrack: what it plays on the player's headphones, and a skip. + static int game_current(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "music", "reskate.music.game"); + const auto t = game(plugin).game_track(); + if (!t) lua_pushnil(L); + else push_track(L, *t); + return 1; + } + static int game_next(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "music", "reskate.music.game_next"); + if (!within(plugin.services_.now_ms(), plugin.music_second_, plugin.musics_, 2)) return result(L, "asked more than twice in a second"); + return result(L, game(plugin).game_music("next")); + } static int previous(lua_State* L) { return music(L, "previous"); } // ---- progression.read / settings.read ------------------------------------------------------------- static int progress(lua_State* L) { @@ -497,6 +1153,87 @@ struct WorldApi { plugin.binds_.insert(action); return result(L, {}); } + static int input_flight(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.flight"); + const auto in = game(plugin).flight_input(plugin.id()); + if (!in) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 6); + const std::pair fields[] = {{"right", in->right}, {"up", in->up}, {"forward", in->forward}, + {"look_x", in->look_x}, {"look_y", in->look_y}}; + for (const auto& [name, value] : fields) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); + } + lua_pushboolean(L, in->boost); + lua_setfield(L, -2, "boost"); + return 1; + } + static void push_buttons(lua_State* L, std::uint32_t bits, const std::pair* names, std::size_t count) { + lua_createtable(L, 0, static_cast(count)); + for (std::size_t i = 0; i < count; ++i) { + lua_pushboolean(L, (bits & names[i].second) != 0); + lua_setfield(L, -2, names[i].first); + } + } + static int input_gamepad(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.gamepad"); + const auto pad = game(plugin).gamepad(); + if (!pad) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 10); + const std::pair axes[] = {{"left_x", pad->left_x}, {"left_y", pad->left_y}, {"right_x", pad->right_x}, + {"right_y", pad->right_y}, {"left_trigger", pad->left_trigger}, {"right_trigger", pad->right_trigger}}; + for (const auto& [name, value] : axes) { + lua_pushnumber(L, value); + lua_setfield(L, -2, name); + } + // XInput's button bits. + static constexpr std::pair names[] = { + {"up", 0x1}, {"down", 0x2}, {"left", 0x4}, {"right", 0x8}, {"start", 0x10}, {"back", 0x20}, {"left_stick", 0x40}, + {"right_stick", 0x80}, {"lb", 0x100}, {"rb", 0x200}, {"a", 0x1000}, {"b", 0x2000}, {"x", 0x4000}, {"y", 0x8000}}; + push_buttons(L, pad->buttons, names, std::size(names)); + lua_setfield(L, -2, "buttons"); + lua_pushboolean(L, pad->playstation); + lua_setfield(L, -2, "playstation"); + return 1; + } + static int input_joystick(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.joystick"); + const auto stick = game(plugin).joystick(); + if (!stick) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 3); + lua_createtable(L, 6, 0); + for (int i = 0; i < 6; ++i) { + lua_pushnumber(L, stick->axes[static_cast(i)]); + lua_rawseti(L, -2, i + 1); + } + lua_setfield(L, -2, "axes"); + lua_createtable(L, 32, 0); + for (int i = 0; i < 32; ++i) { + lua_pushboolean(L, (stick->buttons >> i) & 1); + lua_rawseti(L, -2, i + 1); + } + lua_setfield(L, -2, "buttons"); + lua_pushlstring(L, stick->name.data(), stick->name.size()); + lua_setfield(L, -2, "name"); + return 1; + } + static int input_capture(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.capture"); + return result(L, game(plugin).capture_input(plugin.id(), lua_toboolean(L, 1) != 0)); + } // ---- ui.screen --------------------------------------------------------------------------------------- // screen(title, build): a full-screen screen, drawn while open() has it open; Esc closes it. static int screen(lua_State* L) { @@ -558,7 +1295,10 @@ static void into(lua_State* L, const char* field, std::initializer_list namespace dingosdk::plugins { -namespace { -constexpr std::array runnable{".exe", ".dll", ".sys", ".scr", ".com", ".cpl", ".msi", ".msp", - ".bat", ".cmd", ".ps1", ".psm1", ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".hta", ".lnk", ".pif", ".reg"}; -constexpr std::size_t most_files = 20000; - -// path::string() throws on names outside the ANSI code page; a name here is only shown or compared. -std::string ascii_lowercase(const std::filesystem::path& path) { - std::string text; - for (const auto c : path.generic_wstring()) - text += c < 0x80 ? static_cast(c >= L'A' && c <= L'Z' ? c + 32 : c) : '?'; - return text; -} -} - -std::string check_package_files(const std::filesystem::path& directory) { - std::error_code code; - std::size_t files = 0; - for (std::filesystem::recursive_directory_iterator it(directory, std::filesystem::directory_options::skip_permission_denied, code), end; - !code && it != end; it.increment(code)) { - std::error_code entry_code; - if (!it->is_regular_file(entry_code)) continue; - if (++files > most_files) return "it holds more than 20000 files"; - const auto shown = ascii_lowercase(std::filesystem::relative(it->path(), directory, entry_code)); - const auto extension = ascii_lowercase(it->path().extension()); - bool program = std::find(runnable.begin(), runnable.end(), extension) != runnable.end(); - if (!program) { - std::ifstream in(it->path(), std::ios::binary); - char magic[2]{}; - program = in.read(magic, 2) && magic[0] == 'M' && magic[1] == 'Z'; - } - if (program) return "it holds " + shown + ", a program or a script Windows runs. A plugin never ships one"; - } - return {}; -} - std::vector discover(const std::filesystem::path& data_root, FileChecks& checks, std::vector* mods) { std::vector result; const auto list = mods::scan_mods(data_root); diff --git a/Extension/Plugins/plugin_discovery.h b/Extension/Plugins/plugin_discovery.h index 342b49c9..90a638da 100644 --- a/Extension/Plugins/plugin_discovery.h +++ b/Extension/Plugins/plugin_discovery.h @@ -13,7 +13,4 @@ namespace dingosdk::plugins { using FileChecks = std::map>; // `mods`, when given, gets every mod in the Mods folder (for reskate.mods). std::vector discover(const std::filesystem::path& data_root, FileChecks& checks, std::vector* mods = nullptr); -// Why a package may not load, from its files alone, or empty. A plugin never ships a program: no -// Windows executable (by its first bytes, whatever its name) and no script Windows runs. -std::string check_package_files(const std::filesystem::path& directory); } diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 54e5cb74..07f65187 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -1,6 +1,9 @@ #pragma once +#include "plugin_wave.h" +#include "Extension/AnimationStudio/anim_core.h" #include #include +#include #include #include #include @@ -17,6 +20,67 @@ namespace dingosdk::plugins { using Vec3 = std::array; inline constexpr char unavailable[] = "unavailable here"; +// The game's camera as it is now: where it is, the way it looks, and its vertical field of view. +struct CameraView { + Vec3 position{}, forward{}; + float fov{}; +}; +// Input for a plugin that flies something: the flight keys and mouse look (as Freecam reads them), +// a gamepad's sticks, triggers and buttons (XInput's bits), a joystick or radio's axes and buttons. +struct FlightInput { + float right{}, up{}, forward{}, look_x{}, look_y{}; + bool boost{}; +}; +struct PadInput { + float left_x{}, left_y{}, right_x{}, right_y{}, left_trigger{}, right_trigger{}; + std::uint32_t buttons{}; + bool playstation{}; +}; +struct StickInput { + std::array axes{}; + std::uint32_t buttons{}; + std::string name; +}; +// A sound a plugin plays (reskate.audio): its wave, or the motor synth when it has none. +struct SoundStart { + std::shared_ptr wave; + float volume{1}, pitch{1}, range{40}, throttle{}, speed{}; + bool loop{}; + std::optional position; // empty: at the local skater +}; +struct SoundChange { + std::optional volume, pitch, range, throttle, speed; + std::optional> position; +}; +// What the local skater's physics did since the last tick (events.skater). +struct SkaterNews { + struct Landing { + float down_speed{}, speed{}; + bool rail{}; + }; + struct Hit { + std::string bone; // as the game names its body ("Left forearm") + float impact{}; + bool vehicle{}, board{}; + }; + struct Fall { + float seconds{}, left{}, right{}; + struct Bone { + std::string name; + float impact{}, slide{}; + }; + std::vector bones; // those that hit or slid + }; + std::vector landings; + std::vector hits; + std::vector falls; +}; +// The last slam: its force and the game's cause number, and how long ago (ms). +struct SlamNews { + float force{}; + int cause{-1}; + double ago_ms{}; +}; struct CatalogItem { std::string key, title, category; }; @@ -116,16 +180,111 @@ public: // Chat lines after `sequence`, as this player sees them (filtered when they filter). virtual std::vector chat_since(std::uint64_t) { return {}; } virtual std::string send_chat(std::string_view) { return unavailable; } + // A line in this player's chat only, under the plugin's name with a "Plugin" tag. + virtual std::string local_chat(std::string_view /*plugin*/, std::string_view, std::uint32_t /*colour, 0 the usual*/) { return unavailable; } virtual ThrowdownState throwdowns() { return {}; } + // A drop the local player hosts where the skater stands, for chosen players of the session + // (`series` "JamSession", "SpotBattle" or "ThrowdownSkate"): they see it and join it with the + // game's own Join, and it starts once all have. Its status: "", "asked", "spawning", "open", + // "started", "cancelled", or why it failed. + virtual std::string host_throwdown(std::string_view /*plugin*/, std::string_view /*series*/, std::vector /*players*/, + int /*max players*/) { + return unavailable; + } + virtual void cancel_throwdown(std::string_view /*plugin*/) {} + virtual std::string throwdown_status(std::string_view /*plugin*/) { return {}; } // ---- the rest ---------------------------------------------------------------------------------- // The music an outside player (Spotify and the like) is playing, and "play", "pause", // "next" or "previous" for it. virtual Track track() { return {}; } virtual std::string music(std::string_view) { return unavailable; } + // The game's own soundtrack on the player's headphones (none while a Spotify station is on: + // then the outside player's song is the one heard), and "next" for it. + virtual std::optional game_track() { return std::nullopt; } + virtual std::string game_music(std::string_view) { return unavailable; } virtual Progress progress() { return {}; } virtual std::optional setting(std::string_view) { return std::nullopt; } virtual std::vector setting_names() { return {}; } + // ---- camera ------------------------------------------------------------------------------------ + // The view from a pose (rows right, up, back and position, as follow_camera.h builds them) with + // a vertical field of view in degrees (0 the camera's own). One plugin holds it at a time, and + // Spectate takes it back while it runs; the game's own camera returns on release. + virtual std::optional camera_view() { return std::nullopt; } + virtual std::string set_camera(std::string_view /*plugin*/, const std::array&, float) { return unavailable; } + // Spectate's camera on a session player (0 stops the one this plugin started). + virtual std::string follow_player(std::string_view /*plugin*/, std::uint64_t) { return unavailable; } + virtual void release_camera(std::string_view /*plugin*/) {} + // "" while the plugin's view is the one shown, otherwise why not. + virtual std::string camera_status(std::string_view /*plugin*/) { return unavailable; } + // Holds the skater where it is (noclip, with no input), while any plugin asks. + virtual std::string freeze(std::string_view /*plugin*/, bool) { return unavailable; } + // ---- the session's authority (plugins' server copies) -------------------------------------------- + // Drops a player from the session; a chat line to everyone; one to one player (a dedicated + // server's only: a lobby has no private chat); moves a player's skater (their game does the move). + virtual std::string kick(std::uint64_t, std::string_view /*why*/) { return unavailable; } + virtual std::string announce(std::string_view /*plugin*/, std::string_view) { return unavailable; } + virtual std::string tell(std::string_view /*plugin*/, std::uint64_t, std::string_view) { return unavailable; } + virtual std::string move_player(std::uint64_t, const Vec3&) { return unavailable; } + // ---- skater.animation: clips on the local skater's body (Extension/Skater/skater_animation.h) ------ + // One plugin plays at a time; none while a throwdown runs. A replacement plays when the game does + // `trick` ("kickflip") with that pop ("" any, "low", "mid", "high"); a null clip takes it away. + using ClipRef = std::shared_ptr; + virtual std::string play_clip(std::string_view /*plugin*/, ClipRef, float /*speed*/, float /*blend*/, bool /*loop*/) { return unavailable; } + virtual void stop_clip(std::string_view /*plugin*/) {} + virtual bool clip_playing(std::string_view /*plugin*/) { return false; } + virtual std::string replace_trick(std::string_view /*plugin*/, std::string /*trick*/, std::string /*pop*/, ClipRef, float /*speed*/) { + return unavailable; + } + // Records the skater's pose for so many seconds; the clip comes from take_recording once done. + virtual std::string record_clip(std::string_view /*plugin*/, float /*seconds*/) { return unavailable; } + virtual ClipRef take_recording(std::string_view /*plugin*/) { return nullptr; } + // The game's name for the trick the skater is doing now ("kickflip"), or empty. + virtual std::string current_trick() { return {}; } + // ---- outfit: items worn over the local skater's outfit (Extension/Skater/outfit_overlay.h) ---------- + struct OutfitSlot { + std::string name, item; + std::vector parameters; + }; + virtual std::string outfit_set(std::string_view /*plugin*/, std::string_view /*slot*/, std::string_view /*item*/, std::vector, bool) { + return unavailable; + } + virtual void outfit_clear(std::string_view /*plugin*/, std::string_view /*slot, empty: all*/) {} + virtual std::string outfit_find(std::string_view) { return {}; } + virtual std::vector outfit_slots() { return {}; } + virtual std::string outfit_status() { return unavailable; } + // ---- board: what is drawn of the local skateboard (Extension/Skater/board_pose.h) ------------------ + struct BoardBone { + Vec3 position{}; + std::array rotation{0, 0, 0, 1}; + Vec3 scale{1, 1, 1}; + }; + virtual std::optional> board_bones() { return std::nullopt; } + virtual std::string board_set(std::string_view /*plugin*/, int, const BoardBone&, bool /*world*/) { return unavailable; } + virtual std::string board_hide(std::string_view /*plugin*/, int) { return unavailable; } + virtual void board_clear(std::string_view /*plugin*/, int /*bone, -1: all*/) {} + virtual void board_park(std::string_view /*plugin*/, std::vector) {} + virtual std::string board_toss(std::string_view /*plugin*/, std::vector, const Vec3&, const Vec3&, float) { return unavailable; } + // ---- the skater's physics ----------------------------------------------------------------------- + virtual SkaterNews take_skater_news() { return {}; } + virtual std::optional board_velocity() { return std::nullopt; } + virtual std::optional last_slam() { return std::nullopt; } + // The skater bails as from a slam of `force` (0.5 to 3); not in a throwdown. While No Bail protects + // the skater the wipeout is dropped, as any other is. + virtual std::string wipeout(float /*force*/) { return unavailable; } + // ---- audio: plugins' own sounds, in a level --------------------------------------------------------- + // Its id, or 0 when the plugin plays 8 already (or there is no level). + virtual std::uint64_t play_sound(std::string_view /*plugin*/, SoundStart) { return 0; } + virtual bool change_sound(std::string_view /*plugin*/, std::uint64_t, const SoundChange&) { return false; } + virtual void stop_sound(std::string_view /*plugin*/, std::uint64_t) {} + virtual bool sound_playing(std::string_view /*plugin*/, std::uint64_t) { return false; } // ---- input ------------------------------------------------------------------------------------- + // The flight keys and mouse only while the plugin's camera is shown; pads while the game has the + // focus. Capture keeps the game from the controller too, for 250 ms a call (renewed each tick), + // and only while the plugin's camera is shown. + virtual std::optional flight_input(std::string_view /*plugin*/) { return std::nullopt; } + virtual std::optional gamepad() { return std::nullopt; } + virtual std::optional joystick() { return std::nullopt; } + virtual std::string capture_input(std::string_view /*plugin*/, bool) { return unavailable; } // A named action on a key or pad combo (controller_bindings.h's encoding); presses come back // from take_presses as (plugin, action), only while the game itself has the input. virtual std::string bind(std::string_view /*plugin*/, std::string_view, std::uint32_t) { return unavailable; } diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 43337339..5cf88f07 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -1,19 +1,33 @@ #include "plugin_game_runtime.h" #include "plugin_session_feed.h" #include "Runtime/runtime_internal.h" +#include "plugin_ui.h" +#include "Engine/Game/World/park_rotation.h" +#include "Engine/Core/Json/json.h" #include "Engine/Game/Input/controller_bindings.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "Engine/Game/World/world_layer_catalog.h" #include "Engine/Game/World/world_layer_types.h" #include "Extension/Multiplayer/Hud/game_ui_state.h" +#include "Extension/Multiplayer/Hud/native_party.h" +#include "Extension/Skater/client_source_spawn.h" +#include "Extension/Skater/skater_animation.h" +#include "Extension/Plugins/plugin_joystick.h" +#include "plugin_sound.h" +#include "Extension/Skater/skater_events.h" +#include "Extension/Skater/outfit_overlay.h" +#include "Extension/Skater/board_pose.h" +#include "plugin_camera.h" #include "Extension/Multiplayer/Session/session.h" #include "Extension/Music/external_player.h" +#include "Extension/Music/spotify_station.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Settings/named_settings.h" #include "Extension/Skater/ai_skaters.h" #include "Extension/Skater/no_bail.h" #include "Extension/Throwdowns/native_throwdowns.h" +#include "Extension/Throwdowns/throwdown_relay.h" #include "Extension/Throwdowns/throwdown_debug_text.h" #include "Extension/Trainer/trainer.h" #include "Extension/Travel/local_location_travel.h" @@ -22,6 +36,8 @@ #include #include #include +#include +#include #include #include @@ -112,6 +128,9 @@ public: return set_local_world_control(key, number) ? std::string{} : "the game refused it (no map loaded, or not that control)"; } if (kind == "park") { + const auto lot = std::find_if(park_lots.begin(), park_lots.end(), [&](const auto& l) { return l.key == key; }); + if (lot == park_lots.end() || !valid_park(static_cast(lot - park_lots.begin()), value)) + return "that is not a lot and one of its layouts"; if (set_local_park(key, value)) return {}; return "the host chooses the parks, or that is not a lot and layout"; } @@ -180,7 +199,7 @@ public: const auto chat = multiplayer::chat(); if (chat.latest <= sequence) return result; for (const auto& line : chat.lines) - if (line.sequence > sequence) result.push_back({line.sequence, line.sender, line.name, line.text, line.local, line.server}); + if (line.sequence > sequence && line.tag != "Plugin") result.push_back({line.sequence, line.sender, line.name, line.text, line.local, line.server}); return result; } std::string send_chat(std::string_view text) override { @@ -188,6 +207,9 @@ public: const auto answer = multiplayer::command("chat", text); return answer == "Message sent." ? std::string{} : answer; } + std::string local_chat(std::string_view plugin, std::string_view text, std::uint32_t colour) override { + return multiplayer::plugin_chat(plugin, text, colour); + } ThrowdownState throwdowns() override { ThrowdownState state{multiplayer::local_throwdown_active(), multiplayer::local_throwdown_host(), {}}; for (const auto& p : multiplayer::skate_hud().players) state.skate.push_back({p.name, p.letters, p.up, p.out}); @@ -216,6 +238,15 @@ public: else return "there is no such command"; return {}; } + std::optional game_track() override { + const auto song = spotify_station::game_song(); + if (!song) return std::nullopt; + return Track{true, true, "skate.", song->title, song->artist, {}, 0}; + } + std::string game_music(std::string_view command) override { + if (command != "next") return "there is no such command"; + return spotify_station::game_next() ? std::string{} : "the game's music cannot skip now (a Spotify station is on, or no song has started)"; + } Progress progress() override { const auto model = local_profile_progression(); Progress p; @@ -235,26 +266,392 @@ public: // ---- input ------------------------------------------------------------------------------------------- std::string bind(std::string_view plugin, std::string_view action, std::uint32_t combo) override { if (!valid_action_binding(combo)) return "that is not a key or pad combo"; - binds_[{std::string(plugin), std::string(action)}].combo = combo; + binds_[{std::string(plugin), std::string(action)}].own = combo; + publish_binds(); return {}; } // Each bind's press, once, while the game itself has the input (no menu, console or text box). + // The player's own combo from the Binds page wins over the plugin's. std::vector> take_presses() override { + apply_rebinds(); std::vector> presses; if (binds_.empty()) return presses; ControllerInput input; DingoSDKOverlayReadControllerInput(&input, false); for (auto& [key, bind] : binds_) - if (bind.latch.update(bind.combo, input, false)) presses.push_back(key); + if (bind.latch.update(combo_in_use(key, bind), input, false)) presses.push_back(key); return presses; } + std::string host_throwdown(std::string_view plugin, std::string_view series, std::vector players, int most) override { + if (!multiplayer_session_active()) return "not in a session"; + if (!thrower_.empty() && thrower_ != plugin) { + const auto status = multiplayer::throwdown_relay_host_status(); + if (status == "asked" || status == "spawning" || status == "open") return thrower_ + " is hosting a drop"; + } + auto why = multiplayer::throwdown_relay_host(std::string(series), std::move(players), static_cast(std::max(most, 0))); + if (why.empty()) thrower_ = plugin; + return why; + } + void cancel_throwdown(std::string_view plugin) override { + if (thrower_ != plugin) return; + multiplayer::throwdown_relay_cancel_host(); // its status says "cancelled" from the relay's next tick + } + std::string throwdown_status(std::string_view plugin) override { + return thrower_ == plugin ? multiplayer::throwdown_relay_host_status() : std::string{}; + } + // ---- board ----------------------------------------------------------------------------------------- + std::optional> board_bones() override { + const auto drawn = board_pose::read(); + if (!drawn) return std::nullopt; + std::array out{}; + for (std::size_t i = 0; i < out.size(); ++i) out[i] = {(*drawn)[i].position, (*drawn)[i].rotation, (*drawn)[i].scale}; + return out; + } + std::string board_set(std::string_view plugin, int bone, const BoardBone& value, bool world) override { + return board_pose::set(plugin, bone, {value.position, value.rotation, value.scale}, world ? board_pose::Space::world : board_pose::Space::local); + } + std::string board_hide(std::string_view plugin, int bone) override { return board_pose::hide(plugin, bone); } + void board_clear(std::string_view plugin, int bone) override { board_pose::clear(plugin, bone); } + void board_park(std::string_view plugin, std::vector bones) override { board_pose::park(plugin, std::move(bones)); } + std::string board_toss(std::string_view plugin, std::vector bones, const Vec3& velocity, const Vec3& spin, float ground) override { + return board_pose::toss(plugin, std::move(bones), velocity, spin, ground); + } + // ---- outfit -------------------------------------------------------------------------------------- + std::string outfit_set(std::string_view plugin, std::string_view slot, std::string_view item, std::vector placement, bool covers) override { + return outfit_overlay::set(std::string(plugin), std::string(slot), std::string(item), std::move(placement), covers); + } + void outfit_clear(std::string_view plugin, std::string_view slot) override { outfit_overlay::clear(plugin, slot); } + std::string outfit_find(std::string_view ending) override { return outfit_overlay::find_item(ending); } + std::vector outfit_slots() override { + std::vector out; + for (auto& s : outfit_overlay::slots()) out.push_back({std::move(s.name), std::move(s.item), std::move(s.parameters)}); + return out; + } + std::string outfit_status() override { return outfit_overlay::status(); } + // ---- the skater's physics ------------------------------------------------------------------------ + SkaterNews take_skater_news() override { + skater_events::want(); + SkaterNews news; + for (const auto& l : skater_events::take_landings()) news.landings.push_back({l.down_speed, l.speed, l.rail}); + for (const auto& h : skater_events::take_hits()) + news.hits.push_back({skater_body::names[std::min(h.bone, skater_body::count - 1)], h.impact, h.vehicle, h.board}); + for (const auto& f : skater_events::take_falls()) { + SkaterNews::Fall fall{f.seconds, f.left, f.right, {}}; + for (std::size_t i = 1; i < skater_body::count; ++i) + if (f.impact[i] > 0 || f.slide[i] > 0) fall.bones.push_back({skater_body::names[i], f.impact[i], f.slide[i]}); + news.falls.push_back(std::move(fall)); + } + return news; + } + std::optional board_velocity() override { + skater_events::want(); + return skater_events::board_velocity(); + } + std::optional last_slam() override { + const auto slam = skater_events::last_slam(); + if (!slam.at) return std::nullopt; + return SlamNews{slam.force, slam.cause, static_cast(GetTickCount64() - slam.at)}; + } + std::string wipeout(float force) override { + if (multiplayer::local_throwdown_active()) return "not while a throwdown runs"; + no_bail_request_wipeout(std::clamp(force, 0.5f, 3.0f)); + return {}; + } + // ---- audio --------------------------------------------------------------------------------------- + std::uint64_t play_sound(std::string_view plugin, SoundStart sound) override { + plugin_sound::Voice voice; + voice.owner = plugin; + voice.wave = std::move(sound.wave); + voice.volume = sound.volume; + voice.pitch = sound.pitch; + voice.range = sound.range; + voice.throttle = sound.throttle; + voice.speed = sound.speed; + voice.loop = sound.loop; + voice.position = sound.position; + return plugin_sound::start(std::move(voice)); + } + bool change_sound(std::string_view plugin, std::uint64_t id, const SoundChange& change) override { + auto* voice = plugin_sound::find(id, plugin); + if (!voice) return false; + if (change.volume) voice->volume = *change.volume; + if (change.pitch) voice->pitch = *change.pitch; + if (change.range) voice->range = *change.range; + if (change.throttle) voice->throttle = *change.throttle; + if (change.speed) voice->speed = *change.speed; + if (change.position) voice->position = *change.position; + return true; + } + void stop_sound(std::string_view plugin, std::uint64_t id) override { plugin_sound::stop(id, plugin); } + bool sound_playing(std::string_view plugin, std::uint64_t id) override { return plugin_sound::find(id, plugin) != nullptr; } + // ---- input ------------------------------------------------------------------------------------- + bool camera_mine(std::string_view plugin) const { return !camera_holder_.empty() && camera_holder_ == plugin && camera_shown_; } + std::optional flight_input(std::string_view plugin) override { + if (!camera_mine(plugin)) return std::nullopt; + // Read once a tick: the mouse's movement is used up as it is read. + const auto now = GetTickCount64(); + if (now - flight_read_at_ > 4) { + flight_read_at_ = now; + overlay::FlightInput in; + DingoSDKOverlayReadFlightInput(&in, true, false); + flight_ = in.active ? std::optional(FlightInput{in.right, in.up, in.forward, in.look_x, in.look_y, in.boost}) + : std::nullopt; + } + return flight_; + } + std::optional gamepad() override { + if (!joystick::game_in_foreground()) return std::nullopt; + overlay::Gamepad pad; + { + joystick::InputAccess access; // seen even while captured from the game + pad = overlay::read_gamepad(); + } + if (!pad.valid) return std::nullopt; + return PadInput{pad.left_x, pad.left_y, pad.right_x, pad.right_y, pad.left_trigger, pad.right_trigger, pad.buttons, pad.playstation}; + } + std::optional joystick() override { + if (!joystick::game_in_foreground()) return std::nullopt; + const auto sample = joystick::poll(); + if (!sample.valid) return std::nullopt; + return StickInput{sample.axes, sample.buttons, sample.name}; + } + std::string capture_input(std::string_view plugin, bool on) override { + if (!on) { + if (capturer_ == plugin) capturer_.clear(); + return {}; + } + if (!camera_mine(plugin)) return "only while the plugin's camera is shown"; + capturer_ = plugin; + capture_until_ = GetTickCount64() + 250; + return {}; + } + bool capturing() const noexcept { + return !capturer_.empty() && camera_mine(capturer_) && GetTickCount64() < capture_until_; + } + // ---- skater.animation --------------------------------------------------------------------------- + std::string play_clip(std::string_view plugin, ClipRef clip, float speed, float blend, bool loop) override { + if (multiplayer::local_throwdown_active()) return "not while a throwdown runs"; + if (!player_.empty() && player_ != plugin && skater_animation::playing()) return player_ + " is playing a clip"; + player_ = plugin; + skater_animation::play(skater_animation::Play{std::move(clip), speed, blend, loop}); + return {}; + } + void stop_clip(std::string_view plugin) override { + if (player_ != plugin) return; + skater_animation::play(std::nullopt); + player_.clear(); + } + bool clip_playing(std::string_view plugin) override { return player_ == plugin && skater_animation::playing(); } + std::string replace_trick(std::string_view plugin, std::string trick, std::string pop, ClipRef clip, float speed) override { + const auto key = std::make_pair(trick, pop); + if (const auto found = replacements_.find(key); found != replacements_.end() && found->second.first != plugin) + return found->second.first + " replaces that trick already"; + if (!clip) replacements_.erase(key); + else { + const auto mine = std::count_if(replacements_.begin(), replacements_.end(), [&](const auto& r) { return r.second.first == plugin; }); + if (!replacements_.contains(key) && mine >= 32) return "a plugin replaces at most 32 tricks"; + replacements_[key] = {std::string(plugin), skater_animation::Replacement{std::move(trick), std::move(pop), std::move(clip), speed}}; + } + publish_replacements(); + return {}; + } + std::string record_clip(std::string_view plugin, float seconds) override { + if (!recorder_.empty() && recorder_ != plugin && skater_animation::recording()) return recorder_ + " is recording"; + if (!skater_animation::record(seconds)) return "a recording is going on"; + recorder_ = plugin; + return {}; + } + ClipRef take_recording(std::string_view plugin) override { + if (recorder_ != plugin || skater_animation::recording()) return nullptr; + auto clip = skater_animation::take_recording(); + if (clip) recorder_.clear(); + return clip; + } + std::string current_trick() override { return skater_animation::current_trick(); } + void tick_animation(std::uintptr_t base, std::uintptr_t client) { + outfit_overlay::tick(); + board_pose::tick(base, local_skater_component(base, client)); + const bool allowed = !multiplayer::local_throwdown_active(); + skater_animation::tick(base, local_skater_component(base, client), allowed); + } + // ---- the lobby host's authority ------------------------------------------------------------------ + std::string kick(std::uint64_t player, std::string_view why) override { return multiplayer::host_kick(player, why); } + // The host's own chat, under the plugin's name: a lobby has no server to speak for it. + std::string announce(std::string_view plugin, std::string_view text) override { + if (!multiplayer_session_active()) return "not in a session"; + const auto answer = multiplayer::command("chat", "[" + std::string(plugin) + "] " + std::string(text)); + return answer == "Message sent." ? std::string{} : answer; + } + std::string move_player(std::uint64_t player, const Vec3& at) override { return multiplayer::host_teleport(player, at); } + std::string tell(std::string_view, std::uint64_t, std::string_view) override { + return "a lobby has no private chat: send that player's copy a message (reskate.net.send) and show it there"; + } + // ---- camera ------------------------------------------------------------------------------------ + std::optional camera_view() override { + const auto view = plugin_camera::camera_now(); + if (!view || !(view->vertical_fov > 1 && view->vertical_fov < 175)) return std::nullopt; + const auto& m = view->world; + return CameraView{{m[12], m[13], m[14]}, {-m[8], -m[9], -m[10]}, view->vertical_fov}; + } + std::string set_camera(std::string_view plugin, const std::array& pose, float fov) override { + if (!camera_holder_.empty() && camera_holder_ != plugin) return camera_holder_ + " has the camera"; + if (!follower_.empty() && follower_ != plugin) return follower_ + " is following a player with it"; + camera_holder_ = plugin; + camera_pose_ = pose; + camera_fov_ = fov; + camera_releasing_ = false; + return {}; + } + std::string follow_player(std::string_view plugin, std::uint64_t player) override { + if (!camera_holder_.empty() && camera_holder_ != plugin) return camera_holder_ + " has the camera"; + if (!player) { + if (follower_ == plugin) { + multiplayer::spectate_party_member(0); + follower_.clear(); + } + return {}; + } + if (!multiplayer_session_active()) return "not in a session"; + if (camera_holder_ == plugin) release_camera(plugin); + follower_ = plugin; + multiplayer::spectate_party_member(player); + return {}; + } + void release_camera(std::string_view plugin) override { + if (follower_ == plugin) follow_player(plugin, 0); + if (camera_holder_ != plugin) return; + camera_holder_.clear(); + camera_releasing_ = true; + camera_status_.clear(); + } + std::string camera_status(std::string_view plugin) override { + if (follower_ == plugin) return multiplayer::party_spectating() ? std::string{} : "Spectate is starting"; + if (camera_holder_ != plugin) return "it does not hold the camera"; + return camera_shown_ ? std::string{} : camera_status_.empty() ? "waiting for the game's camera" : camera_status_; + } + void apply_camera(std::uintptr_t base, std::uintptr_t client, bool ready, bool phase) { + if (multiplayer::party_spectating()) { + // Spectate has the lease now, and gives the game's camera back itself. + camera_shown_ = camera_releasing_ = false; + if (!camera_holder_.empty()) camera_status_ = "Spectate has the camera"; + return; + } + std::string detail; + if (!camera_holder_.empty()) { + camera_shown_ = update_party_camera(base, client, ready, phase, &camera_pose_, detail, camera_fov_); + if (!camera_shown_ && !detail.empty()) camera_status_ = detail; + } else if (camera_releasing_ && update_party_camera(base, client, ready, phase, nullptr, detail)) { + camera_releasing_ = false; + } + } + std::string freeze(std::string_view plugin, bool on) override { + const bool before = !freezers_.empty(); + if (on) { + if (!before && !session_noclip_allowed()) return "the host has turned noclip off"; + freezers_.insert(std::string(plugin)); + } else freezers_.erase(std::string(plugin)); + const bool now = !freezers_.empty(); + if (now == before) return {}; + const auto why = queue(overlay::DebugAction::set_noclip, now); + if (!why.empty() && now) freezers_.erase(std::string(plugin)); + return why; + } + bool frozen() const noexcept { return !freezers_.empty(); } void release(std::string_view plugin) override { + stop_clip(plugin); + plugin_sound::stop_all(plugin); + outfit_overlay::clear(plugin); + board_pose::clear(plugin); + board_pose::park(plugin, {}); + cancel_throwdown(plugin); + if (capturer_ == plugin) capturer_.clear(); + if (std::erase_if(replacements_, [&](const auto& r) { return r.second.first == plugin; })) publish_replacements(); + if (recorder_ == plugin) recorder_.clear(); + release_camera(plugin); + (void)freeze(plugin, false); hide_hud(plugin, false); plugin_feed::release_tags(std::string(plugin)); - std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; }); + if (std::erase_if(binds_, [&](const auto& item) { return item.first.first == plugin; })) publish_binds(); + } + void set_storage(const std::filesystem::path& storage) { + if (!binds_file_.empty() || storage.empty()) return; + binds_file_ = storage / "binds.json"; + load_overrides(); } private: + void publish_replacements() { + std::vector list; + for (const auto& [key, value] : replacements_) list.push_back(value.second); + skater_animation::set_replacements(std::move(list)); + } + std::string player_, recorder_, thrower_, capturer_; + std::uint64_t capture_until_{}, flight_read_at_{}; + std::optional flight_; + // By (trick, pop): the plugin and its clip. + std::map, std::pair> replacements_; + using BindKey = std::pair; + struct Bind { + std::uint32_t own{}; + ControllerComboLatch latch; + }; + std::uint32_t combo_in_use(const BindKey& key, const Bind& bind) const { + const auto found = overrides_.find(key); + return found != overrides_.end() ? found->second : bind.own; + } + void publish_binds() { + std::vector list; + for (const auto& [key, bind] : binds_) list.push_back({key.first, key.second, bind.own, combo_in_use(key, bind)}); + plugin_ui::publish_binds(std::move(list)); + } + void apply_rebinds() { + auto changes = plugin_ui::take_rebinds(); + if (changes.empty()) return; + for (auto& change : changes) { + BindKey key{std::move(change.plugin), std::move(change.action)}; + if (!change.combo) overrides_.erase(key); + else if (!*change.combo || valid_action_binding(*change.combo)) overrides_[std::move(key)] = *change.combo; + } + save_overrides(); + publish_binds(); + } + // binds.json: {"": {"": combo}}, the player's choices only. + void load_overrides() { + try { + std::ifstream in(binds_file_, std::ios::binary); + if (!in) return; + const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + const auto json = Json::parse(text, {256 * 1024, 4, 4096}); + if (!json.is_object()) return; + for (const auto& [plugin, actions] : json.items()) { + if (!actions.is_object()) continue; + for (const auto& [action, combo] : actions.items()) + if (combo.is_number_unsigned() && combo.get() <= 0xffffffffu) + overrides_[{plugin, action}] = static_cast(combo.get()); + } + } catch (const std::exception&) { + overrides_.clear(); // a broken file is the plugins' own binds again + } + } + void save_overrides() { + if (binds_file_.empty()) return; + auto json = Json::object(); + for (const auto& [key, combo] : overrides_) { + if (!json.contains(key.first)) json[key.first] = Json::object(); + json[key.first][key.second] = combo; + } + try { + std::filesystem::create_directories(binds_file_.parent_path()); + auto temporary = std::filesystem::path(binds_file_) += ".tmp"; + { + std::ofstream out(temporary, std::ios::binary | std::ios::trunc); + const auto text = json.dump(2); + if (!out || !out.write(text.data(), static_cast(text.size()))) return; + } + std::filesystem::rename(temporary, binds_file_); + } catch (const std::exception&) { + } + } static std::string reply(const std::string& answer) { return answer.starts_with("error") || answer.find("not") != std::string::npos ? answer : std::string{}; } @@ -286,19 +683,40 @@ private: } std::uintptr_t base_; + std::string camera_holder_, follower_, camera_status_; + std::array camera_pose_{}; + float camera_fov_{}; + bool camera_shown_{}, camera_releasing_{}; + std::set> freezers_; std::set> hud_hiders_; bool hud_was_hidden_{}; bool music_started_{}; - struct Bind { - std::uint32_t combo{}; - ControllerComboLatch latch; - }; - std::map, Bind> binds_; + std::map binds_; + std::map overrides_; + std::filesystem::path binds_file_; }; +RealGame* the_game{}; } -Game& real_game(std::uintptr_t image_base) { +Game& real_game(std::uintptr_t image_base, const std::filesystem::path& storage) { static auto* game = new RealGame(image_base); // the process's lifetime, as the host's + the_game = game; + game->set_storage(storage); return *game; } +void apply_plugin_camera(std::uintptr_t image_base, std::uintptr_t client, bool ready, bool phase) noexcept { + try { + if (the_game) the_game->apply_camera(image_base, client, ready, phase); + } catch (...) {} +} +bool plugin_freeze_active() noexcept { return the_game && the_game->frozen(); } +bool plugin_input_capture() noexcept { return the_game && the_game->capturing(); } +void tick_plugin_sounds(std::uintptr_t image_base, std::uintptr_t client, bool ready) noexcept { + plugin_sound::tick(image_base, client, ready); +} +void tick_plugin_animation(std::uintptr_t image_base, std::uintptr_t client) noexcept { + try { + if (the_game) the_game->tick_animation(image_base, client); + } catch (...) {} +} } diff --git a/Extension/Plugins/plugin_game_runtime.h b/Extension/Plugins/plugin_game_runtime.h index b6a76333..fb56a7ce 100644 --- a/Extension/Plugins/plugin_game_runtime.h +++ b/Extension/Plugins/plugin_game_runtime.h @@ -1,9 +1,21 @@ #pragma once #include "plugin_game.h" #include +#include // The wider set's game in the real game (plugin_game.h): each part through the function ReSkate's -// own console or menu uses, under the same rules. Client thread. +// own console or menu uses, under the same rules. Client thread. The binds players change on the +// Binds page are kept in `storage`/binds.json. namespace dingosdk::plugins { -Game& real_game(std::uintptr_t image_base); +Game& real_game(std::uintptr_t image_base, const std::filesystem::path& storage); +// The client tick's camera phase, after Spectate's: shows the plugin camera, or gives it back. +void apply_plugin_camera(std::uintptr_t image_base, std::uintptr_t client, bool ready, bool phase) noexcept; +// Whether a plugin holds the skater still: noclip's input is then none. +bool plugin_freeze_active() noexcept; +// Whether a plugin's flight holds the controller (reskate.input.capture): the game gets none of it. +bool plugin_input_capture() noexcept; +// Each client tick: the local skater for plugins' clips (reskate.animation). +void tick_plugin_animation(std::uintptr_t image_base, std::uintptr_t client) noexcept; +// Each client tick: plugins' sounds (reskate.audio); `ready` while a level plays. +void tick_plugin_sounds(std::uintptr_t image_base, std::uintptr_t client, bool ready) noexcept; } diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index ba425281..dd670ce6 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -14,11 +14,14 @@ std::string lowercase(std::string text) { } } -Host::Host(Environment& environment, Side side, Limits limits) : environment_(environment), side_(side), limits_(limits) {} +Host::Host(Environment& environment, Side side, Limits limits, std::string suffix) + : environment_(environment), side_(side), limits_(limits), suffix_(std::move(suffix)) {} Host::~Host() { shutdown(); } Host::Entry* Host::find(std::string_view id) { + // An owner's id, as the game was given it, names its entry too. + if (!suffix_.empty() && id.ends_with(suffix_)) id.remove_suffix(suffix_.size()); const auto found = entries_.find(id); return found == entries_.end() ? nullptr : &found->second; } @@ -52,7 +55,7 @@ void Host::load(Entry& entry) { std::string permissions; for (const auto& permission : manifest.permissions) permissions += (permissions.empty() ? "" : ", ") + permission; Services& services = *this; - entry.plugin = std::make_unique(id, entry.candidate.directory, std::move(manifest), services, limits_); + entry.plugin = std::make_unique(id + suffix_, entry.candidate.directory, std::move(manifest), services, limits_); // Its messages go under the name its manifest.json gives it, the same on every player's game // whatever its folder is called; the folder's name when it has none. std::string channel = id; @@ -80,8 +83,8 @@ void Host::unload(Entry& entry) { entry.plugin->unload(); entry.plugin.reset(); } - release_commands(entry.candidate.id); - environment_.game().release(entry.candidate.id); + release_commands(entry.candidate.id + suffix_); + environment_.game().release(entry.candidate.id + suffix_); } void Host::collect(Entry& entry) { @@ -89,8 +92,8 @@ void Host::collect(Entry& entry) { entry.failure = entry.plugin->reason(); entry.plugin->close_if_stopped(); entry.plugin.reset(); - release_commands(entry.candidate.id); - environment_.game().release(entry.candidate.id); + release_commands(entry.candidate.id + suffix_); + environment_.game().release(entry.candidate.id + suffix_); } void Host::release_commands(std::string_view id) { @@ -167,12 +170,22 @@ void Host::deliver_messages() { std::uint64_t me = 0; for (const auto& player : session_.players) if (player.me) me = player.id; + const bool server = side_ == Side::server; for (const auto& received : environment_.take_messages()) { const auto message = wire::decode(received.bytes); - if (!message || (message->to && message->to != me)) continue; + // A server copy hears what is for everyone or for the authority; a client copy, what is + // for everyone or for it, and the authority's words only from the authority (the session + // checks who sent those). + if (!message || (server ? message->to > wire::authority && message->to != me : message->to && message->to != me) || + (server && message->authority)) + continue; SessionPlayer from{received.from}; for (const auto& player : session_.players) if (player.id == received.from) from = player; + if (message->authority) { + from.server = true; + if (from.name.empty()) from.name = "Server"; + } for (auto& [id, entry] : entries_) { if (!entry.plugin || entry.plugin->channel() != message->channel) continue; entry.plugin->receive(from, message->payload); @@ -194,7 +207,7 @@ std::string Host::call_plugin(std::string_view caller, std::string_view target, Json& result) { auto* entry = running(target); if (!entry) return std::format("there is no running plugin {}", target); - if (entry->candidate.id == caller) return "a plugin calls its own functions directly"; + if (entry->candidate.id + suffix_ == caller) return "a plugin calls its own functions directly"; auto error = entry->plugin->call_exposed(std::string(function), arguments, result); // (It is closed if that stopped it; the plugin that called carries on.) if (entry->plugin->state() == State::stopped) collect(*entry); @@ -236,9 +249,43 @@ void Host::follow_game() { } skater_state_ = now; const auto wipeouts = game.wipeouts(); - if (wipeouts_known_ && wipeouts > wipeouts_) each([&](Plugin& plugin, const std::string&) { plugin.bailed(wipeouts); }); + if (wipeouts_known_ && wipeouts > wipeouts_) { + const auto slam = game.last_slam(); + each([&](Plugin& plugin, const std::string&) { plugin.bailed(wipeouts, slam); }); + } wipeouts_ = wipeouts; wipeouts_known_ = true; + auto news = game.take_skater_news(); + for (const auto& l : news.landings) { + auto data = Json::object(); + data["down_speed"] = l.down_speed; + data["speed"] = l.speed; + data["onto"] = l.rail ? "rail" : "ground"; + each([&](Plugin& plugin, const std::string&) { plugin.game_event("board_landed", "events.skater", data); }); + } + for (const auto& h : news.hits) { + auto data = Json::object(); + data["bone"] = h.bone; + data["impact"] = h.impact; + data["vehicle"] = h.vehicle; + data["board"] = h.board; + each([&](Plugin& plugin, const std::string&) { plugin.game_event("body_hit", "events.skater", data); }); + } + for (const auto& f : news.falls) { + auto data = Json::object(); + data["seconds"] = f.seconds; + data["left"] = f.left; + data["right"] = f.right; + auto bones = Json::object(); + for (const auto& b : f.bones) { + auto bone = Json::object(); + bone["impact"] = b.impact; + bone["slide"] = b.slide; + bones[b.name] = std::move(bone); + } + data["bones"] = std::move(bones); + each([&](Plugin& plugin, const std::string&) { plugin.game_event("bail_end", "events.skater", data); }); + } } if (wants("chat.read")) { auto lines = game.chat_since(chat_seen_); @@ -292,7 +339,7 @@ void Host::deliver_events() { std::string channel = emitted.source; if (const auto* source = find(emitted.source); source && source->plugin) channel = source->plugin->channel_name(); for (auto& [id, entry] : entries_) { - if (!entry.plugin || id == emitted.source) continue; + if (!entry.plugin || id + suffix_ == emitted.source) continue; entry.plugin->plugin_event(emitted.source, channel, emitted.event, emitted.data); collect(entry); } @@ -301,7 +348,8 @@ void Host::deliver_events() { bool Host::send(std::uint32_t channel, std::uint64_t to, std::string_view payload) { if (!session_.active) return false; - return environment_.send_message(wire::encode(channel, to, payload)); + if (side_ == Side::server && to == wire::authority) return false; // it is the authority + return environment_.send_message(wire::encode(channel, to, payload, side_ == Side::server)); } void Host::draw_and_build() { @@ -404,7 +452,7 @@ std::vector Host::status() const { status.commands = entry.plugin->commands(); } else if (entry.held) { status.state = "switched off"; - status.reason = "plugin on " + id + " starts it again"; + status.reason = "plugin on " + id + suffix_ + " starts it again"; } else { status.state = entry.failure.starts_with("it is a ") ? "not loaded" : "stopped"; status.reason = entry.failure; @@ -428,7 +476,7 @@ std::string Host::switch_off(std::string_view id) { if (!entry) return std::format("error: There is no plugin {}.", id); entry->held = true; unload(*entry); - return std::format("Plugin {} switched off until plugin on {}, or the next launch.", id, id); + return std::format("Plugin {} switched off until plugin on {}, or the next launch.", std::string(id) + suffix_, std::string(id) + suffix_); } std::string Host::switch_on(std::string_view id) { diff --git a/Extension/Plugins/plugin_host.h b/Extension/Plugins/plugin_host.h index c7c43450..baac178b 100644 --- a/Extension/Plugins/plugin_host.h +++ b/Extension/Plugins/plugin_host.h @@ -77,7 +77,9 @@ struct Status { class Host final : private Services { public: // A client host runs client plugins and those for both sides; a server host, server ones and both. - Host(Environment& environment, Side side, Limits limits = {}); + // `suffix` follows each plugin's id where it is the game's owner of something, in its logs and + // its storage: a lobby host's server copies ("@server") never share what the client copies hold. + Host(Environment& environment, Side side, Limits limits = {}, std::string suffix = {}); ~Host() override; Host(const Host&) = delete; Host& operator=(const Host&) = delete; @@ -137,6 +139,7 @@ private: bool emit(std::string_view source, std::string_view event, Json data) override; bool http(HttpRequest request) override { return environment_.http_request(std::move(request)); } Game& game() override { return environment_.game(); } + bool authority() override { return side_ == Side::server; } // What the game did since the last tick, to the plugins with the permission for it. void follow_game(); void deliver_http(); @@ -152,6 +155,7 @@ private: Environment& environment_; Side side_; Limits limits_; + std::string suffix_; std::map> entries_; std::map> commands_; // command name: plugin id std::string level_; diff --git a/Extension/Plugins/plugin_joystick.h b/Extension/Plugins/plugin_joystick.h new file mode 100644 index 00000000..f6d43f41 --- /dev/null +++ b/Extension/Plugins/plugin_joystick.h @@ -0,0 +1,105 @@ +#pragma once +// Joysticks and radio controllers for plugins (reskate.input.joystick): anything that shows up in +// Windows' "Set up USB game controllers" (a radio in its USB Joystick mode, a flight stick). Axis +// numbers 1..6 are winmm's X, Y, Z, R, U, V, each -1..1. Windows only (winmm). +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winmm.lib") + +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::plugins::joystick { +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; +}; + +// While it lives, reads see the controllers even while a plugin keeps the game off them. +struct InputAccess { + InputAccess() noexcept { overlay::begin_input_access(); } + ~InputAccess() { overlay::end_input_access(); } + InputAccess(const InputAccess&) = delete; + InputAccess& operator=(const InputAccess&) = delete; +}; + +namespace detail { +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 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 first controller, preferring one named a radio. Any thread; rescans at most once a second. +inline Sample poll() { + InputAccess access; + static std::atomic cached{-1}; + static std::atomic next_scan{0}; + Sample 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 a controller does not drive it from the background. +inline bool game_in_foreground() { + DWORD pid{}; + GetWindowThreadProcessId(GetForegroundWindow(), &pid); + return pid == GetCurrentProcessId(); +} +} +#endif diff --git a/Extension/Plugins/plugin_manifest.cpp b/Extension/Plugins/plugin_manifest.cpp index fd48279e..e3c7edb8 100644 --- a/Extension/Plugins/plugin_manifest.cpp +++ b/Extension/Plugins/plugin_manifest.cpp @@ -2,16 +2,17 @@ #include "Engine/Core/Json/json.h" #include #include +#include #include namespace dingosdk::plugins { namespace { -constexpr std::array permissions{ +constexpr std::array permissions{ "console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera", "world.raycast", "world.objects", "world.environment", "world.ai", "travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read", - "chat.send", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "mods.read"}; + "chat.send", "chat.local", "server", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read"}; } bool known_permission(std::string_view name) noexcept { @@ -101,4 +102,40 @@ Manifest parse_manifest(std::string_view text) { } return result; } + +namespace { +constexpr std::array runnable{".exe", ".dll", ".sys", ".scr", ".com", ".cpl", ".msi", ".msp", + ".bat", ".cmd", ".ps1", ".psm1", ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".hta", ".lnk", ".pif", ".reg"}; +constexpr std::size_t most_files = 20000; + +// path::string() throws on names outside the ANSI code page; a name here is only shown or compared. +std::string ascii_lowercase(const std::filesystem::path& path) { + std::string text; + for (const auto c : path.generic_wstring()) + text += c < 0x80 ? static_cast(c >= L'A' && c <= L'Z' ? c + 32 : c) : '?'; + return text; +} +} + +std::string check_package_files(const std::filesystem::path& directory) { + std::error_code code; + std::size_t files = 0; + for (std::filesystem::recursive_directory_iterator it(directory, std::filesystem::directory_options::skip_permission_denied, code), end; + !code && it != end; it.increment(code)) { + std::error_code entry_code; + if (!it->is_regular_file(entry_code)) continue; + if (++files > most_files) return "it holds more than 20000 files"; + const auto shown = ascii_lowercase(std::filesystem::relative(it->path(), directory, entry_code)); + const auto extension = ascii_lowercase(it->path().extension()); + bool program = std::find(runnable.begin(), runnable.end(), extension) != runnable.end(); + if (!program) { + std::ifstream in(it->path(), std::ios::binary); + char magic[2]{}; + program = in.read(magic, 2) && magic[0] == 'M' && magic[1] == 'Z'; + } + if (program) return "it holds " + shown + ", a program or a script Windows runs. A plugin never ships one"; + } + return {}; +} + } diff --git a/Extension/Plugins/plugin_manifest.h b/Extension/Plugins/plugin_manifest.h index f567d3cd..0c025518 100644 --- a/Extension/Plugins/plugin_manifest.h +++ b/Extension/Plugins/plugin_manifest.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -31,5 +32,8 @@ Manifest parse_manifest(std::string_view text); // it could leave the package: absolute, a drive or a stream (':'), a backslash, an empty, "." or // ".." segment, or a character outside printable ASCII. std::string safe_relative_path(std::string_view path); +// Why a package may not load, from its files alone, or empty. A plugin never ships a program: no +// Windows executable (by its first bytes, whatever its name) and no script Windows runs. +std::string check_package_files(const std::filesystem::path& directory); const char* side_name(Side side) noexcept; } diff --git a/Extension/Plugins/plugin_runtime.cpp b/Extension/Plugins/plugin_runtime.cpp index 8b6e4cb6..73f951e1 100644 --- a/Extension/Plugins/plugin_runtime.cpp +++ b/Extension/Plugins/plugin_runtime.cpp @@ -4,6 +4,7 @@ #include "plugin_camera.h" #include "plugin_http.h" #include "plugin_game_runtime.h" +#include "plugin_wire.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Multiplayer/session_tools.h" #include "Extension/Multiplayer/Session/session.h" @@ -31,14 +32,20 @@ std::uintptr_t base{}; // What the discovery thread last found of the Mods folder (reskate.mods). std::mutex mods_mutex; std::vector found_mods; // under mods_mutex -// Plugin messages in and out of the session's throwdown packets: a few at a time. +// Plugin messages in and out of the session's throwdown packets: a few at a time. The server +// copies' (on a lobby's host) have their own. std::mutex messages_mutex; -std::vector inbox; // under messages_mutex -std::vector> outbox; // under messages_mutex +struct Mailbox { + std::vector inbox; + std::vector> outbox; +}; +Mailbox client_mail, server_mail; // under messages_mutex +// The game for the plugins: a player's copies (client), or the server copies a lobby's host runs, +// which draw nothing and keep their storage apart. class GameEnvironment final : public Environment { public: - explicit GameEnvironment(std::filesystem::path storage) : storage_(std::move(storage)) {} + GameEnvironment(std::filesystem::path storage, bool server) : storage_(std::move(storage)), server_(server) {} void log(LogLevel level, std::string_view text) override { logging::write(level == LogLevel::error ? logging::Level::error : level == LogLevel::warn ? logging::Level::warning : logging::Level::info, logging::Channel::plugins, text); @@ -112,13 +119,14 @@ public: } bool send_message(std::vector bytes) override { std::lock_guard lock(messages_mutex); + auto& outbox = mail().outbox; if (outbox.size() >= 64) return false; outbox.push_back(std::move(bytes)); return true; } std::vector take_messages() override { std::lock_guard lock(messages_mutex); - return std::exchange(inbox, {}); + return std::exchange(mail().inbox, {}); } std::optional world_to_screen(const Vec3& p) override { const auto display = plugin_ui::display(); @@ -126,12 +134,16 @@ public: return projector ? projector->project(p) : std::nullopt; } std::array display() override { return plugin_ui::display(); } - void publish_layers(std::vector layers) override { plugin_ui::publish_layers(std::move(layers)); } - bool panels_shown() override { return plugin_ui::panels_shown(); } - void publish_panels(std::vector panels) override { plugin_ui::publish_panels(std::move(panels)); } - std::vector take_actions() override { return plugin_ui::take_actions(); } + void publish_layers(std::vector layers) override { + if (!server_) plugin_ui::publish_layers(std::move(layers)); + } + bool panels_shown() override { return !server_ && plugin_ui::panels_shown(); } + void publish_panels(std::vector panels) override { + if (!server_) plugin_ui::publish_panels(std::move(panels)); + } + std::vector take_actions() override { return server_ ? std::vector{} : plugin_ui::take_actions(); } bool http_request(HttpRequest request) override { return http::start(std::move(request)); } - Game& game() override { return real_game(base); } + Game& game() override { return real_game(base, storage_); } std::vector take_http_responses() override { return http::take(); } private: @@ -139,7 +151,9 @@ private: SessionState session_; private: + Mailbox& mail() { return server_ ? server_mail : client_mail; } std::filesystem::path storage_; + bool server_{}; }; std::filesystem::path data_root; @@ -147,6 +161,11 @@ std::filesystem::path data_root; // their scripts while the game shuts down. GameEnvironment* environment{}; Host* host{}; +// Plugins' server copies, while this game hosts a lobby: the session's authority. +GameEnvironment* server_environment{}; +Host* server_host{}; +bool serving{}; +std::uint64_t local_player{}; std::mutex found_mutex; std::vector found; // under found_mutex std::uint64_t found_generation{}; // under found_mutex @@ -157,6 +176,7 @@ tricks::TrickWatch trick_watch; Vec3 started_at{}; std::chrono::steady_clock::time_point started_time{}; std::string watched_level; +std::string last_trick_name; // the score HUD's last sequence header // The score HUD into the trick watch, and what it saw to the plugins. Client thread. void read_tricks() { @@ -174,7 +194,9 @@ void read_tricks() { // A wipeout is physics states 300-399 (as Domination reads it). signals.wipeout = skater.skater && skater.physics_state >= 300 && skater.physics_state < 400; const auto event = trick_watch.update(signals); - environment->score_now = ScoreState{hud->line_score, hud->multiplier, trick_watch.in_trick()}; + environment->score_now = ScoreState{hud->line_score, hud->multiplier, trick_watch.in_trick(), hud->header, hud->descriptor, + tricks::names_manual(*hud)}; + if (!hud->header.empty()) last_trick_name = hud->header; const auto now = std::chrono::steady_clock::now(); TrickNews news; switch (event.kind) { @@ -192,6 +214,7 @@ void read_tricks() { news.started_at = started_at; news.landed_at = skater.position; news.ms = std::chrono::duration(now - started_time).count(); + news.name = last_trick_name; break; case tricks::TrickEvent::Kind::broken: news.kind = TrickNews::Kind::broken; @@ -244,8 +267,10 @@ bool start(std::uintptr_t image_base) noexcept { data_root = std::filesystem::path(std::wstring(path.data(), length)).parent_path(); auto storage = local_storage(); if (storage.empty()) storage = data_root / L"ReSkate.Plugins"; - environment = new GameEnvironment(std::move(storage)); + server_environment = new GameEnvironment(storage / L"server", true); + environment = new GameEnvironment(std::move(storage), false); host = new Host(*environment, Side::client); + server_host = new Host(*server_environment, Side::server, {}, "@server"); std::thread(watch).detach(); logging::write(logging::Level::info, logging::Channel::plugins, "Plugins: looking in Mods for reskate-plugin.json every 2 s."); return true; @@ -270,6 +295,27 @@ void on_client_tick() noexcept { } } if (changed) host->apply(latest); + // The server copies run while this game hosts a lobby (a dedicated server runs its own). + const auto session = environment->session(); + local_player = 0; + bool hosting = false; + for (const auto& player : session.players) + if (player.me) { + local_player = player.id; + hosting = session.active && player.host && !session.dedicated; + } + if (hosting != serving) { + serving = hosting; + if (serving) { + std::lock_guard lock(found_mutex); + server_host->apply(found); + logging::write(logging::Level::info, logging::Channel::plugins, "Plugins: hosting a lobby, so server copies run here."); + } else { + server_host->apply({}); + std::lock_guard lock(messages_mutex); + server_mail = {}; + } + } else if (serving && changed) server_host->apply(latest); } const auto now = std::chrono::steady_clock::now(); const double dt = last_tick == std::chrono::steady_clock::time_point{} ? 0 @@ -278,6 +324,7 @@ void on_client_tick() noexcept { if (host->wants_tricks()) read_tricks(); else environment->score_now.reset(); host->tick(dt); + if (serving) server_host->tick(dt); } catch (const std::exception& error) { logging::log(logging::Level::error, logging::Channel::plugins, "Plugin tick failed: {}", error.what()); } @@ -300,28 +347,59 @@ void set_level(std::string_view level) noexcept { bool run_console(const std::string& text) noexcept { if (!host) return false; - try { return host->run_console(text); } + try { return host->run_console(text) || (serving && server_host->run_console(text)); } catch (const std::exception& error) { logging::log(logging::Level::error, logging::Channel::plugins, "Plugin command failed: {}", error.what()); return true; } } +// Another player's plugin message. On a lobby's host the server copies hear what is for them and +// what is for everyone; the authority's words come only from the authority (session.cpp checks). void receive_message(std::uint64_t sender, const std::vector& bytes) noexcept { try { std::lock_guard lock(messages_mutex); - if (inbox.size() < 256) inbox.push_back({sender, bytes}); + const auto to = wire::to_of(bytes); + const auto put = [&](Mailbox& mail) { + if (mail.inbox.size() < 256) mail.inbox.push_back({sender, bytes}); + }; + if (serving) { + if (wire::from_authority(bytes)) return; + if (to == wire::authority || to == 0) put(server_mail); + if (to != wire::authority) put(client_mail); + } else if (to != wire::authority) put(client_mail); } catch (...) {} } +// What goes out to the session. On a lobby's host, what its own copies say to the authority stays +// here, and what the authority says to everyone (or the host) is heard here too. std::vector> take_outgoing() noexcept { std::lock_guard lock(messages_mutex); - return std::exchange(outbox, {}); + auto out = std::exchange(client_mail.outbox, {}); + if (!serving) return out; + std::vector> sent; + for (auto& bytes : out) { + const auto to = wire::to_of(bytes); + if ((to == wire::authority || to == 0) && server_mail.inbox.size() < 256) server_mail.inbox.push_back({local_player, bytes}); + if (to != wire::authority) sent.push_back(std::move(bytes)); + } + for (auto& bytes : std::exchange(server_mail.outbox, {})) { + const auto to = wire::to_of(bytes); + if ((to == 0 || to == local_player) && client_mail.inbox.size() < 256) client_mail.inbox.push_back({local_player, bytes}); + if (to != local_player) sent.push_back(std::move(bytes)); + } + return sent; } std::string describe() { if (!host) return "error: Plugins did not start."; - const auto plugins = host->status(); + auto plugins = host->status(); + // While this game hosts a lobby, its server copies too. + if (serving) + for (auto status : server_host->status()) { + status.id += "@server"; + plugins.push_back(std::move(status)); + } if (plugins.empty()) return "No plugins. A plugin is a mod in Mods with a reskate-plugin.json."; std::string text; for (const auto& plugin : plugins) { @@ -338,15 +416,31 @@ std::string describe() { std::string list_commands() { if (!host) return "error: Plugins did not start."; std::string text; - for (const auto& plugin : host->status()) - for (const auto& [name, help] : plugin.commands) { - if (!text.empty()) text += '\n'; - text += std::format("{} - {} ({})", name, help, plugin.id); - } + const auto add = [&](const std::vector& all, std::string_view suffix) { + for (const auto& plugin : all) + for (const auto& [name, help] : plugin.commands) { + if (!text.empty()) text += '\n'; + text += std::format("{} - {} ({}{})", name, help, plugin.id, suffix); + } + }; + add(host->status(), ""); + if (serving) add(server_host->status(), "@server"); // run when no player's copy has the command return text.empty() ? "No plugin has a console command." : text; } -std::string reload(std::string_view id) { return host ? host->reload(id) : "error: Plugins did not start."; } -std::string switch_off(std::string_view id) { return host ? host->switch_off(id) : "error: Plugins did not start."; } -std::string switch_on(std::string_view id) { return host ? host->switch_on(id) : "error: Plugins did not start."; } +// "@server" names a server copy (on this game while it hosts a lobby); a plain id, the player's copy. +namespace { +template std::string either(std::string_view id, Do&& act) { + if (!host) return "error: Plugins did not start."; + constexpr std::string_view server_suffix = "@server"; + if (id.ends_with(server_suffix)) { + if (!serving) return "error: server copies run only while this game hosts a lobby."; + return act(*server_host, id.substr(0, id.size() - server_suffix.size())); + } + return act(*host, id); +} +} +std::string reload(std::string_view id) { return either(id, [](Host& h, std::string_view i) { return h.reload(i); }); } +std::string switch_off(std::string_view id) { return either(id, [](Host& h, std::string_view i) { return h.switch_off(i); }); } +std::string switch_on(std::string_view id) { return either(id, [](Host& h, std::string_view i) { return h.switch_on(i); }); } } diff --git a/Extension/Plugins/plugin_sound.cpp b/Extension/Plugins/plugin_sound.cpp new file mode 100644 index 00000000..41c6d367 --- /dev/null +++ b/Extension/Plugins/plugin_sound.cpp @@ -0,0 +1,156 @@ +#include "plugin_sound.h" +#include "Extension/Multiplayer/Remote/native_skater.h" +#include "Extension/Multiplayer/Voice/native_voice.h" +#include "Extension/Skater/client_source_spawn.h" +#include +#include +#include +#include +#include + +namespace dingosdk::plugin_sound { +namespace { +using Clock = std::chrono::steady_clock; +using multiplayer::native_voice_rate; +// Plugins' sounds in the game's sample provider ("RSKP"), apart from voices. +constexpr std::uint64_t source_tag = 0x52534b5000000000ULL; +constexpr std::size_t most_each = 8, most_all = 48; +// Made ahead for a sound just started, so its first blocks do not run dry; at most this much in one +// frame; how long a finished sound keeps its place for its last blocks to play. +constexpr double preroll_seconds = 0.06; +constexpr float longest_frame = 0.1f, tail_seconds = 0.4f; + +struct Playing { + Voice voice; + double cursor{}, owed{preroll_seconds * native_voice_rate}; + float tail{}; + bool ended{}; + audio::MotorSynth synth{static_cast(native_voice_rate)}; +}; +struct Player { + multiplayer::NativeVoicePlayback playback{source_tag}; + std::map voices; + std::uint64_t next_id = 1; + bool started{}, in_level{}; + Clock::time_point last{}; +}; +// Never destroyed: its sounds belong to the game, which is gone by then. +Player& player() { + static auto* value = new Player; + return *value; +} +float distance(const std::array& a, const std::array& b) { + const float x = a[0] - b[0], y = a[1] - b[1], z = a[2] - b[2]; + return std::sqrt(x * x + y * y + z * z); +} +} + +std::uint64_t start(Voice voice) { + auto& p = player(); + if (!p.in_level) return 0; + std::size_t mine = 0; + for (const auto& [id, playing] : p.voices) mine += playing.voice.owner == voice.owner && !playing.ended; + if (mine >= most_each || p.voices.size() >= most_all) return 0; + const auto id = p.next_id++; + Playing playing; + playing.voice = std::move(voice); + p.voices.emplace(id, std::move(playing)); + return id; +} +Voice* find(std::uint64_t id, std::string_view owner) { + auto& p = player(); + const auto found = p.voices.find(id); + return found != p.voices.end() && found->second.voice.owner == owner && !found->second.ended ? &found->second.voice : nullptr; +} +void stop(std::uint64_t id, std::string_view owner) { + auto& p = player(); + if (const auto found = p.voices.find(id); found != p.voices.end() && found->second.voice.owner == owner) found->second.ended = true; +} +void stop_all(std::string_view owner) { + for (auto& [id, playing] : player().voices) + if (playing.voice.owner == owner) playing.ended = true; +} + +void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept { + auto& p = player(); + try { + const auto now = Clock::now(); + if (!p.started) { + p.playback.reset(1); + p.started = true; + p.last = now; + } + const float elapsed = std::clamp(std::chrono::duration(now - p.last).count(), 0.0f, longest_frame); + p.last = now; + p.in_level = ready; + if (p.voices.empty()) return; + std::array camera{}; + if (!ready || !read_local_camera_transform(base, client, camera)) { + for (const auto& [id, playing] : p.voices) p.playback.remove(id, 1); + p.voices.clear(); // out of a level: they end + p.playback.process(base); + return; + } + const std::array ear{camera[12], camera[13], camera[14]}; + std::optional> skater; + if (std::any_of(p.voices.begin(), p.voices.end(), [](const auto& v) { return !v.second.voice.position; })) { + const auto frame = multiplayer::capture_local(base, client, false); + if (frame.ready) skater = std::array{frame.pose.root.position[0], frame.pose.root.position[1] + 0.3f, frame.pose.root.position[2]}; + } + std::vector block; + for (auto it = p.voices.begin(); it != p.voices.end();) { + auto& [id, playing] = *it; + auto& v = playing.voice; + const auto at = v.position ? *v.position : skater ? *skater : ear; + const float d = distance(ear, at), range = std::max(v.range, 1.0f); + const float fall = std::clamp(1.0f - d / range, 0.0f, 1.0f); + const float gain = std::clamp(v.volume, 0.0f, 1.0f) * fall * fall; + p.playback.position(id, 1, at, playing.ended ? 0.0f : gain); + if (playing.ended) { + playing.tail += elapsed; + if (playing.tail >= tail_seconds) { + p.playback.remove(id, 1); + it = p.voices.erase(it); + continue; + } + ++it; + continue; + } + playing.owed += static_cast(elapsed) * native_voice_rate; + const auto count = static_cast(std::min(std::floor(playing.owed), static_cast(native_voice_rate) * longest_frame)); + playing.owed -= static_cast(count); + if (!count) { + ++it; + continue; + } + block.resize(count); + if (!v.wave) { + playing.synth.render(block.data(), static_cast(count), v.throttle, v.speed, 1.0f, v.pitch); + } else { + // The wave at its pitch: read faster or slower, between its samples. + const auto& samples = v.wave->samples; + const double step = std::clamp(static_cast(v.pitch), 0.25, 4.0); + std::size_t made = 0; + for (; made < count; ++made) { + if (playing.cursor >= static_cast(samples.size() - 1)) { + if (!v.loop) break; + playing.cursor = std::fmod(playing.cursor, static_cast(samples.size())); + } + const auto i = static_cast(playing.cursor); + const auto f = static_cast(playing.cursor - static_cast(i)); + const float a = samples[i], b = samples[std::min(i + 1, samples.size() - 1)]; + block[made] = static_cast(a + (b - a) * f); + playing.cursor += step; + } + block.resize(made); + if (made < count) playing.ended = true; // played out; its last block still plays + } + if (!block.empty()) p.playback.submit(id, 1, {reinterpret_cast(block.data()), block.size() * sizeof(std::int16_t)}); + ++it; + } + p.playback.process(base); + } catch (...) { + p.voices.clear(); + } +} +} diff --git a/Extension/Plugins/plugin_sound.h b/Extension/Plugins/plugin_sound.h new file mode 100644 index 00000000..88fdeff4 --- /dev/null +++ b/Extension/Plugins/plugin_sound.h @@ -0,0 +1,32 @@ +#pragma once +#include "plugin_wave.h" +#include "Extension/Skater/motor_synth.h" +#include +#include +#include +#include +#include +#include + +// Plugins' sounds in the game (reskate.audio): their own WAVE files, or a synthesised motor, played +// through the game's positioned voice sound (Multiplayer/Voice/native_voice.h, as players' voices +// are), so the level's mix, echo and walls apply. In a level only. Client thread. +namespace dingosdk::plugin_sound { +struct Voice { + std::string owner; + std::shared_ptr wave; // empty: the motor + float volume{1}, pitch{1}; + float range{40}; // metres to silence + float throttle{}, speed{}; // the motor's + bool loop{}; + std::optional> position; // empty: at the local skater +}; +// Its id, or 0 when there is no room (at most 8 a plugin, 48 in all). +std::uint64_t start(Voice voice); +// The voice of `owner`, to change while it plays; null once it has finished. +Voice* find(std::uint64_t id, std::string_view owner); +void stop(std::uint64_t id, std::string_view owner); +void stop_all(std::string_view owner); +// Each client tick: plays them, where they are, as loud as they are from the camera. +void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept; +} diff --git a/Extension/Plugins/plugin_ui.h b/Extension/Plugins/plugin_ui.h index 281d19f8..683e4779 100644 --- a/Extension/Plugins/plugin_ui.h +++ b/Extension/Plugins/plugin_ui.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,14 @@ struct Style { float rounding{}; int align{}; // text: 0 left, 1 centre, 2 right of its point bool shadow = true; + std::array uv{0, 0, 1, 1}; // pictures: the part of the picture drawn (u0, v0, u1, v1) }; // Screen shapes are in pixels from the top left; world shapes in world metres, projected through // the camera as the frame is drawn, and left out behind it. struct Shape { - enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle } kind{Kind::text}; + // image: `text` is the picture's file, points[0] and [1] its corners; world_image: centred on + // points[0], `radius` metres tall. + enum class Kind { text, rect, line, circle, poly, world_text, world_line, world_circle, image, world_image } kind{Kind::text}; std::vector points; float radius{}; std::string text; @@ -40,7 +44,8 @@ struct Layer { }; struct Widget { - enum class Kind { heading, label, separator, button, toggle, slider, dropdown } kind{Kind::label}; + // bar: a read-only gauge, `value` between `minimum` and `maximum` (an axis, a level). + enum class Kind { heading, label, separator, button, toggle, slider, dropdown, bar } kind{Kind::label}; std::string id, text; bool on{}; float value{}, minimum{}, maximum{1}; @@ -54,6 +59,17 @@ struct Panel { }; // The widget a full-screen screen's Esc sends. inline constexpr char close_widget[] = "__close"; +// A plugin's bind (reskate.input.bind) as the Binds page lists it: the plugin's own combo, and the +// one in use (the player's, when they recorded or cleared one; 0 is none). +struct Bind { + std::string plugin, action; + std::uint32_t own{}, combo{}; +}; +// The Binds page's change to one: a combo (0 clears it), or nullopt for the plugin's own again. +struct Rebind { + std::string plugin, action; + std::optional combo; +}; // What the player did to a widget: pressed a button, or set a toggle, slider or dropdown. struct Action { std::string plugin, panel, widget; @@ -67,6 +83,8 @@ inline std::mutex mutex; inline std::shared_ptr> layers = std::make_shared>(); inline std::shared_ptr> panels = std::make_shared>(); inline std::vector actions; +inline std::shared_ptr> binds = std::make_shared>(); +inline std::vector rebinds; inline std::atomic panels_seen_ms{}; inline std::atomic display_width{1920}, display_height{1080}; inline std::int64_t now_ms() { @@ -102,6 +120,25 @@ inline std::vector take_actions() { std::lock_guard lock(detail::mutex); return std::exchange(detail::actions, {}); } +// Client thread: every plugin's binds, whenever they change. +inline void publish_binds(std::vector value) { + auto shared = std::make_shared>(std::move(value)); + std::lock_guard lock(detail::mutex); + detail::binds = std::move(shared); +} +inline std::shared_ptr> latest_binds() { + std::lock_guard lock(detail::mutex); + return detail::binds; +} +// Render thread: the player recorded, cleared or reset a plugin's bind. +inline void rebind(Rebind change) { + std::lock_guard lock(detail::mutex); + if (detail::rebinds.size() < 64) detail::rebinds.push_back(std::move(change)); +} +inline std::vector take_rebinds() { + std::lock_guard lock(detail::mutex); + return std::exchange(detail::rebinds, {}); +} // Render thread, each frame the PLUGINS tab shows: panels are built only while it is open. inline void note_panels_shown() { detail::panels_seen_ms.store(detail::now_ms(), std::memory_order_relaxed); } inline bool panels_shown() { return detail::now_ms() - detail::panels_seen_ms.load(std::memory_order_relaxed) < 500; } diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index 8fd66a33..77764277 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -93,6 +93,12 @@ bool Plugin::load(std::string entry) { const auto file = services_.storage_file(id_); std::string error; const auto text = read_package_file(file, limits_.storage_bytes * 2, error); + if (!error.empty() && error != "not found") { + // Kept aside, not overwritten by the next save. + std::error_code code; + std::filesystem::rename(file, std::filesystem::path(file) += ".bad", code); + log(LogLevel::warn, "its storage could not be read and was set aside: " + error); + } if (error.empty()) { try { auto stored = Json::parse(text, {limits_.storage_bytes * 2, 33, 1u << 20}); @@ -349,6 +355,8 @@ void Plugin::trick(const TrickNews& news) { lua_setfield(L, -2, "landed_at"); lua_pushnumber(L, news.ms); lua_setfield(L, -2, "ms"); + lua_pushlstring(L, news.name.data(), news.name.size()); + lua_setfield(L, -2, "name"); return 1; }); break; @@ -365,6 +373,7 @@ void Plugin::trick(const TrickNews& news) { void Plugin::tick(double dt_ms) { if (state_ != State::running) return; + ++tick_number_; raycasts_ = 0; const double now = services_.now_ms(); std::vector due; @@ -561,12 +570,26 @@ void Plugin::skater_changed(const char* state, const char* previous) { }); } -void Plugin::bailed(std::uint64_t wipeouts) { +void Plugin::game_event(std::string_view event, std::string_view permission, const Json& data) { + if (state_ != State::running || !has(permission)) return; + fire_with(event, [&](lua_State* L) { + push_json(L, data); + return 1; + }); +} +void Plugin::bailed(std::uint64_t wipeouts, std::optional slam) { if (state_ != State::running || !has("events.skater")) return; fire_with("bail", [&](lua_State* L) { - lua_createtable(L, 0, 2); + lua_createtable(L, 0, 4); lua_pushnumber(L, static_cast(wipeouts)); lua_setfield(L, -2, "count"); + // The slam that did it, when one was recorded in the last second: its force and cause. + if (slam && slam->ago_ms < 1000) { + lua_pushnumber(L, slam->force); + lua_setfield(L, -2, "force"); + lua_pushinteger(L, slam->cause); + lua_setfield(L, -2, "cause"); + } if (const auto s = services_.skater()) { lua_pushvector(L, s->position[0], s->position[1], s->position[2]); lua_setfield(L, -2, "at"); @@ -668,7 +691,7 @@ void Plugin::save_storage() { auto temporary = std::filesystem::path(file) += ".tmp"; { std::ofstream out(temporary, std::ios::binary | std::ios::trunc); - const auto text = storage_.dump(2); + const auto text = storage_.dump(); // as compact as the quota measures it if (!out || !out.write(text.data(), static_cast(text.size()))) throw std::runtime_error("cannot write it"); } std::filesystem::rename(temporary, file); diff --git a/Extension/Plugins/plugin_vm.h b/Extension/Plugins/plugin_vm.h index 4f9b9ddd..60f13ca3 100644 --- a/Extension/Plugins/plugin_vm.h +++ b/Extension/Plugins/plugin_vm.h @@ -45,6 +45,9 @@ struct Limits { int teleports_per_second = 2; std::size_t shapes = 4000; // drawn each frame std::size_t shape_text_bytes = 64u << 10; + std::size_t images = 32; // different pictures a plugin draws + std::size_t clip_bytes = 48u << 20; // the clips a plugin holds, as poses (40 bytes a joint a frame) + std::size_t sounds = 32; // different waves a plugin plays, each at most 30 s int toasts = 3; // in 5 s int sounds_per_second = 10; std::size_t message_bytes = 4096 - 14; // a plugin message's payload (plugin_wire.h's header is 14) @@ -81,16 +84,20 @@ struct TrickNews { Vec3 started_at{}, landed_at{}; double ms{}; // landed: from its start std::string reason; // broken: "slam" or "wipeout" + std::string name; // landed: the trick the HUD named last (may be empty) }; struct ScoreState { float line{}, multiplier{}; bool in_trick{}; + std::string trick, detail; // the HUD's sequence header and descriptor ("Manual", "Kickflip"...) + bool manual{}; // a manual is going on, as the HUD names it }; // The multiplayer session as plugins see it: who is in it (the local player too). struct SessionPlayer { std::uint64_t id{}; // SteamID64 std::string name; bool me{}, host{}; + bool server{}; // the session's authority: a message from a plugin's server copy bool operator==(const SessionPlayer&) const = default; }; struct SessionState { @@ -153,6 +160,9 @@ public: virtual bool http(HttpRequest request) = 0; // The wider set's game (plugin_game.h). virtual Game& game() = 0; + // Whether this is the session's authority: plugins' server copies, on the lobby's host or the + // dedicated server. + virtual bool authority() = 0; }; enum class State { loading, running, stopped }; @@ -201,7 +211,9 @@ public: void http_done(const HttpResponse& response); // The wider set's events, each to a plugin with its permission and a handler for it. void skater_changed(const char* state, const char* previous); - void bailed(std::uint64_t wipeouts); + void bailed(std::uint64_t wipeouts, std::optional slam); + // A game event with plain data (board_landed, body_hit, bail_end), to a plugin with `permission`. + void game_event(std::string_view event, std::string_view permission, const Json& data); void chat_line(const ChatLine& line); void track_changed(const Track& track); void throwdown_phase(const char* phase); @@ -288,7 +300,18 @@ private: int teleports_{}; std::vector shapes_; std::size_t shape_text_{}; - bool drawing_{}, shapes_warned_{}; + bool drawing_{}; + double shapes_warned_at_{-1e9}; // the drawing limit is said at most every 10 s + std::map> image_files_; // a picture's path in the package, and its file + // skater.animation: clips by their path in the package, or "rec:" for a recording, and the + // bytes they hold (at most limits_.clip_bytes). + std::map, std::less<>> clips_; + std::size_t clip_bytes_{}; + int recordings_{}; + // reskate.audio: waves by their path in the package, and how many sounds began this second. + std::map, std::less<>> waves_; + double wave_second_{}; + int wave_starts_{}; int draw_table_{}, ui_table_{}; struct PanelEntry { std::string title; int function{}; }; std::vector panels_; @@ -308,12 +331,13 @@ private: }; std::vector listeners_; int calls_{}; - double emit_tick_{}; + std::uint64_t emit_tick_{}, tick_number_{}; // the tick events were emitted in; ticks so far std::size_t emitted_{}; std::map http_pending_; // request id: its callback double control_second_{}, edit_second_{}, environment_second_{}, voice_second_{}, music_second_{}; int controls_{}, edits_{}, environments_{}, voices_{}, musics_{}; - double travel_at_{-1e9}, chat_at_{-1e9}; + double travel_at_{-1e9}, chat_at_{-1e9}, local_chat_second_{}, server_second_{}, host_at_{-1e9}, wipeout_at_{-1e9}; + int local_chats_{}, server_calls_{}; std::optional> trainer_point_; std::set> binds_; std::vector screens_; diff --git a/Extension/Plugins/plugin_wave.cpp b/Extension/Plugins/plugin_wave.cpp new file mode 100644 index 00000000..bc093f1b --- /dev/null +++ b/Extension/Plugins/plugin_wave.cpp @@ -0,0 +1,81 @@ +#include "plugin_wave.h" +#include +#include +#include + +namespace dingosdk::plugins { +namespace { +constexpr std::uint16_t format_pcm = 1, format_float = 3, format_extensible = 0xfffe; +template T get(const std::uint8_t* at) { + T value{}; + std::memcpy(&value, at, sizeof value); + return value; +} +} + +std::shared_ptr decode_wave(std::span bytes, float most_seconds, std::string& error) { + if (bytes.size() < 12 || std::memcmp(bytes.data(), "RIFF", 4) || std::memcmp(bytes.data() + 8, "WAVE", 4)) { + error = "it is not a WAVE file"; + return nullptr; + } + std::span format, data; + for (std::size_t at = 12; at + 8 <= bytes.size();) { + auto size = static_cast(get(bytes.data() + at + 4)); + const auto body = at + 8; + size = std::min(size, bytes.size() - body); + if (!std::memcmp(bytes.data() + at, "fmt ", 4)) format = bytes.subspan(body, size); + else if (!std::memcmp(bytes.data() + at, "data", 4)) data = bytes.subspan(body, size); + at = body + size + (size & 1); + } + if (format.size() < 16 || data.empty()) { + error = "it has no format or no sound"; + return nullptr; + } + auto tag = get(format.data()); + const unsigned channels = get(format.data() + 2), rate = get(format.data() + 4), + bits = get(format.data() + 14); + if (tag == format_extensible && format.size() >= 26) tag = get(format.data() + 24); // its SubFormat + const bool floats = tag == format_float; + if (!channels || channels > 8 || rate < 4000 || rate > 192000 || (tag != format_pcm && !floats) || (floats && bits != 32) || + (!floats && bits != 8 && bits != 16 && bits != 24 && bits != 32)) { + error = "its format is not 8 to 32-bit PCM or 32-bit float"; + return nullptr; + } + const std::size_t stride = channels * (bits / 8), frames = data.size() / stride; + if (static_cast(frames) / rate > most_seconds) { + error = "it is longer than " + std::to_string(static_cast(most_seconds)) + " s"; + return nullptr; + } + const auto at = [&](std::size_t frame) { + float sum = 0.0f; + for (unsigned c = 0; c < channels; ++c) { + const auto* p = data.data() + frame * stride + c * (bits / 8); + float v{}; + if (floats) v = get(p); + else if (bits == 8) v = (static_cast(*p) - 128.0f) / 128.0f; + else if (bits == 16) v = get(p) / 32768.0f; + else if (bits == 24) v = static_cast(static_cast((p[0] << 8) | (p[1] << 16) | (p[2] << 24)) >> 8) / 8388608.0f; + else v = static_cast(get(p)) / 2147483648.0f; + sum += std::isfinite(v) ? v : 0.0f; + } + return sum / static_cast(channels); + }; + // Each output sample is the mean of the input samples it spans (a plain low-pass). + const double step = static_cast(rate) / wave_rate; + const auto count = static_cast(static_cast(frames) / step); + auto wave = std::make_shared(); + wave->samples.resize(count); + for (std::size_t i = 0; i < count; ++i) { + const auto from = static_cast(static_cast(i) * step); + const auto to = std::min(frames, std::max(from + 1, static_cast(static_cast(i + 1) * step))); + float sum = 0.0f; + for (auto f = from; f < to; ++f) sum += at(f); + wave->samples[i] = static_cast(std::clamp(sum / static_cast(to - from), -1.0f, 1.0f) * 32767.0f); + } + if (wave->samples.empty()) { + error = "it holds no sound"; + return nullptr; + } + return wave; +} +} diff --git a/Extension/Plugins/plugin_wave.h b/Extension/Plugins/plugin_wave.h new file mode 100644 index 00000000..5b566e4f --- /dev/null +++ b/Extension/Plugins/plugin_wave.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include +#include +#include + +// Sounds plugins ship (reskate.audio): a RIFF WAVE file read whole and made into what the game's +// positioned sound takes, 16 kHz mono 16-bit (as Board Break's sounds are). 8, 16, 24 or 32-bit PCM +// or 32-bit float, any rate and channels; at most `most_seconds` long. Portable: no game here. +namespace dingosdk::plugins { +inline constexpr unsigned wave_rate = 16000; +struct Wave { + std::vector samples; // 16 kHz mono + float seconds() const noexcept { return static_cast(samples.size()) / wave_rate; } +}; +// Empty, and `error` says why, for a file that is not one it can play. +std::shared_ptr decode_wave(std::span bytes, float most_seconds, std::string& error); +} diff --git a/Extension/Plugins/plugin_wire.h b/Extension/Plugins/plugin_wire.h index 9220fa02..11a0abda 100644 --- a/Extension/Plugins/plugin_wire.h +++ b/Extension/Plugins/plugin_wire.h @@ -8,12 +8,16 @@ // A plugin's message to the same plugin on other players' games, sent in the session's throwdown // packets (as Domination's are). Throwdown kinds are 1-15 and Domination's tag 0xD0; plugins' is -// 0xD1. Little-endian: u8 tag, u8 version, u32 channel (fnv1a of the plugin's lowercase name), u64 -// the player it is for (0: everyone), then the payload. The host fills in all but the payload, so -// a plugin cannot pretend to be another or say who sent it (the session says that). +// 0xD1, and 0xD2 for what the session's authority says (the server copy of a plugin, on the +// lobby's host or the dedicated server), which only the authority may send. Little-endian: u8 tag, +// u8 version, u32 channel (fnv1a of the plugin's lowercase name), u64 the player it is for (0: +// everyone, 1: the authority), then the payload. The host fills in all but the payload, so a plugin +// cannot pretend to be another or say who sent it (the session says that). namespace dingosdk::plugins::wire { -inline constexpr std::uint8_t tag = 0xD1, version = 1; +inline constexpr std::uint8_t tag = 0xD1, authority_tag = 0xD2, version = 1; inline constexpr std::size_t header = 14; +// `to` for the authority: no SteamID64 is this small. +inline constexpr std::uint64_t authority = 1; inline std::uint32_t channel_of(std::string_view name) { std::uint32_t hash = 2166136261u; @@ -23,12 +27,26 @@ inline std::uint32_t channel_of(std::string_view name) { } return hash; } -inline bool is_plugin(std::span bytes) noexcept { return !bytes.empty() && bytes[0] == tag; } +inline bool is_plugin(std::span bytes) noexcept { + return !bytes.empty() && (bytes[0] == tag || bytes[0] == authority_tag); +} +inline bool from_authority(std::span bytes) noexcept { return !bytes.empty() && bytes[0] == authority_tag; } +inline std::uint64_t to_of(std::span bytes) noexcept { + std::uint64_t to = 0; + if (bytes.size() >= header) + for (int i = 0; i < 8; ++i) to |= static_cast(bytes[6 + i]) << (8 * i); + return to; +} +// Whether a relay (the lobby's host, the dedicated server) passes a guest's packet on to everyone: +// not a plugin message for the authority alone, nor one claiming to be the authority's. +inline bool relayed(std::span bytes) noexcept { + return !is_plugin(bytes) || (!from_authority(bytes) && to_of(bytes) != authority); +} -inline std::vector encode(std::uint32_t channel, std::uint64_t to, std::string_view payload) { +inline std::vector encode(std::uint32_t channel, std::uint64_t to, std::string_view payload, bool authority_says = false) { std::vector bytes; bytes.reserve(header + payload.size()); - bytes.push_back(tag); + bytes.push_back(authority_says ? authority_tag : tag); bytes.push_back(version); for (int i = 0; i < 4; ++i) bytes.push_back(static_cast(channel >> (8 * i))); for (int i = 0; i < 8; ++i) bytes.push_back(static_cast(to >> (8 * i))); @@ -39,11 +57,13 @@ struct Message { std::uint32_t channel{}; std::uint64_t to{}; std::string payload; + bool authority{}; // said by the session's authority }; // Empty for anything that is not a plugin message of this version. inline std::optional decode(std::span bytes) { - if (bytes.size() < header || bytes[0] != tag || bytes[1] != version) return std::nullopt; + if (bytes.size() < header || !is_plugin(bytes) || bytes[1] != version) return std::nullopt; Message message; + message.authority = from_authority(bytes); for (int i = 0; i < 4; ++i) message.channel |= static_cast(bytes[2 + i]) << (8 * i); for (int i = 0; i < 8; ++i) message.to |= static_cast(bytes[6 + i]) << (8 * i); message.payload.assign(reinterpret_cast(bytes.data() + header), bytes.size() - header); diff --git a/Extension/RoadRash/Test/road_rash_tests.cpp b/Extension/RoadRash/Test/road_rash_tests.cpp deleted file mode 100644 index f133c001..00000000 --- a/Extension/RoadRash/Test/road_rash_tests.cpp +++ /dev/null @@ -1,160 +0,0 @@ -// Road Rash's rules, with made-up falls: the side of a fall, the marks a count of bails leaves, the -// names of the items that draw them and where a limb's decal goes. -#include "Extension/RoadRash/road_rash_model.h" -#include -#include -#include -#include -#include -#include -#include - -using namespace dingosdk; -using namespace dingosdk::road_rash; -using skater_body::Bone; -namespace { -void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } -bool near(float a, float b) { return std::abs(a - b) < 1e-4f; } - -// One physics step in which `bone` hits the ground this hard and slides along it this fast. -skater_body::Contacts hit(Bone bone, float impact, float slide = 0.0f) { - skater_body::Contacts contacts; - auto& body = contacts.bodies[skater_body::index(bone)]; - body.touching = true; - body.impact = impact; - body.slide = {slide, 0.0f, 0.0f}; - body.hit.world = true; - return contacts; -} -int level(const Tally& tally, Part part) { return tally.levels[index(part)]; } -Tally after(unsigned bails, Side side) { - Tally tally; - for (unsigned i = 0; i < bails; ++i) count(tally, side); - return tally; -} - -void a_fall_is_on_the_side_whose_limbs_took_it() { - Fall fall; - check(side(fall) == Side::both, "A fall nothing felt is on neither side"); - add(fall, hit(Bone::left_forearm, 3.0f, 2.0f)); - add(fall, hit(Bone::left_upleg, 2.0f)); - add(fall, hit(Bone::right_hand, 1.0f)); - check(near(fall.left, 7.0f) && near(fall.right, 1.0f), "Impact and slide of each side's limbs add up"); - check(side(fall) == Side::left, "The left limbs took most of it"); - Fall other; - add(other, hit(Bone::right_leg, 4.0f)); - add(other, hit(Bone::right_arm, 1.0f, 3.0f)); - check(side(other) == Side::right, "and the right ones of this one"); - Fall even; - add(even, hit(Bone::left_leg, 3.0f)); - add(even, hit(Bone::right_leg, 2.6f)); - check(side(even) == Side::both, "A fall both sides felt alike is on both"); - Fall light; - add(light, hit(Bone::left_hand, 0.5f)); - check(side(light) == Side::both, "A touch is no fall on a side"); -} - -void feet_trunk_head_and_the_own_board_count_for_no_side() { - Fall fall; - for (const auto bone : {Bone::left_foot, Bone::left_toe, Bone::right_foot, Bone::right_toe, Bone::hips, Bone::spine2, Bone::neck1}) - add(fall, hit(bone, 9.0f, 9.0f)); - check(fall.left == 0.0f && fall.right == 0.0f, "Feet stand on the ground all the time; trunk and head have no side"); - auto board = hit(Bone::left_hand, 5.0f); - board.bodies[skater_body::index(Bone::left_hand)].hit = {}; - board.bodies[skater_body::index(Bone::left_hand)].hit.board = true; - add(fall, board); - check(fall.left == 0.0f, "A hand on the skater's own board hit nothing"); - auto untouched = hit(Bone::right_leg, 5.0f); - untouched.bodies[skater_body::index(Bone::right_leg)].touching = false; - add(fall, untouched); - check(fall.right == 0.0f, "A body that touches nothing hit nothing"); -} - -void marks_come_with_the_bails() { - check(after(9, Side::left).levels == std::array{}, "Nine bails leave no mark"); - const auto ten = after(10, Side::left); - check(level(ten, Part::left_leg) == 1 && level(ten, Part::left_arm) == 0 && level(ten, Part::right_leg) == 0, - "The tenth marks the leg of the side that took them"); - const auto eleven = after(11, Side::left); - check(level(eleven, Part::left_arm) == 1 && level(eleven, Part::right_leg) == 0, "the arm follows one bail later"); - const auto fourteen = after(14, Side::left); - check(level(fourteen, Part::left_leg) == 2 && level(fourteen, Part::right_leg) == 1 && level(fourteen, Part::right_arm) == 0, - "At fourteen the leg is worse and the other side's leg starts"); - const auto twenty = after(20, Side::left); - check(level(twenty, Part::left_leg) == 3 && level(twenty, Part::left_arm) == 2 && level(twenty, Part::face) == 1, - "At twenty the leg is at its worst and the face shows it"); - const auto all = after(25, Side::left); - check(level(all, Part::left_leg) == 3 && level(all, Part::left_arm) == 3 && level(all, Part::right_leg) == 3 && - level(all, Part::right_arm) == 3 && level(all, Part::face) == 2, "At twenty-five everything is at its worst"); - check(after(200, Side::both).levels == all.levels, "and stays there"); -} - -void the_side_that_took_more_goes_first_and_no_mark_gets_smaller() { - const auto right = after(10, Side::right); - check(level(right, Part::right_leg) == 1 && level(right, Part::left_leg) == 0, "Falls on the right mark the right leg first"); - // Ten on the left, then the right side overtakes it: the left keeps what it has. - auto tally = after(10, Side::left); - for (int i = 0; i < 11; ++i) count(tally, Side::right); - check(!left_first(tally) && tally.bails == 21, "The right side took more by now"); - check(level(tally, Part::right_leg) == 3 && level(tally, Part::left_leg) >= 2, "It is ahead now, and the left leg kept its marks"); - // Falls on both sides count half for each; the last one-sided fall breaks the tie. - Tally even; - count(even, Side::right); - count(even, Side::left); - for (int i = 0; i < 8; ++i) count(even, Side::both); - check(near(even.left, even.right) && left_first(even) && level(even, Part::left_leg) == 1 && level(even, Part::right_leg) == 0, - "A tie goes to the side of the last one-sided fall"); -} - -void items_are_named_by_set_design_and_level() { - check(item_name(Part::left_leg, false, 0, 1, true) == "roadrash_leg_1", "The first design has no letter"); - check(item_name(Part::right_leg, true, 0, 2, true) == "roadrash_legr_2", "The right leg has its own, mirrored set"); - check(item_name(Part::right_leg, false, 0, 2, true) == "roadrash_leg_2", "or shares the left one's"); - check(item_name(Part::left_arm, false, 1, 3, true) == "roadrash_arm_b_3_blood", "The worst level comes with blood"); - check(item_name(Part::right_arm, true, 2, 3, false) == "roadrash_arm_c_3_dry", "or dry; arms share one set"); - check(item_name(Part::face, false, 0, 1, true) == "roadrash_face_1" && item_name(Part::face, false, 1, 2, false) == "roadrash_face_b_2_dry", - "The face's worst level is its second"); - check(item_name(Part::left_leg, false, 9, 1, true) == "roadrash_leg_1", "An unknown design is the first"); -} - -void a_limbs_decal_sits_on_its_knee_or_elbow() { - const auto left = placement(Part::left_leg, 1), right = placement(Part::right_leg, 1); - check(near(left[0] + right[0], 1.0f) && near(left[1], right[1]) && near(left[2], right[2]), "The right limb mirrors the left across the layout"); - check(near(left[2], 20.0f / 76.0f) && near(left[3], 0.5f), "A level's size is its centimetres over the limb's scale, upright"); - const auto worst = placement(Part::left_leg, 3); - check(worst[2] > left[2] && worst[1] < left[1], "A worse level is bigger and reaches further down the limb"); - check(placement(Part::left_arm, 3)[2] > placement(Part::left_arm, 1)[2] && placement(Part::left_arm, 9)[2] == placement(Part::left_arm, 3)[2], - "Arms likewise; a level past the worst is the worst"); -} - -void parameters_the_game_worked_out_again_are_the_same() { - const auto words = [](std::initializer_list values) { - std::vector out; - for (const float value : values) out.push_back(std::bit_cast(value)); - return out; - }; - const auto given = words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f, 0.5f}); - auto back = given; - back[2] = std::bit_cast(80.0f / 255.0f) + 1; // the same size, one bit off - check(same_parameters(given, given) && same_parameters(given, back), "A last bit of difference is no change"); - check(!same_parameters(given, words({0.365f, 0.515f, 16.0f / 51.0f, 0.5f, 0.5f})), "A centimetre down the limb is one"); - check(!same_parameters(given, words({0.365f, 0.505f, 16.0f / 51.0f, 0.5f})), "and so is another count of them"); -} -} - -int main() { - try { - a_fall_is_on_the_side_whose_limbs_took_it(); - feet_trunk_head_and_the_own_board_count_for_no_side(); - marks_come_with_the_bails(); - the_side_that_took_more_goes_first_and_no_mark_gets_smaller(); - items_are_named_by_set_design_and_level(); - a_limbs_decal_sits_on_its_knee_or_elbow(); - parameters_the_game_worked_out_again_are_the_same(); - } catch (const std::exception& error) { - std::cerr << "FAILED: " << error.what() << '\n'; - return 1; - } - std::cout << "Road Rash tests passed.\n"; - return 0; -} diff --git a/Extension/RoadRash/road_rash.cpp b/Extension/RoadRash/road_rash.cpp deleted file mode 100644 index f4366913..00000000 --- a/Extension/RoadRash/road_rash.cpp +++ /dev/null @@ -1,381 +0,0 @@ -#include "road_rash.h" -#include "road_rash_model.h" -#include "skater_items.h" -#include "Engine/Core/Log/logging.h" -#include "Engine/Game/Build/20260929/no_bail.h" -#include "Engine/Vfs/builtin_packs.h" -#include "Engine/Vfs/mod_catalog.h" -#include "Extension/HallOfMeat/hall_of_meat_skater.h" -#include "Extension/Multiplayer/Hud/game_ui_state.h" -#include "Extension/Profile/local_profile_runtime.h" -#include "Extension/Skater/no_bail.h" -#include -#include -#include -#include -#include -#include -#include - -namespace dingosdk::road_rash { -namespace { -constexpr const char* enabled_preference = "RoadRash"; -constexpr const char* blood_preference = "RoadRashBlood"; -constexpr const char* tattoos_preference = "RoadRashCoversTattoos"; -// Part of the name of the recipe slot each part's item goes into. -constexpr std::array slot_names{"tattoo_legl", "tattoo_arml", "tattoo_legr", "tattoo_armr", "moles"}; -constexpr std::uint64_t fall_at_most = 3000, fall_at_least = 400; // milliseconds a fall is watched for its side - -struct State { - // Any thread. - std::atomic ready{}, on{true}, blood{true}, covers{}, turned_off{}; - std::atomic heal{}, changed{}; // asked for from another thread, done at the next tick - std::atomic wanted_bails{-1}; - std::atomic bails{}; - // Client thread from here on. - Tally tally; - std::array designs{}; - bool designs_drawn{}; - std::uint64_t chance{}; - // The bail being watched for its side. - bool counted{}; - std::uint64_t wipeouts{}, last_bail{}; - std::optional fall_since; - Fall fall; - // What is on the skater. - std::uintptr_t skater{}; - bool dirty{true}, worn{}, missing_told{}; - bool own{}; // it wears items of ours, as a rule in a recipe of its own: the game passes - // nothing of the outfit on to it then - bool afresh{}; // the marks are to be worked out from the outfit again - std::optional feed; // how the game feeds the skater its outfit, as last seen - std::uintptr_t component{}; // the item component that was given a recipe - std::uint64_t checked{}, last_error{}, given_back{}, menu_seen{}; - std::uintptr_t slots_of{}; - std::array slots{}; - std::map items; // wanted name -> installed item, empty when missing -}; -State& state() { - static auto* value = new State; - return *value; -} - -template void say(logging::Level level, std::format_string text, Args&&... args) { - logging::log(level, logging::Channel::skater, "Road Rash: {}", std::format(text, std::forward(args)...)); -} - -bool ours(const std::string& asset) { return lower(asset).find(item_prefix) != std::string::npos; } -// A slot with nothing to see in it: empty, or the game's own "none" item of a face layer. -bool blank(const std::string& asset) { return asset.empty() || lower(asset).ends_with("_empty"); } - -// Which design each part wears: drawn afresh whenever the skater is clean. -void draw_designs(State& s) { - if (!s.chance) { - LARGE_INTEGER now{}; - QueryPerformanceCounter(&now); - s.chance = static_cast(now.QuadPart) | 1; - } - for (auto& design : s.designs) { - s.chance ^= s.chance << 13; - s.chance ^= s.chance >> 7; - s.chance ^= s.chance << 17; - design = static_cast((s.chance >> 20) % design_count); - } - s.designs_drawn = true; -} -void clean(State& s) { - s.tally = {}; - s.fall_since.reset(); - s.fall = {}; - s.bails.store(0, std::memory_order_release); - draw_designs(s); - s.dirty = s.afresh = true; -} -std::string levels(const Tally& tally) { - std::string out; - for (const auto part : parts) out += std::format("{}{} {}", out.empty() ? "" : ", ", part_names[index(part)], tally.levels[index(part)]); - return out; -} -void count_bail(State& s, Side side) { - const auto before = s.tally.levels; - count(s.tally, side); - s.bails.store(s.tally.bails, std::memory_order_release); - if (before != s.tally.levels) s.dirty = true; - if (logging::enabled(logging::Level::debug)) - say(logging::Level::debug, "bail {} on {} (limbs hit: left {:.1f}, right {:.1f}): {}.", s.tally.bails, - side == Side::left ? "the left side" : side == Side::right ? "the right side" : "both sides", s.fall.left, s.fall.right, - levels(s.tally)); -} - -// The installed item called `name` at its end; empty when the content mod is not installed. -const std::string& item(State& s, const SkaterItems& m, const std::string& name) { - if (const auto known = s.items.find(name); known != s.items.end()) return known->second; - return s.items.emplace(name, installed_item(m, name)).first->second; -} -// The item a part wears at a level: of its own design or else the first, from its own set or else -// the unmirrored one, at that level or else the nearest lower one that is installed. -std::string wanted_item(State& s, const SkaterItems& m, Part part, int& level) { - for (; level > 0; --level) - for (const auto design : {s.designs[index(part)], std::size_t{0}}) - for (const bool mirrored : {part == Part::right_leg, false}) { - const auto& found = item(s, m, item_name(part, mirrored, design, level, s.blood.load(std::memory_order_acquire))); - if (!found.empty()) return found; - } - return {}; -} -// The recipe slot of each part, by the slot's name in the skater's recipe template. -void find_slots(State& s, const SkaterItems& m, std::uintptr_t resource) { - s.slots.fill(-1); - const auto slots = m.ptr(resource, 0x28); - const auto count = m.count(slots, 24, multiplayer::max_cosmetic_slots); - for (std::size_t i = 0; i < count; ++i) { - const auto slot = m.ptr(slots, i * 24 + 8) & ~std::uintptr_t{4}; - const auto name = lower(m.text(m.ptr(slot, 0x20))); - for (std::size_t part = 0; part < part_count; ++part) - if (s.slots[part] < 0 && name.find(slot_names[part]) != std::string::npos) s.slots[part] = static_cast(i); - } - s.slots_of = resource; -} -// Makes the skater wear the marks of its levels, one item per part in the part's own slot, over -// the outfit it wears. Throws while the skater is still being built: tried again. -void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { - const auto component = m.component(skater); - auto recipe = m.capture(skater); - const auto resource = m.resource(component); - if (s.slots_of != resource) find_slots(s, m, resource); - bool marked{}; - for (const auto& item : recipe.items) marked |= ours(item.asset); - // Whose recipe the skater wears is read from the skater: a respawn hands over what looks like a - // new one and is the same, items and all. - if (const auto fed = feed(m, component); fed.follows_outfit()) { - // The game feeds it its outfit: a new skater, or one given back, which holds our items until - // the game has put the outfit's own back. - SkaterItems::check(!marked || now - s.given_back > 3000, "Waiting for the game to put the outfit back."); - s.own = marked; - s.feed = fed; - } else { - // A recipe of its own: ours when our items are in it or it went to this component. Whose - // else it could be is not ours to change. - SkaterItems::check(s.feed && (marked || component == s.component), "The skater does not follow its outfit: left alone."); - s.own = true; - } - const bool covers = s.covers.load(std::memory_order_acquire); - bool changed{}, missing{}, worn{}; - for (const auto part : parts) { - const auto i = index(part); - if (s.slots[i] < 0 || static_cast(s.slots[i]) >= recipe.items.size()) continue; - auto& slot = recipe.items[static_cast(s.slots[i])]; - int level = s.tally.levels[i]; - const auto wanted = level ? wanted_item(s, m, part, level) : std::string{}; - if (s.tally.levels[i] && wanted.empty()) missing = true; - // The player's own tattoo or moles stay, unless the wounds may cover them. - if (wanted.empty() || (!ours(slot.asset) && !blank(slot.asset) && !covers)) continue; - worn = true; - auto parameters = slot.parameters; - if (part != Part::face && parameters.size() == std::tuple_size_v) { - const auto place = placement(part, level); - for (std::size_t k = 0; k < place.size(); ++k) parameters[k] = std::bit_cast(place[k]); - } - if (slot.asset == wanted && same_parameters(slot.parameters, parameters)) continue; - slot.asset = wanted; - slot.parameters = std::move(parameters); - changed = true; - } - if (missing && !s.missing_told) { - s.missing_told = true; - say(logging::Level::info, "the skater has marks to show, but the Road Rash items are not installed (a content mod holds them)."); - } - s.worn = worn; - if (!changed) return; - apply(m, component, recipe); - s.own = true; - s.component = component; - say(logging::Level::debug, "marks changed after {} bail(s): {}.", s.tally.bails, levels(s.tally)); -} -// Gives the skater back to the game: the outfit's own items return, the player's own tattoos and -// moles with them, and what the player changes in the game's menus reaches the skater again. -// Throws while its items are updating: tried again. -void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now, const char* why) { - const auto component = m.component(skater); - // One the game feeds already only has to put the outfit's own items back. - if (const auto fed = feed(m, component); fed.follows_outfit() || s.feed) { - hand_back(m, component, fed.follows_outfit() ? fed : *s.feed); - s.given_back = now; - say(logging::Level::debug, "the skater is the game's again ({}).", why); - } - s.own = s.worn = false; -} - -// The fall: its side from what the limbs hit, for as long as the body is a ragdoll. -void watch_fall(State& s, const NoBailSkater& skater, std::uint64_t now, bool wiping_out) { - bool ragdoll = wiping_out; - if (hall_of_meat::skater_available()) { - const auto step = hall_of_meat::read_step(skater, 0.0f, false); - add(s.fall, step.body); - ragdoll = step.ragdoll.value_or(wiping_out) || wiping_out; - } - const auto lasted = now - *s.fall_since; - if (lasted < fall_at_least || (ragdoll && lasted < fall_at_most)) return; - count_bail(s, side(s.fall)); - s.fall_since.reset(); - s.fall = {}; -} -} - -bool start(std::uintptr_t base) noexcept { - auto& s = state(); - if (s.ready.load(std::memory_order_acquire)) return true; - // Its official mod not installed, or turned off, in the launcher: no Road Rash this launch, and - // nothing of it shows. - if (const auto mods_root = mods::engine_data_root() / mods::mods_folder; !mods::official_in_use(mods_root, mods::road_rash_official)) { - s.turned_off.store(true, std::memory_order_release); - logging::write(logging::Level::info, logging::Channel::skater, - mods::official_off(mods_root, mods::road_rash_official) ? "Road Rash is off: its official mod is turned off in the launcher." - : "Road Rash is off: its official mod is not installed (launcher, Mods, OFFICIAL)."); - return false; - } - constexpr auto prefix = addr::native_cosmetics::recipe_copy_prefix; - std::array actual{}; - if (!memory::peek(base + addr::native_cosmetics::recipe_copy, actual) || actual != prefix) { - logging::write(logging::Level::warning, logging::Channel::skater, - "Road Rash cannot change the skater's items: the native recipe copy did not match."); - return false; - } - if (!no_bail_available()) { - logging::write(logging::Level::warning, logging::Channel::skater, - "Road Rash cannot follow the skater: its bails come through No Bail's hooks, which did not start."); - return false; - } - s.on.store(profile_runtime::local_preference(enabled_preference).value_or(false), std::memory_order_release); - s.blood.store(profile_runtime::local_preference(blood_preference).value_or(true), std::memory_order_release); - s.covers.store(profile_runtime::local_preference(tattoos_preference).value_or(false), std::memory_order_release); - s.ready.store(true, std::memory_order_release); - logging::log(logging::Level::info, logging::Channel::skater, "Road Rash ready ({}, blood {}).", s.on.load() ? "on" : "off", - s.blood.load() ? "on" : "off"); - return true; -} - -void on_client_tick(std::uintptr_t client) noexcept { - auto& s = state(); - if (!s.ready.load(std::memory_order_acquire)) return; - try { - if (!s.designs_drawn) draw_designs(s); - if (s.heal.exchange(false, std::memory_order_acq_rel)) clean(s); - if (s.changed.exchange(false, std::memory_order_acq_rel)) s.dirty = s.afresh = true; - if (const int wanted = s.wanted_bails.exchange(-1, std::memory_order_acq_rel); wanted >= 0) { - clean(s); - s.tally.bails = static_cast(wanted); - s.tally.left = s.tally.right = static_cast(wanted) / 2; - grow(s.tally); - s.bails.store(s.tally.bails, std::memory_order_release); - } - NoBailSkater skater; - if (!no_bail_skater(skater)) { - // No local skater (a respawn, a level change). After a level change the game builds the - // next one from the profile, with nothing of ours on it; the count stays, so the marks - // go back on. - s.skater = 0; - s.fall_since.reset(); - s.fall = {}; - s.counted = false; - s.slots_of = 0; - s.dirty = true; - return; - } - if (skater.entity != s.skater) { - s.skater = skater.entity; - s.dirty = true; - } - const bool on = s.on.load(std::memory_order_acquire); - if (!on && !s.own) return; // switched off, and the skater is the game's: nothing to do - const auto now = GetTickCount64(); - bool down{}; - if (on) { - watch_physics_state(client, skater.entity); - const auto watch = watched_physics_state(); - if (watch.valid) { - const bool wiping_out = watch.state == addr::no_bail::wipeout_physics_state; - if (!s.counted) { - s.wipeouts = watch.wipeouts; - s.counted = true; - } - // One slam can enter the wipeout more than once. - if (watch.wipeouts != s.wipeouts) { - s.wipeouts = watch.wipeouts; - if (!s.fall_since && now - s.last_bail > 1500) { - s.last_bail = now; - s.fall_since = now; - s.fall = {}; - } - } - if (s.fall_since) watch_fall(s, skater, now, wiping_out); - down = wiping_out && now - s.last_bail < 6000; - } - } else { - s.fall_since.reset(); - } - bool marks{}; - if (on) - for (const int level : s.tally.levels) marks |= level > 0; - // The game's menus change the outfit, and a skater that wears a recipe of ours gets none of - // it. So while a menu is up the skater is the game's, as it is with nothing to show or when - // the marks are worked out again; they go back on, over the outfit as it is by then. - const char* why = !marks ? "nothing to show" : s.afresh ? "the marks are worked out again" : nullptr; - if (!why && multiplayer::sample_game_ui_state(skater.base).in_menu) { - why = "a game menu is up"; - s.menu_seen = now; - } - if (why) { - if (s.own) give_back(s, SkaterItems{readable, skater.base}, skater.entity, now, why); - s.afresh = false; - s.dirty = true; - return; - } - // New marks go on once the skater is up again, and not before the game has had a second for - // what it still does to the skater as a menu closes. While some are on, a look every few - // seconds whether the game has put the outfit's items over them or built the skater anew. - if (down || s.fall_since || now - s.given_back < 300 || now - s.menu_seen < 1000 || - !(s.dirty || (s.worn && now - s.checked > 3000))) - return; - s.checked = now; - wear(s, SkaterItems{readable, skater.base}, skater.entity, now); - s.dirty = false; - } catch (const std::exception& error) { - // The skater is being rebuilt, or the catalog is not ready: tried again at a later tick. - const auto now = GetTickCount64(); - if (now - s.last_error > 10000 && logging::enabled(logging::Level::debug)) { - s.last_error = now; - say(logging::Level::debug, "waiting ({}).", error.what()); - } - } catch (...) { /* Marks are never worth the client tick. */ } -} - -bool available() noexcept { return state().ready.load(std::memory_order_acquire); } -bool turned_off() noexcept { return state().turned_off.load(std::memory_order_acquire); } -bool enabled() noexcept { return available() && state().on.load(std::memory_order_acquire); } -bool blood() noexcept { return state().blood.load(std::memory_order_acquire); } -bool covers_tattoos() noexcept { return state().covers.load(std::memory_order_acquire); } - -void set_enabled(bool enabled) noexcept { - auto& s = state(); - s.on.store(enabled, std::memory_order_release); - // Off: the marks go, and so does the count. - (enabled ? s.changed : s.heal).store(true, std::memory_order_release); - profile_runtime::set_local_preference(enabled_preference, enabled); -} -void set_blood(bool blood) noexcept { - auto& s = state(); - s.blood.store(blood, std::memory_order_release); - s.changed.store(true, std::memory_order_release); - profile_runtime::set_local_preference(blood_preference, blood); -} -void set_covers_tattoos(bool covers) noexcept { - auto& s = state(); - s.covers.store(covers, std::memory_order_release); - s.changed.store(true, std::memory_order_release); - profile_runtime::set_local_preference(tattoos_preference, covers); -} -void heal() noexcept { state().heal.store(true, std::memory_order_release); } -void set_bails(unsigned count) noexcept { state().wanted_bails.store(static_cast((std::min)(count, 1000u)), std::memory_order_release); } -unsigned bails() noexcept { return state().bails.load(std::memory_order_acquire); } -} diff --git a/Extension/RoadRash/road_rash.h b/Extension/RoadRash/road_rash.h deleted file mode 100644 index bbd51c0c..00000000 --- a/Extension/RoadRash/road_rash.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once -#include - -// Road Rash: bails leave marks on the local skater that build up over a session. After ten bails -// the leg and then the arm of the side that took more of them show road dust and a graze, later -// real scrapes and bruises, and from twenty raw wounds on knee, shin, elbow, forearm and knuckles -// and a bruised face; the other side follows a few bails behind. A mark never gets smaller until -// the skater is healed (the console's `roadrash heal`) or the game is closed. -// -// The marks are not drawn by ReSkate: they are cosmetic items of a content mod, tattoo items for -// the limbs and moles items for the face (road_rash_model.h has their names), which this puts into -// the skater's recipe while it plays, through the game's own recipe copy (skater_items.h). Without -// that mod installed nothing ever shows. A slot that holds one of the player's own tattoos or moles -// keeps it, unless the player lets the wounds cover it; what was there comes back with healing. -// Nothing is saved to the profile's outfit: the game builds the skater from the profile again at -// every level change, and the marks are put back on from the session's count. -// -// The game feeds a skater's items either from the player's outfit or from a recipe of the skater's -// own, never both: one that wears a recipe handed to it gets nothing of what the player changes in -// the game's menus. So the marks are on only while the game is played. While a game menu is up, -// with nothing to show, on healing and when switched off, the skater is given back to the game, -// which puts the outfit's own items back and feeds it again (skater_items.h); the marks go back on, -// over the outfit as it is by then, a second after the menu has gone. -// -// Which side a bail hit comes from the ragdoll's own contacts, as Hall of Meat reads them -// (Extension/HallOfMeat/hall_of_meat_skater.h); a bail is the physics state selector choosing -// Wipeout (Extension/Skater/no_bail.h). Nothing is hooked for it. -namespace dingosdk::road_rash { -// Requires the validated build, No Bail started and the local profile loaded: the switches start -// from the saved choices (on, with blood, tattoos kept, until a player changes them). -bool start(std::uintptr_t image_base) noexcept; -// Client thread, every tick, with the local client. -void on_client_tick(std::uintptr_t client) noexcept; - -// The switches, any thread; a change applies at the next tick and is saved with the profile. -bool available() noexcept; -// Its official mod is turned off in the launcher (Engine/Vfs/builtin_packs.h): it did not start. -bool turned_off() noexcept; -bool enabled() noexcept; // off: the marks come off and no bail is counted -bool blood() noexcept; // off: the worst wounds are dry, with scabs -bool covers_tattoos() noexcept; // on: wounds take the place of the player's own tattoos and moles -void set_enabled(bool enabled) noexcept; -void set_blood(bool blood) noexcept; -void set_covers_tattoos(bool covers) noexcept; -// Any thread, done at the next tick: takes every mark off and starts counting again. -void heal() noexcept; -// Any thread, done at the next tick: counts as if the skater had bailed this often, half of it on -// each side, to look at a stage without slamming for it. -void set_bails(unsigned count) noexcept; -// How many bails are counted now. -unsigned bails() noexcept; -} diff --git a/Extension/RoadRash/road_rash_commands.cpp b/Extension/RoadRash/road_rash_commands.cpp deleted file mode 100644 index 5485791a..00000000 --- a/Extension/RoadRash/road_rash_commands.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "road_rash.h" -#include "Extension/Console/commands.h" - -// Road Rash's console switches; the menu's (Mod Options > Player) send them too. -namespace dingosdk::console { -void register_road_rash_commands(Commands ®istry) { - const auto unavailable = "Road Rash is unavailable for this game build."; - auto rash = variable("roadrash", "Bails leave marks on your skater that build up: dust, scrapes, bruises, wounds " - "(needs the Road Rash item pack)", - Group::gameplay, argument("0|1", Type::boolean)); - rash.inspect = [unavailable](const Model &) { - return boolean_state(road_rash::available(), road_rash::enabled(), unavailable, - std::to_string(road_rash::bails()) + " bail(s) counted"); - }; - rash.run = [](const Model &, const Values &args, const Output &) { road_rash::set_enabled(std::get(args[0])); }; - registry.add(std::move(rash)); - - auto blood = variable("roadrashblood", "Fresh blood on Road Rash's worst wounds; off, they are dry and scabbed", - Group::gameplay, argument("0|1", Type::boolean)); - blood.inspect = [unavailable](const Model &) { return boolean_state(road_rash::available(), road_rash::blood(), unavailable); }; - blood.run = [](const Model &, const Values &args, const Output &) { road_rash::set_blood(std::get(args[0])); }; - registry.add(std::move(blood)); - - auto tattoos = variable("roadrashtattoos", "Road Rash's wounds take the place of your own tattoos and moles until you heal; " - "off, a limb that wears a tattoo gets no wounds", - Group::gameplay, argument("0|1", Type::boolean)); - tattoos.inspect = [unavailable](const Model &) { - return boolean_state(road_rash::available(), road_rash::covers_tattoos(), unavailable); - }; - tattoos.run = [](const Model &, const Values &args, const Output &) { road_rash::set_covers_tattoos(std::get(args[0])); }; - registry.add(std::move(tattoos)); - - auto heal = action("roadrash heal", "Take Road Rash's marks off and start counting bails again", Group::gameplay); - heal.run = [](const Model &, const Values &, const Output &out) { - road_rash::heal(); - out("Healed."); - }; - registry.add(std::move(heal)); - - auto bails = action("roadrash bails", "Count as if you had bailed this often, to look at a stage (10, 14 and 20 are the steps)", - Group::gameplay, {argument("count", Type::unsigned_integer)}); - bails.run = [](const Model &, const Values &args, const Output &out) { - const auto count = std::get(args[0]); - road_rash::set_bails(static_cast(count > 1000 ? 1000 : count)); - out("Counted."); - }; - registry.add(std::move(bails)); -} -} // namespace dingosdk::console diff --git a/Extension/RoadRash/road_rash_model.h b/Extension/RoadRash/road_rash_model.h deleted file mode 100644 index 44a41b72..00000000 --- a/Extension/RoadRash/road_rash_model.h +++ /dev/null @@ -1,166 +0,0 @@ -#pragma once -#include "Engine/Game/Skater/skater_body.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Road Rash's rules, apart from the game: which side of the body a bail hit, how many bails it -// takes for a limb's marks to show and to get worse, what the items that draw them are called and -// where on a limb they sit. Plain data and pure functions (Test/road_rash_tests.cpp). -namespace dingosdk::road_rash { -// Where marks can sit: the skater has one tattoo layer per arm and leg, and one moles layer on the face. -enum class Part : std::uint8_t { left_leg, left_arm, right_leg, right_arm, face }; -inline constexpr std::size_t part_count = 5; -inline constexpr std::array parts{Part::left_leg, Part::left_arm, Part::right_leg, Part::right_arm, Part::face}; -inline constexpr std::array part_names{"left leg", "left arm", "right leg", "right arm", "face"}; -constexpr std::size_t index(Part part) noexcept { return static_cast(part); } -constexpr bool leg(Part part) noexcept { return part == Part::left_leg || part == Part::right_leg; } -constexpr bool left(Part part) noexcept { return part == Part::left_leg || part == Part::left_arm; } - -// A limb's marks have three levels, the face's two. -inline constexpr int worst_limb = 3, worst_face = 2; -constexpr int worst(Part part) noexcept { return part == Part::face ? worst_face : worst_limb; } -// The bails it takes for the first marks, the bigger ones and the worst ones. -inline constexpr std::array steps{10, 14, 20}; -// The face follows the worst level of the limbs, and gets worse four bails later. -inline constexpr unsigned face_later = 4; -// How many bails after the leg of the side that took more falls a limb follows: that side's arm, -// then the other side's leg and arm. -inline constexpr unsigned arm_later = 1, other_side_later = 4; - -// ---- a fall ------------------------------------------------------------------------------- - -enum class Side : std::uint8_t { both, left, right }; -// How hard the limbs of each side hit something in a fall, summed over its physics steps: the speed -// into the surface and the speed along it, metres per second. -struct Fall { - float left{}, right{}; -}; -// The side a body is on; feet and toes (they touch the ground all the time), the trunk and the -// head count for neither. -constexpr Side side_of(skater_body::Bone bone) noexcept { - using skater_body::Bone; - switch (bone) { - case Bone::left_hand: case Bone::left_forearm: case Bone::left_arm: case Bone::left_shoulder: - case Bone::left_leg: case Bone::left_upleg: return Side::left; - case Bone::right_hand: case Bone::right_forearm: case Bone::right_arm: case Bone::right_shoulder: - case Bone::right_leg: case Bone::right_upleg: return Side::right; - default: return Side::both; - } -} -// Adds one physics step's contacts. A body that only touches the skater's own board hit nothing. -inline void add(Fall& fall, const skater_body::Contacts& contacts) noexcept { - for (std::size_t i = 0; i < skater_body::count; ++i) { - const auto& body = contacts.bodies[i]; - const auto side = side_of(static_cast(i)); - if (side == Side::both || !body.touching || (body.hit.board && !body.hit.world && !body.hit.vehicle)) continue; - const float hit = body.impact + game::length(body.slide); - (side == Side::left ? fall.left : fall.right) += hit; - } -} -// The side that took the fall: the one whose limbs took three fifths of it or more. A fall the -// limbs hardly felt, or felt alike, is on both. -inline Side side(const Fall& fall) noexcept { - const float total = fall.left + fall.right; - if (!(total >= 2.0f)) return Side::both; - const float share = fall.left / total; - return share >= 0.6f ? Side::left : share <= 0.4f ? Side::right : Side::both; -} - -// ---- the session -------------------------------------------------------------------------- - -// The level `bails` give a part, when the left (or the right) side took more of them. -constexpr int level_for(Part part, unsigned bails, bool left_first) noexcept { - if (part == Part::face) return bails >= steps[2] + face_later ? 2 : bails >= steps[2] ? 1 : 0; - const unsigned later = (left(part) == left_first ? 0 : other_side_later) + (leg(part) ? 0 : arm_later); - if (bails < later) return 0; - const unsigned count = bails - later; - return count >= steps[2] ? 3 : count >= steps[1] ? 2 : count >= steps[0] ? 1 : 0; -} -// Bails counted since the skater was last clean, and the marks they left. A mark never gets -// smaller: only healing takes it off. -struct Tally { - unsigned bails{}; - float left{}, right{}; // falls on each side; one on both counts half for each - bool last_left{}; // the side of the last one-sided fall: it breaks a tie - std::array levels{}; -}; -constexpr bool left_first(const Tally& tally) noexcept { - return tally.left > tally.right || (tally.left == tally.right && tally.last_left); -} -constexpr void grow(Tally& tally) noexcept { - const bool first = left_first(tally); - for (const auto part : parts) - tally.levels[index(part)] = (std::max)(tally.levels[index(part)], level_for(part, tally.bails, first)); -} -constexpr void count(Tally& tally, Side side) noexcept { - ++tally.bails; - if (side == Side::both) { - tally.left += 0.5f; - tally.right += 0.5f; - } else { - (side == Side::left ? tally.left : tally.right) += 1.0f; - tally.last_left = side == Side::left; - } - grow(tally); -} - -// ---- the items ---------------------------------------------------------------------------- - -// The marks are cosmetic items of a content mod: tattoo items for the limbs, moles items for the -// face, found by the end of their names: RoadRash__[_][_Dry|_Blood]. -// Set Leg, Arm, Face; LegR is the left leg's set mirrored, for the right leg: a decal sits -// on a limb like a sticker read from the front, so only a mirrored one puts the marks -// of the left leg's outer side on the right leg's outer side. -// design nothing for the first, B_ and C_ for the others: each part wears one design for as -// long as its marks last. -// level 1 .. the part's worst; the worst comes dry (scabs) and with fresh blood. -inline constexpr std::string_view item_prefix = "roadrash_"; -inline constexpr std::size_t design_count = 3; -inline constexpr std::array design_letters{"", "b_", "c_"}; -// In lower case, as names are compared. `mirrored` asks for the right leg's own set. -inline std::string item_name(Part part, bool mirrored, std::size_t design, int level, bool blood) { - std::string name(item_prefix); - name += part == Part::face ? "face_" : !leg(part) ? "arm_" : mirrored ? "legr_" : "leg_"; - name += design_letters[design < design_count ? design : 0]; - name += std::to_string(level); - if (level >= worst(part)) name += blood ? "_blood" : "_dry"; - return name; -} - -// ---- where a limb's decal goes ------------------------------------------------------------ - -// A tattoo slot places its item with five numbers: x across and y up the limb's piece of the -// body's tattoo layout (0..1), its size, its turn (0.5 is upright) and one unused. -using Placement = std::array; -// The middle of the kneecap or the point of the elbow on a left limb (the right one mirrors x), -// how much y changes per centimetre down the limb, and how many centimetres a size of 1 covers. -struct Limb { - float x, y, y_per_cm, cm_per_size; -}; -inline constexpr Limb leg_spot{0.37f, 0.59f, 0.01073f, 76.0f}, arm_spot{0.635f, 0.505f, 0.01192f, 51.0f}; -// A level's picture covers `side` centimetres with its centre `down` centimetres below that point. -struct Cut { - float side, down; -}; -inline constexpr std::array leg_cuts{{{20, 0}, {27, 2}, {38, 7}}}, arm_cuts{{{16, 0}, {24, 3}, {46, 16}}}; -constexpr Placement placement(Part part, int level) noexcept { - const auto& spot = leg(part) ? leg_spot : arm_spot; - const auto& cut = (leg(part) ? leg_cuts : arm_cuts)[static_cast(std::clamp(level, 1, worst_limb) - 1)]; - return {left(part) ? spot.x : 1.0f - spot.x, spot.y - cut.down * spot.y_per_cm, cut.side / spot.cm_per_size, 0.5f, 0.5f}; -} -// A slot's parameters are words that hold floats. The game works them out again when it updates the -// skater: what it hands back is as good as what it was given, but not equal to the last bit. -inline bool same_parameters(const std::vector& a, const std::vector& b) noexcept { - if (a.size() != b.size()) return false; - for (std::size_t i = 0; i < a.size(); ++i) - if (a[i] != b[i] && !(std::abs(std::bit_cast(a[i]) - std::bit_cast(b[i])) < 0.002f)) return false; - return true; -} -} diff --git a/Extension/Skater/Test/drone_flight_tests.cpp b/Extension/Skater/Test/drone_flight_tests.cpp deleted file mode 100644 index 3206d353..00000000 --- a/Extension/Skater/Test/drone_flight_tests.cpp +++ /dev/null @@ -1,483 +0,0 @@ -// The FPV drone's flight model (drone_flight.h): hover and height hold, thrust and top speed, -// motor and rate lag, angle mode, crashes against a wall and landing on a floor, and a gamepad's -// sticks. Flown in a world of one floor (y = 0) and one wall (x = 20). -#include "Extension/Skater/drone_beacon.h" -#include "Extension/Skater/drone_flight.h" -#include "Extension/Skater/drone_follow.h" -#include -using namespace dingosdk::drone; -using Vec3 = std::array; -static int fails = 0; -#define CHECK(c, msg) do { if (!(c)) { std::printf("FAIL: %s\n", msg); ++fails; } } while (0) - -namespace { -constexpr float tick = 1.0f / 60.0f; -// The first point on the segment that lies on the floor or the wall. -bool cast(const Vec3& from, const Vec3& to, Vec3& hit) { - float best = 2.0f; - const auto plane = [&](int axis, float at) { - const float a = from[axis] - at, b = to[axis] - at; - if ((a > 0) == (b > 0) || a == b) return; - const float t = a / (a - b); - if (t >= 0 && t <= 1 && t < best) best = t; - }; - plane(1, 0.0f); - plane(0, 20.0f); - if (best > 1.0f) return false; - for (int i = 0; i < 3; ++i) hit[i] = from[i] + (to[i] - from[i]) * best; - return true; -} -const World world{cast}; -std::array at(float x, float y, float z) { return {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1}; } -Settings settings() { - Settings s; - s.hover_throttle = 0.22f; - s.armed = armed(); - return s; -} -// Flies `seconds` of ticks, the command the same throughout. -void fly(std::array& camera, State& state, const Cmd& cmd, float seconds, Settings s, const World* w = &world, float speed = 10.0f) { - for (float t = 0; t < seconds; t += tick) { - s.armed = armed(); - camera = step_drone_cmd(camera, state, cmd, tick, speed, s, w); - } -} -Vec3 position(const std::array& m) { return {m[12], m[13], m[14]}; } -float speed_of(const State& s) { return std::sqrt(s.velocity[0] * s.velocity[0] + s.velocity[1] * s.velocity[1] + s.velocity[2] * s.velocity[2]); } -float up_y(const State& s) { return s.body[5]; } -Cmd holding(float climb = 0.0f) { Cmd c; c.hold = true; c.climb = climb; return c; } -} - -int main() { - // Thrust: a 22% hover is about ten times the drone's weight. - { - const auto s = settings(); - const float ratio = detail::max_thrust(s) / s.gravity; - CHECK(ratio > 9.0f && ratio < 10.5f, "a 22% hover gives about a 10:1 thrust to weight"); - CHECK(std::abs(detail::output_for(s.gravity, s) - 0.22f) < 1e-3f, "the hover output holds the drone up"); - } - // Height hold: let go and it stays where it is, upright or leaning. - { - set_armed(true); - auto camera = at(0, 5, 0); - State state; - fly(camera, state, holding(), 5.0f, settings()); - CHECK(std::abs(position(camera)[1] - 5.0f) < 0.15f && speed_of(state) < 0.2f, "height hold hovers in place"); - auto s = settings(); - s.angle_mode = true; - Cmd lean = holding(); - lean.roll = -0.6f; // (away from the wall) - fly(camera, state, lean, 3.0f, s); - CHECK(std::abs(position(camera)[1] - 5.0f) < 0.6f, "height hold keeps the height while leaning"); - CHECK(std::abs(position(camera)[0]) > 3.0f, "leaning moves the drone sideways"); - fly(camera, state, holding(1.0f), 1.0f, s); - CHECK(state.velocity[1] > 4.0f, "full stick up climbs at about the climb rate"); - CHECK(state.motor > 0.95f, "height hold never holds full stick back from full throttle"); - fly(camera, state, holding(-1.0f), 0.3f, s); - CHECK(state.motor < 0.05f, "full stick down is no throttle, height hold or not"); - } - // Motor and rate lag: neither jumps to what was asked. - { - set_armed(true); - auto camera = at(0, 50, 0); - State state; - Cmd hover; - hover.throttle = 0.22f; - fly(camera, state, hover, 0.5f, settings(), nullptr); - Cmd full; - full.throttle = 1.0f; - full.roll = 1.0f; - fly(camera, state, full, tick, settings(), nullptr); - CHECK(state.motor < 0.6f, "the motors take time to spool up"); - const float want = detail::actual_rate(1.0f, Settings{}.roll) * detail::kDegToRad; - CHECK(state.rate[2] > 0.0f && state.rate[2] < want * 0.6f, "the roll rate takes time to build"); - fly(camera, state, full, 0.3f, settings(), nullptr); - CHECK(state.motor > 0.95f, "the motors reach full output"); - CHECK(std::abs(state.rate[2] - want) < want * 0.05f, "the roll rate reaches the stick's rate"); - } - // Top speed: flat out, drag holds it near the speed setting's (four times it, in m/s). - { - set_armed(true); - auto camera = at(0, 500, 0); - State state; - auto s = settings(); - s.angle_mode = true; - s.max_angle_deg = 75.0f; - Cmd flat_out; - flat_out.throttle = 1.0f; - flat_out.pitch = -1.0f; - fly(camera, state, flat_out, 15.0f, s, nullptr, 10.0f); - const float top = speed_of(state); - std::printf("Flat out at a speed setting of 10: %.1f m/s\n", top); - CHECK(top > 26.0f && top < 46.0f, "flat out settles near the top speed"); - } - // Angle mode: let go of the sticks and it levels out. - { - set_armed(true); - auto camera = at(0, 50, 0); - State state; - auto s = settings(); - s.angle_mode = true; - Cmd lean = holding(); - lean.pitch = 1.0f; - lean.roll = -1.0f; - fly(camera, state, lean, 1.0f, s); - CHECK(up_y(state) < 0.85f, "angle mode leans with the sticks"); - fly(camera, state, holding(), 1.0f, s); - CHECK(up_y(state) > 0.99f, "angle mode levels out when the sticks are let go"); - } - // A hard hit: it bounces off, is knocked about, and the motors cut until it is armed again. - { - set_armed(true); - auto camera = at(15, 5, 0); - State state; - fly(camera, state, holding(), tick, settings()); - state.velocity = {15.0f, 0.0f, 0.0f}; - Cmd ahead = holding(); - for (int i = 0; i < 60 && !state.crashed; ++i) fly(camera, state, ahead, tick, settings()); - CHECK(state.crashed && !armed(), "a hard hit cuts the motors"); - CHECK(state.velocity[0] < 0.0f, "a hard hit bounces off"); - CHECK(std::abs(state.rate[0]) + std::abs(state.rate[1]) + std::abs(state.rate[2]) > 1.0f, "a hard hit knocks the drone about"); - CHECK(position(camera)[0] < 20.0f, "it stays on its side of the wall"); - CHECK(telemetry().crashed.load() && telemetry().glitch.load() > 0.2f, "the crash shows and breaks up the picture"); - fly(camera, state, holding(), 4.0f, settings()); - CHECK(position(camera)[1] < 0.5f && position(camera)[1] >= 0.0f, "a crashed drone falls to the floor and stays on it"); - set_armed(true); - fly(camera, state, holding(), tick, settings()); - CHECK(!state.crashed, "arming again puts it right"); - } - // Crashes off: it just stops against the wall. - { - set_armed(true); - auto camera = at(15, 5, 0); - State state; - auto s = settings(); - s.crash = false; - fly(camera, state, holding(), tick, s); - state.velocity = {15.0f, 0.0f, 0.0f}; - fly(camera, state, holding(), 1.0f, s); - CHECK(!state.crashed && armed() && std::abs(state.velocity[0]) < 0.5f, "with crashes off it stops against the wall"); - } - // A gentle landing is not a crash. - { - set_armed(true); - auto camera = at(0, 3, 0); - State state; - fly(camera, state, holding(-0.25f), 4.0f, settings()); - CHECK(!state.crashed && armed(), "a gentle landing is not a crash"); - CHECK(position(camera)[1] >= 0.0f && position(camera)[1] < 0.5f, "it lands on the floor"); - } - // A gamepad: a drifting stick reads nothing, the throttle stick springs back to a hold, and the - // sensitivity scales full stick. - { - dingosdk::overlay::Gamepad pad; - pad.valid = true; - pad.left_x = 0.05f; - pad.right_y = -0.06f; - auto sticks = gamepad_sticks(pad, 0.03f, 0.6f, 0.45f, 0.22f); - CHECK(sticks.valid && sticks.centred && sticks.yaw == 0.0f && sticks.pitch == 0.0f && sticks.climb == 0.0f, - "a drifting gamepad stick reads nothing"); - CHECK(std::abs(sticks.throttle - 0.22f) < 1e-4f, "a gamepad's throttle at rest hovers"); - pad.right_x = 1.0f; - pad.right_y = 0.0f; - pad.left_y = 1.0f; - sticks = gamepad_sticks(pad, 0.03f, 0.6f, 0.45f, 0.22f); - CHECK(std::abs(sticks.roll - 0.6f) < 1e-4f && sticks.climb > 0.99f && sticks.throttle > 0.99f, "full gamepad sticks"); - pad.right_x = 0.3f; - const auto soft = gamepad_sticks(pad, 0.03f, 1.0f, 0.45f, 0.22f).roll; - const auto plain = gamepad_sticks(pad, 0.03f, 1.0f, 0.0f, 0.22f).roll; - CHECK(soft < plain * 0.8f, "expo softens the middle of a gamepad stick"); - pad.right_x = 0.0f; - pad.right_y = 1.0f; - CHECK(gamepad_sticks(pad, 0.03f, 1.0f, 0.0f, 0.22f).pitch > 0.99f && gamepad_sticks(pad, 0.03f, 1.0f, 0.0f, 0.22f, true).pitch < -0.99f, - "the right stick's pitch inverts"); - pad.right_x = 1.0f; - pad.right_y = 0.0f; - CHECK(gamepad_sticks(pad, 0.03f, 0.6f, 0.45f, 0.22f, false, true).roll > 0.99f && - gamepad_sticks(pad, 0.03f, 0.6f, 0.45f, 0.22f, false, true).yaw == gamepad_sticks(pad, 0.03f, 0.6f, 0.45f, 0.22f).yaw, - "in angle mode full stick is the full lean, and the sensitivity still slows turning"); - // Flown: let go of the throttle stick and the drone holds its height. - set_armed(true); - auto camera = at(0, 5, 0); - State state; - pad = {}; - pad.valid = true; - for (float t = 0; t < 3.0f; t += tick) { - auto s = settings(); - camera = step_drone_radio(camera, state, gamepad_sticks(pad, 0.1f, 0.6f, 0.45f, 0.22f), tick, 10.0f, s, &world); - } - CHECK(std::abs(position(camera)[1] - 5.0f) < 0.15f, "a gamepad at rest holds the height"); - // Height hold off: the throttle is the stick's alone, whatever the lean. - { - auto s = settings(); - s.angle_mode = true; - s.alt_hold = false; - State flat, leaning; - auto a = at(0, 50, 0), b = at(0, 50, 0); - dingosdk::overlay::Gamepad rest, lean; - rest.valid = lean.valid = true; - lean.right_x = -1.0f; - for (float t = 0; t < 1.0f; t += tick) { - a = step_drone_radio(a, flat, gamepad_sticks(rest, 0.1f, 0.6f, 0.45f, 0.22f, false, true), tick, 10.0f, s, &world); - b = step_drone_radio(b, leaning, gamepad_sticks(lean, 0.1f, 0.6f, 0.45f, 0.22f, false, true), tick, 10.0f, s, &world); - } - CHECK(std::abs(flat.motor - 0.22f) < 0.01f && std::abs(leaning.motor - flat.motor) < 0.01f, - "without height hold the throttle does not change with the lean"); - } - } - // Another player's drone, heard: the throttle it seems to be on, from how it moves. - { - namespace beacon = dingosdk::drone::beacon; - const float g = beacon::assumed_gravity, hover = beacon::assumed_hover; - CHECK(std::abs(beacon::estimate_throttle({0, 0, 0}, beacon::no_rotation) - hover) < 1e-4f, "a hovering drone is on its hover throttle"); - CHECK(beacon::estimate_throttle({0, -g, 0}, beacon::no_rotation) < 1e-4f, "a falling drone is off the throttle"); - CHECK(std::abs(beacon::estimate_throttle({0, g, 0}, beacon::no_rotation) - hover * std::pow(2.0f, 2.0f / 3.0f)) < 1e-3f, - "climbing at 1 g takes twice the hover thrust"); - // Leant 45 degrees and holding its height: the thrust is 1/cos(45) of its weight. - const float half = 0.3926991f; - const beacon::Rotation leant{std::sin(half), 0, 0, std::cos(half)}; - const auto up = beacon::basis_from_rotation(leant)[1]; - const beacon::Position level{up[0] / up[1] * g, 0, up[2] / up[1] * g}; - CHECK(std::abs(beacon::estimate_throttle(level, leant) - hover * std::pow(1.0f / std::cos(2 * half), 2.0f / 3.0f)) < 1e-3f, - "a leant drone holding its height is on more than its hover throttle"); - // Through packets: still for a while, then climbing at 1 g. - const auto start = beacon::Clock::now(); - const auto at_time = [&](int n) { return start + std::chrono::milliseconds(66 * n); }; - int n = 0; - for (; n < 10; ++n) beacon::receive(7, "pilot", {0, 5, 0}, beacon::no_rotation, true, at_time(n)); - auto heard = beacon::snapshot(at_time(n - 1)); - CHECK(heard.size() == 1 && std::abs(heard[0].throttle - hover) < 0.01f && heard[0].speed < 0.01f, "a drone keeping still sounds like a hover"); - for (int k = 0; k < 15; ++k, ++n) { - const float t = 0.066f * k; - beacon::receive(7, "pilot", {0, 5 + 0.5f * g * t * t, 0}, beacon::no_rotation, true, at_time(n)); - } - heard = beacon::snapshot(at_time(n - 1)); - CHECK(heard.size() == 1 && heard[0].throttle > 0.5f && heard[0].speed > 5.0f, "a drone climbing hard sounds busier and faster"); - beacon::forget(7); - } - // Follow mode (drone_follow.h): the autopilot flies the real model behind a skater. - { - namespace follow = dingosdk::drone::follow; - using dingosdk::drone::detail::Vec3; - const follow::Tuning tuning; - const auto cfg = follow::flight_settings(settings(), tuning); - Vec3 skater{0, 1, 0}; - Vec3 heading{0, 0, -1}; - auto camera = follow::pose_facing(follow::spot(skater, {0, 0, 0}, heading, tuning, 0.0f), skater); - State drone; - follow::Pilot pilot; - float worst_frame = 0.0f; // the skater furthest off the middle of the shot, degrees - float worst_up = 0.0f, worst_down = 0.0f; // the skater above / below the middle of the shot, degrees - float lowest = 1e9f, highest = -1e9f; // the drone's height over the skater's ground - float nose_up = 0.0f, nose_down = 0.0f; // the drone's steepest lean back / forward, degrees - float settle = 1.5f; // seconds of each run not measured (only the take-off) - const auto run = [&](float seconds, float speed, float turn_rate, bool ollies = false) { - for (float t = 0; t < seconds; t += tick) { - heading = dingosdk::drone::detail::rotate(heading, {0, 1, 0}, turn_rate * tick); - skater = dingosdk::drone::detail::add(skater, dingosdk::drone::detail::scale(heading, speed * tick)); - // An ollie every 1.5 s: up 0.8 m and down again over 0.6 s. - const float phase = std::fmod(t, 1.5f); - skater[1] = 1.0f + (ollies && phase < 0.6f ? 0.8f * std::sin(phase / 0.6f * 3.14159f) : 0.0f); - const Vec3 at{camera[12], camera[13], camera[14]}; - const auto cmd = follow::fly(pilot, drone, at, skater, heading, tick, cfg, tuning); - camera = step_drone_cmd(camera, drone, cmd, tick, 10.0f, cfg); - if (t > settle) { - Vec3 look = dingosdk::drone::detail::scale(dingosdk::drone::detail::row(camera, 2), -1.0f); - Vec3 to{skater[0] - camera[12], skater[1] - camera[13], skater[2] - camera[14]}; - look[1] = to[1] = 0.0f; - const float c = dingosdk::drone::detail::dot(dingosdk::drone::detail::normalize(look), dingosdk::drone::detail::normalize(to)); - worst_frame = std::max(worst_frame, std::acos(std::clamp(c, -1.0f, 1.0f)) * 57.2958f); - const Vec3 view = dingosdk::drone::detail::normalize(dingosdk::drone::detail::scale(dingosdk::drone::detail::row(camera, 2), -1.0f)); - const Vec3 body = dingosdk::drone::detail::normalize(Vec3{skater[0] - camera[12], skater[1] + 0.9f - camera[13], skater[2] - camera[14]}); - const float above = (std::asin(body[1]) - std::asin(view[1])) * 57.2958f; - worst_up = std::max(worst_up, above); - worst_down = std::max(worst_down, -above); - const float nose = std::asin(std::clamp(-dingosdk::drone::detail::row(drone.body, 2)[1], -1.0f, 1.0f)) * 57.2958f; - nose_up = std::max(nose_up, nose); - nose_down = std::max(nose_down, -nose); - lowest = std::min(lowest, camera[13] - 1.0f); - highest = std::max(highest, camera[13] - 1.0f); - } - } - }; - const auto behind = [&] { - const Vec3 d{camera[12] - skater[0], camera[13] - skater[1], camera[14] - skater[2]}; - return std::array{-dingosdk::drone::detail::dot(d, heading), d[1]}; - }; - run(6.0f, 8.0f, 0.0f); // rolling straight at 8 m/s - settle = 0.0f; // from here on every moment counts - std::printf("Follow: straight, worst framing %.1f deg [nose %.0f up %.0f down]\n", worst_frame, nose_up, nose_down); - worst_frame = 0.0f; - auto b = behind(); - CHECK(b[0] > 2.0f && b[0] < 9.0f && b[1] > 0.5f && b[1] < 4.0f, "follow: behind and above a skater rolling straight"); - CHECK(drone.velocity[2] < -5.0f, "follow: keeps up with the skater"); - run(2.0f, 8.0f, 0.785f); // a 90 degree turn over two seconds - run(4.0f, 8.0f, 0.0f); - std::printf("Follow: turn, worst framing %.1f deg [nose %.0f up %.0f down]\n", worst_frame, nose_up, nose_down); - b = behind(); - CHECK(b[0] > 2.0f && b[0] < 9.0f, "follow: swings round behind after a turn"); - CHECK(worst_frame < 45.0f, "follow: keeps the skater in shot"); - worst_frame = 0.0f; - std::printf("Follow: so far the skater sat up to %.1f deg above / %.1f below the middle [nose %.0f up %.0f down]\n", worst_up, worst_down, nose_up, nose_down); - worst_frame = worst_up = worst_down = nose_up = nose_down = 0.0f; - // Stops hard (a powerslide: 8 m/s to nothing in a second), then stands. - for (int i = 1; i <= 10; ++i) run(0.1f, 8.0f - 0.8f * static_cast(i), 0.0f); - run(5.0f, 0.0f, 0.0f); - std::printf("Follow: stop, worst framing %.1f deg, %.1f up / %.1f down [nose %.0f up %.0f down]\n", worst_frame, worst_up, worst_down, nose_up, nose_down); - CHECK(worst_frame < 40.0f, "follow: keeps the skater in shot when they stop hard"); - b = behind(); - const float settled = std::abs(b[0] - tuning.distance) + std::abs(b[1] - tuning.height); - CHECK(settled < 1.5f && dingosdk::drone::detail::length(drone.velocity) < 1.0f, "follow: settles behind a skater who stops"); - std::printf("Follow: settled %.2f m off [nose %.0f up %.0f down]\n", settled, nose_up, nose_down); - // Ollies while rolling: the drone should not hop with every one. - run(3.0f, 6.0f, 0.0f); - worst_frame = worst_up = worst_down = nose_up = nose_down = 0.0f; - lowest = 1e9f; highest = -1e9f; - run(6.0f, 6.0f, 0.0f, true); - std::printf("Follow: ollies, height %.2f..%.2f m, %.1f up / %.1f down [nose %.0f up %.0f down]\n", lowest, highest, worst_up, worst_down, nose_up, nose_down); - CHECK(highest - lowest < 0.6f, "follow: does not hop with every ollie"); - // Falls behind (the skater pushes off hard), catches up and settles: no big nose-up brake. - worst_frame = worst_up = worst_down = nose_up = nose_down = 0.0f; - for (int i = 0; i < 10; ++i) run(0.15f, 2.0f + 1.0f * static_cast(i), 0.0f); - run(6.0f, 12.0f, 0.0f); - run(2.0f, 6.0f, 0.0f); // and eases off - std::printf("Follow: catch-up, worst framing %.1f deg, %.1f up / %.1f down [nose %.0f up %.0f down]\n", worst_frame, worst_up, worst_down, nose_up, nose_down); - CHECK(worst_up < 25.0f && worst_down < 25.0f, "follow: keeps the skater in shot while catching up"); - } - // Follow on a halfpipe: the skater rides wall to wall (across x, up to 4 m up each wall). The - // drone should stop chasing the turns and hold a spot off to the side to film it. - { - namespace follow = dingosdk::drone::follow; - namespace d = dingosdk::drone::detail; - const follow::Tuning tuning; - const auto cfg = follow::flight_settings(settings(), tuning); - Vec3 skater{0, 1, 0}; - auto camera = follow::pose_facing(follow::spot(skater, {0, 0, 0}, {1, 0, 0}, tuning, 0.0f), skater); - State drone; - follow::Pilot pilot; - float worst_side = 0.0f, worst_up = 0.0f, travel = 0.0f, lowest = 1e9f; - Vec3 last{}; - for (float t = 0; t < 20.0f; t += tick) { - const float phase = std::sin(t * 3.14159f / 2.0f); // wall to wall every two seconds - skater = {6.0f * phase, 1.0f + 4.0f * std::max(std::abs(phase) - 0.6f, 0.0f) / 0.4f, 0.0f}; - const Vec3 at{camera[12], camera[13], camera[14]}; - const auto cmd = follow::fly(pilot, drone, at, skater, {std::cos(t * 3.14159f / 2.0f) >= 0 ? 1.0f : -1.0f, 0, 0}, tick, cfg, tuning, &world); - camera = step_drone_cmd(camera, drone, cmd, tick, 10.0f, cfg, &world); - lowest = std::min(lowest, camera[13]); - if (t > 12.0f) { - const Vec3 now{camera[12], camera[13], camera[14]}; - if (t > 12.0f + tick) travel += d::length(d::sub(now, last)); - last = now; - Vec3 look = d::scale(d::row(camera, 2), -1.0f); - const Vec3 to = d::sub(Vec3{skater[0], skater[1] + 0.9f, skater[2]}, now); - Vec3 flat_look{look[0], 0, look[2]}, flat_to{to[0], 0, to[2]}; - worst_side = std::max(worst_side, std::acos(std::clamp(d::dot(d::normalize(flat_look), d::normalize(flat_to)), -1.0f, 1.0f)) * 57.2958f); - worst_up = std::max(worst_up, std::abs(std::asin(d::normalize(to)[1]) - std::asin(d::normalize(look)[1])) * 57.2958f); - } - } - std::printf("Follow: halfpipe, session %d, moved %.1f m in 8 s, worst %.1f deg off to the side, %.1f up or down\n", - pilot.session ? 1 : 0, travel, worst_side, worst_up); - CHECK(pilot.session, "follow: a halfpipe session is filmed from a spot, not chased"); - CHECK(travel < 8.0f, "follow: holds its spot through a halfpipe session"); - CHECK(worst_side < 40.0f && worst_up < 30.0f, "follow: keeps a halfpipe session in shot"); - CHECK(lowest > 0.5f, "follow: stays off the ground through a session"); - } - // Follow into a wall: the skater rides at the wall (x = 20), stops short of it, turns round and - // rides back. The spot behind it is then inside the wall; the drone keeps off it. - { - namespace follow = dingosdk::drone::follow; - namespace d = dingosdk::drone::detail; - const follow::Tuning tuning; - const auto cfg = follow::flight_settings(settings(), tuning); - Vec3 skater{0, 1, 5}; - Vec3 heading{1, 0, 0}; - auto camera = follow::pose_facing(follow::spot(skater, {0, 0, 0}, heading, tuning, 0.0f), skater); - State drone; - follow::Pilot pilot; - float closest = 1e9f, worst_side = 0.0f; - const auto step = [&](float speed) { - skater = d::add(skater, d::scale(heading, speed * tick)); - const Vec3 at{camera[12], camera[13], camera[14]}; - const auto cmd = follow::fly(pilot, drone, at, skater, heading, tick, cfg, tuning, &world); - camera = step_drone_cmd(camera, drone, cmd, tick, 10.0f, cfg, &world); - closest = std::min(closest, 20.0f - camera[12]); - Vec3 look = d::scale(d::row(camera, 2), -1.0f); - Vec3 flat_look{look[0], 0, look[2]}, flat_to{skater[0] - camera[12], 0, skater[2] - camera[14]}; - if (d::length(flat_to) > 1.0f) - worst_side = std::max(worst_side, std::acos(std::clamp(d::dot(d::normalize(flat_look), d::normalize(flat_to)), -1.0f, 1.0f)) * 57.2958f); - }; - float t = 0; - for (; skater[0] < 16.0f; t += tick) step(6.0f); // toward the wall - for (float slow = 6.0f; slow > 0.0f; slow -= 6.0f * tick) step(slow); // stops a few metres short of it - heading = {-1, 0, 0}; - for (float u = 0; u < 1.0f; u += tick) step(0.0f); // turns round - for (float u = 0; u < 6.0f; u += tick) step(6.0f); // and rides away from it - std::printf("Follow: wall, came within %.2f m of it, worst %.1f deg off to the side\n", closest, worst_side); - CHECK(closest > 0.5f, "follow: keeps off a wall the spot behind the skater is in"); - } - // Follow settings: the DRONE page's distance and aggression; the middle is the tuning above. - { - namespace follow = dingosdk::drone::follow; - const follow::Tuning plain; - const auto middle = follow::tuning_for(4.5f, 0.5f); - CHECK(std::abs(middle.reaction - plain.reaction) < 1e-4f && std::abs(middle.speed_gain - plain.speed_gain) < 1e-4f && - std::abs(middle.gap_gain - plain.gap_gain) < 1e-4f && std::abs(middle.height - plain.height) < 0.01f && - std::abs(middle.session_distance - plain.session_distance) < 1e-4f, "follow: the middle settings are the usual tuning"); - const auto calm = follow::tuning_for(8.0f, 0.0f), hard = follow::tuning_for(8.0f, 1.0f); - CHECK(calm.distance == 8.0f && calm.reaction > hard.reaction && calm.max_lean_deg < hard.max_lean_deg, - "follow: aggression makes the pilot quicker and lean harder"); - } - // Follow down a steep slope: the skater rides off an 8 m high top, down a 1:1 slope and on. The - // spot behind the skater is then metres inside the slope; the drone keeps over the ground. - { - namespace follow = dingosdk::drone::follow; - namespace d = dingosdk::drone::detail; - const auto ground = [](float x) { return std::clamp(8.0f - x, 0.0f, 8.0f); }; - const World bank{[&](const Vec3& from, const Vec3& to, Vec3& hit) { - for (int i = 0; i <= 64; ++i) { - const float k = static_cast(i) / 64.0f; - const Vec3 at{from[0] + (to[0] - from[0]) * k, from[1] + (to[1] - from[1]) * k, from[2] + (to[2] - from[2]) * k}; - if (at[1] <= ground(at[0])) { hit = at; return true; } - } - return false; - }}; - const follow::Tuning tuning; - const auto cfg = follow::flight_settings(settings(), tuning); - Vec3 skater{-8.0f, 9.0f, 0}; - const Vec3 heading{1, 0, 0}; - auto camera = follow::pose_facing(follow::spot(skater, {0, 0, 0}, heading, tuning, 0.0f), skater); - State drone; - follow::Pilot pilot; - float lowest = 1e9f; - for (float t = 0; t < 6.0f; t += tick) { - skater[0] += 6.0f * tick; - skater[1] = ground(skater[0]) + 1.0f; - const Vec3 at{camera[12], camera[13], camera[14]}; - const auto cmd = follow::fly(pilot, drone, at, skater, heading, tick, cfg, tuning, &bank); - camera = step_drone_cmd(camera, drone, cmd, tick, 10.0f, cfg, &bank); - if (t > 1.0f) lowest = std::min(lowest, camera[13] - ground(camera[12])); - } - std::printf("Follow: down a slope, lowest %.2f m over the ground\n", lowest); - CHECK(lowest > 0.6f, "follow: keeps over a slope its spot is inside"); - // A spot under the ground (as a bad height would give): it keeps its clearance instead. - auto low = follow::tuning_for(4.5f, 0.5f); - low.height = -3.0f; - skater = {0, 1, 0}; - camera = follow::pose_facing(Vec3{-4.5f, 2.0f, 0.0f}, skater); - drone = State{}; - pilot = follow::Pilot{}; - lowest = 1e9f; - for (float t = 0; t < 6.0f; t += tick) { - skater[0] += 4.0f * tick; - const Vec3 at{camera[12], camera[13], camera[14]}; - const auto cmd = follow::fly(pilot, drone, at, skater, heading, tick, cfg, low, &world); - camera = step_drone_cmd(camera, drone, cmd, tick, 10.0f, cfg, &world); - lowest = std::min(lowest, camera[13]); - } - std::printf("Follow: spot underground, lowest %.2f m\n", lowest); - CHECK(lowest > 1.1f, "follow: keeps its clearance when its spot is under the ground"); - } - if (fails) return 1; - std::puts("Drone flight tests passed."); - return 0; -} diff --git a/Extension/Skater/Test/drone_radio_tests.cpp b/Extension/Skater/Test/drone_radio_tests.cpp deleted file mode 100644 index c7e8eb29..00000000 --- a/Extension/Skater/Test/drone_radio_tests.cpp +++ /dev/null @@ -1,71 +0,0 @@ -// 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/board_pose.cpp b/Extension/Skater/board_pose.cpp new file mode 100644 index 00000000..2794fb5f --- /dev/null +++ b/Extension/Skater/board_pose.cpp @@ -0,0 +1,369 @@ +#include "board_pose.h" +#include "Extension/AnimationStudio/anim_core.h" +#include "breakable_deck.h" +#include "Extension/Multiplayer/Remote/native_pose_layout.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::board_pose { +namespace { +using anim_studio::conjugate; +using anim_studio::multiply; +using anim_studio::normalised; +using anim_studio::rotate; +constexpr int place_bone = 1; +constexpr std::uintptr_t highest_user = 0x00007fffffffffffULL; +bool peek(std::uintptr_t address, void* out, std::size_t size) noexcept { + if (address < 0x10000 || address > highest_user - size) return false; + __try { + std::memcpy(out, reinterpret_cast(address), size); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +template std::optional read_value(std::uintptr_t address) noexcept { + T value{}; + if (!peek(address, &value, sizeof(T))) return std::nullopt; + return value; +} +struct alignas(16) Lane { + std::array scale, rotation, position; +}; +static_assert(sizeof(Lane) == 0x30); +// Rotation and position of one bone; its scale, and each fourth lane, as they are. +bool store(std::uintptr_t address, const Quat& rotation, const Vec3& position) noexcept { + if (address < 0x10000 || address > highest_user - sizeof(Lane)) return false; + __try { + auto* lane = reinterpret_cast(address); + lane[4] = rotation[0]; lane[5] = rotation[1]; lane[6] = rotation[2]; lane[7] = rotation[3]; + lane[8] = position[0]; lane[9] = position[1]; lane[10] = position[2]; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +bool finite(const Vec3& v) { return std::isfinite(v[0]) && std::isfinite(v[1]) && std::isfinite(v[2]); } +Vec3 add(const Vec3& a, const Vec3& b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; } +Vec3 sub(const Vec3& a, const Vec3& b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } +Vec3 mul(const Vec3& a, float k) { return {a[0] * k, a[1] * k, a[2] * k}; } +float length(const Vec3& v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } +Quat about(const Vec3& axis, float angle) { + const float s = std::sin(angle * 0.5f); + return normalised({axis[0] * s, axis[1] * s, axis[2] * s, std::cos(angle * 0.5f)}); +} +// The pose a bone is written with, as the client tick asked it. +struct Wanted { + bool hidden{}; + Quat rotation{0, 0, 0, 1}; + Vec3 position{}; + Space space{}; +}; +// A piece in flight: its frame in the world, and each of its bones in that frame. +struct Piece { + std::vector bones; + Vec3 velocity{}, spin{}; + float ground{}; + bool started{}, landed{}; + Vec3 at{}; + Quat facing{0, 0, 0, 1}; + std::map> held; // each bone relative to the piece's frame + LARGE_INTEGER last{}; +}; +struct Shared { + std::mutex mutex; + std::string owner; + std::map wanted; + std::set parked; + std::map, std::less<>> park_by; + std::optional piece; + std::optional> drawn; +}; +Shared& shared() { + static auto* value = new Shared; + return *value; +} +std::atomic base_now{}, holder_now{}; +std::atomic any{}; +// A breakable deck pack is in the game (breakable_deck.h): its deck model carries four stretches of +// deck on bones 12 to 15, which every board keeps out of sight unless a plugin places them (else they +// show as cracks); its truck model has the baseplates on the trucks' effectors (5 and 9), held on the +// deck; and the wheels sit as far apart as the deck's own trucks have them. +std::atomic deck_pack{}; +std::atomic deck_axle{1.0f}; +constexpr int first_stretch = 12; +struct TruckRest { + int truck, plate; + Quat facing; // the truck's bone on the deck's, at rest + Vec3 at; + Vec3 effector; // its effector on the truck's bone, at rest (not turned) +}; +constexpr std::array truck_rests{{ + {4, 5, {-0.3641458f, -0.3641458f, -0.6061335f, 0.6061335f}, {0.0005361f, -0.0183003f, -0.2344986f}, {0.0395021f, 0.0f, 0.0f}}, + {8, 9, {-0.6061674f, 0.6061674f, 0.3640894f, 0.3640894f}, {0.0005361f, -0.0182917f, 0.2346680f}, {0.0395677f, 0.0f, 0.0f}}, +}}; +// The wheels, each on its truck's bone, about 0.0876 m out along the axle. +constexpr std::array wheel_bones{6, 7, 10, 11}; +constexpr float wheel_offset = 0.0876f; +bool store_float(std::uintptr_t address, float value) noexcept { + if (address < 0x10000 || address > highest_user - sizeof(float)) return false; + __try { + *reinterpret_cast(address) = value; + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +void refresh_any(Shared& s) { any.store(!s.wanted.empty() || !s.parked.empty() || s.piece.has_value(), std::memory_order_release); } +std::string take(Shared& s, std::string_view owner) { + if (!s.owner.empty() && s.owner != owner) return s.owner + " holds the board"; + s.owner = owner; + return {}; +} +std::uintptr_t board_buffer(std::uintptr_t base, std::uintptr_t holder) { + const auto pose = multiplayer::read_native_pose_layout(peek, base, holder, 64); + return pose.buffer && pose.count == static_cast(bones) ? pose.buffer : 0; +} +std::uintptr_t bone_at(std::uintptr_t buffer, int index) { return buffer + static_cast(index) * sizeof(Lane); } + +// The piece's flight this frame: gravity, then the ground, where it settles flat the side it came down. +void fly(Piece& p, const Quat& place_rotation, const Vec3& place_at, const std::array& lanes) { + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + static const double frequency = [] { LARGE_INTEGER f{}; QueryPerformanceFrequency(&f); return static_cast(f.QuadPart); }(); + if (!p.started) { + p.started = true; + p.at = place_at; + p.facing = place_rotation; + p.last = now; + for (const int b : p.bones) + p.held[b] = {Quat{lanes[b].rotation}, Vec3{lanes[b].position[0], lanes[b].position[1], lanes[b].position[2]}}; + } + const auto dt = static_cast(std::clamp((now.QuadPart - p.last.QuadPart) / frequency, 0.0, 0.05)); + p.last = now; + if (!p.landed) { + p.velocity[1] -= 9.81f * dt; + p.at = add(p.at, mul(p.velocity, dt)); + if (const float rate = length(p.spin); rate > 1e-4f) p.facing = normalised(multiply(about(mul(p.spin, 1.0f / rate), rate * dt), p.facing)); + if (p.velocity[1] <= 0 && p.at[1] <= p.ground) { + // Down: flat, pointing the way it was, a short slide. + const auto ahead = rotate(p.facing, {0, 0, 1}); + const bool upside = rotate(p.facing, {0, 1, 0})[1] < 0; + p.facing = about({0, 1, 0}, std::atan2(ahead[0], ahead[2])); + if (upside) p.facing = normalised(multiply(p.facing, about({0, 0, 1}, 3.14159265f))); + p.velocity = {p.velocity[0] * 0.3f, 0, p.velocity[2] * 0.3f}; + p.spin = {}; + p.at[1] = p.ground; + p.landed = true; + } + } else { + p.at = add(p.at, mul(p.velocity, dt)); + p.velocity = mul(p.velocity, std::max(0.0f, 1.0f - 7.0f * dt)); + p.at[1] = p.ground; + } +} +} + +std::string set(std::string_view owner, int bone, const Bone& value, Space space) { + if (bone < first_writable || bone >= bones) return "a board's bone is 2 to 15"; + if (!finite(value.position) || length(value.position) > 1000 || + !std::all_of(value.rotation.begin(), value.rotation.end(), [](float v) { return std::isfinite(v); })) + return "a bone's place is finite"; + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (auto why = take(s, owner); !why.empty()) return why; + s.wanted[bone] = {false, normalised(value.rotation), value.position, space}; + refresh_any(s); + return {}; +} +std::string hide(std::string_view owner, int bone) { + if (bone < first_writable || bone >= bones) return "a board's bone is 2 to 15"; + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (auto why = take(s, owner); !why.empty()) return why; + s.wanted[bone] = {true}; + refresh_any(s); + return {}; +} +void clear(std::string_view owner, int bone) { + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (const auto parked = s.park_by.find(owner); parked != s.park_by.end() && bone < 0) { + s.park_by.erase(parked); + s.parked.clear(); + for (const auto& [who, list] : s.park_by) s.parked.insert(list.begin(), list.end()); + } + if (s.owner == owner) { + if (bone < 0) { + s.wanted.clear(); + s.piece.reset(); + } else s.wanted.erase(bone); + if (s.wanted.empty() && !s.piece) s.owner.clear(); + } + refresh_any(s); +} +void park(std::string_view owner, std::vector list) { + std::erase_if(list, [](int b) { return b < first_writable || b >= bones; }); + auto& s = shared(); + std::lock_guard lock(s.mutex); + s.park_by[std::string(owner)] = std::move(list); + s.parked.clear(); + for (const auto& [who, bones_of] : s.park_by) s.parked.insert(bones_of.begin(), bones_of.end()); + refresh_any(s); +} +std::string toss(std::string_view owner, std::vector list, Vec3 velocity, Vec3 spin, float ground) { + std::erase_if(list, [](int b) { return b < first_writable || b >= bones; }); + if (list.empty()) return "a piece is bones 2 to 15"; + if (!finite(velocity) || !finite(spin) || length(velocity) > 50 || length(spin) > 50 || !std::isfinite(ground)) + return "a piece leaves at most 50 m/s, turning at most 50 rad/s"; + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (auto why = take(s, owner); !why.empty()) return why; + Piece piece; + piece.bones = std::move(list); + piece.velocity = velocity; + piece.spin = spin; + piece.ground = ground; + s.piece = std::move(piece); + refresh_any(s); + return {}; +} +std::optional> read() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + return s.drawn; +} +void tick(std::uintptr_t base, std::uintptr_t component) noexcept { + std::uintptr_t holder{}; + try { + if (component && base) { + const auto collection = read_value(component + 0x18).value_or(0); + const auto entity = collection ? read_value(collection).value_or(0) : 0; + if (entity) holder = multiplayer::read_native_board(peek, base, entity).holder; + } + } catch (...) {} + base_now.store(base, std::memory_order_release); + holder_now.store(holder, std::memory_order_release); + try { + const auto deck = breakable_deck::in_play(); + deck_axle.store(breakable_deck::axle(deck), std::memory_order_release); + deck_pack.store(!deck.empty(), std::memory_order_release); + } catch (...) {} +} + +void on_render_pose(std::uintptr_t animation_interface) noexcept { + if (animation_interface <= 0xc0) return; + const auto error = GetLastError(); + try { + const auto base = base_now.load(std::memory_order_acquire); + const auto holder = animation_interface - 0xc0; + const bool local = holder == holder_now.load(std::memory_order_acquire) && holder; + const bool stretches = deck_pack.load(std::memory_order_acquire); + if (!local && !stretches && !any.load(std::memory_order_acquire)) { + SetLastError(error); + return; + } + const auto buffer = board_buffer(base, holder); + if (!buffer) { + SetLastError(error); + return; + } + std::array lanes{}; + if (!peek(buffer, lanes.data(), sizeof(lanes))) { + SetLastError(error); + return; + } + auto& s = shared(); + std::unique_lock lock(s.mutex, std::try_to_lock); + if (!lock.owns_lock()) { + SetLastError(error); + return; + } + // Out of sight: far below the board, from where its animation put it. + const auto away = [&](int b) { + const Vec3 at{lanes[b].position[0], lanes[b].position[1], lanes[b].position[2]}; + if (length(at) < 100.0f) (void)store(bone_at(buffer, b), Quat{lanes[b].rotation}, add(at, {0, -2000, 0})); + }; + for (const int b : s.parked) away(b); + if (stretches) { + for (int b = first_stretch; b < bones; ++b) + if (!s.parked.contains(b)) away(b); + // Each baseplate on the deck, from its truck's bone as the animation left it (not while + // the truck is off its place: a plugin moved it). + for (const auto& rest : truck_rests) { + const Quat facing = normalised(Quat{lanes[rest.truck].rotation}); + const Vec3 at{lanes[rest.truck].position[0], lanes[rest.truck].position[1], lanes[rest.truck].position[2]}; + if (!(length(sub(at, rest.at)) < 0.05f)) continue; + const auto back = conjugate(facing); + (void)store(bone_at(buffer, rest.plate), normalised(multiply(back, rest.facing)), + rotate(back, sub(add(rest.at, rotate(rest.facing, rest.effector)), at))); + } + // The deck's own trucks: the wheels' place along the axle is set rather than scaled, so a + // pose that comes by twice is not moved twice. + if (const float axle = deck_axle.load(std::memory_order_acquire); axle != 1.0f) + for (const int b : wheel_bones) { + int way = 0; // the axle, in the truck's axes: the largest part of where the wheel sits + for (int i = 1; i < 3; ++i) + if (std::abs(lanes[b].position[i]) > std::abs(lanes[b].position[way])) way = i; + const float out = std::abs(lanes[b].position[way]); + if (out > 0.05f && out < 0.16f) + (void)store_float(bone_at(buffer, b) + (8 + way) * sizeof(float), std::copysign(wheel_offset * axle, lanes[b].position[way])); + } + } + if (!local) { + SetLastError(error); + return; + } + std::array drawn{}; + for (int b = 0; b < bones; ++b) + drawn[b] = {{lanes[b].position[0], lanes[b].position[1], lanes[b].position[2]}, Quat{lanes[b].rotation}, + {lanes[b].scale[0], lanes[b].scale[1], lanes[b].scale[2]}}; + s.drawn = drawn; + const Quat place_rotation = normalised(Quat{lanes[place_bone].rotation}); + const Vec3 place_at{lanes[place_bone].position[0], lanes[place_bone].position[1], lanes[place_bone].position[2]}; + const auto back = conjugate(place_rotation); + const auto from_world = [&](const Quat& rotation, const Vec3& at) { + return std::pair{normalised(multiply(back, rotation)), rotate(back, sub(at, place_at))}; + }; + for (const auto& [b, want] : s.wanted) { + if (want.hidden) away(b); + else if (want.space == Space::local) (void)store(bone_at(buffer, b), want.rotation, want.position); + else { + const auto [rotation, at] = from_world(want.rotation, want.position); + (void)store(bone_at(buffer, b), rotation, at); + } + } + if (s.piece) { + auto& p = *s.piece; + // A piece leaves from where the plugin last put its bones (the pose before this frame's + // writes can hold them parked out of sight), else from where the animation put them. + auto start = lanes; + if (!p.started) + for (const int b : p.bones) + if (const auto want = s.wanted.find(b); want != s.wanted.end() && !want->second.hidden) { + auto [rotation, at] = want->second.space == Space::local ? std::pair{want->second.rotation, want->second.position} + : from_world(want->second.rotation, want->second.position); + start[b].rotation = rotation; + start[b].position = {at[0], at[1], at[2], start[b].position[3]}; + } + fly(p, place_rotation, place_at, start); + for (const auto& [b, rel] : p.held) { + // The bone in the world: the piece's frame (which was the board's place as it left) + // times the bone's pose then; written from wherever the board's place is now. + const auto world_rotation = normalised(multiply(p.facing, rel.first)); + const auto world_at = add(p.at, rotate(p.facing, rel.second)); + const auto [rotation, at] = from_world(world_rotation, world_at); + (void)store(bone_at(buffer, b), rotation, at); + } + } + } catch (...) { + } + SetLastError(error); +} +} diff --git a/Extension/Skater/board_pose.h b/Extension/Skater/board_pose.h new file mode 100644 index 00000000..e11f1571 --- /dev/null +++ b/Extension/Skater/board_pose.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// The skateboard's rig as plugins may change what is drawn of it (reskate.board), as one service. The board is a 16-bone rig: 0 its +// reference, 1 its place in the world, 2 the deck's mount (deck, trucks and wheels hang on it), 3 +// the deck, 4 and 8 the trucks, 6, 7, 10, 11 the wheels, 12 to 15 a breakable deck's stretches. What +// is written goes into the pose as it goes to the renderer, every frame, and the next animation +// update writes the whole pose again: nothing of the board itself changes, only what is drawn. Bones +// 0 and 1 are never written (they place the board; clips of the skater move bone 1). While a +// breakable deck pack is in the game, every board keeps 12 to 15 out of sight unless a plugin sets them. +namespace dingosdk::board_pose { +using Vec3 = std::array; +using Quat = std::array; // x, y, z, w +struct Bone { + Vec3 position{}; + Quat rotation{0, 0, 0, 1}; + Vec3 scale{1, 1, 1}; +}; +// How a set bone is meant: in its own parent's frame ("local", as the pose holds it), or in the +// world, for a bone that hangs on the board's place (bone 1): written relative to wherever the board +// is in that frame. +enum class Space { local, world }; +inline constexpr int bones = 16, first_writable = 2; + +// Client thread. One owner holds the local board's bones at a time; empty when taken, else why not. +std::string set(std::string_view owner, int bone, const Bone& value, Space space); +std::string hide(std::string_view owner, int bone); +void clear(std::string_view owner, int bone = -1); +// These bones go out of sight on every board, other players' included (a breakable deck's +// stretches, which the game would draw as cracks). +void park(std::string_view owner, std::vector list); +// A piece comes off the local board: these bones (hanging on bone 1) leave as one, from where they +// are, at `velocity` (m/s, world) turning at `spin` (rad/s, world), fall, land on `ground` (a world +// height) and lie there, until cleared. +std::string toss(std::string_view owner, std::vector list, Vec3 velocity, Vec3 spin, float ground); +// The local board's bones as last drawn, in their parents' frames; empty without a board. +std::optional> read(); +// Each client tick: the local skater's animation component (0 for none). +void tick(std::uintptr_t base, std::uintptr_t component) noexcept; +// From the render pose hook (native_skater.cpp), every skeleton handed to the renderer. +void on_render_pose(std::uintptr_t animation_interface) noexcept; +} diff --git a/Extension/Skater/breakable_deck.cpp b/Extension/Skater/breakable_deck.cpp new file mode 100644 index 00000000..81f62c9b --- /dev/null +++ b/Extension/Skater/breakable_deck.cpp @@ -0,0 +1,147 @@ +#include "breakable_deck.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Extension/Assets/live_mods.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::breakable_deck { +namespace { +// What a deck says of itself, in its folder's deck.ini: `axle` for its trucks (1 = a standard board's; +// more on a wider deck) and `label`, its name in the menus. +struct Setup { + float axle = 1.0f; + std::string label; +}; +std::string trimmed(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return std::string(text); +} +Setup read_setup(std::string_view deck) { + Setup out; + if (deck.empty()) return out; + try { + const auto folder = mods::board_break_deck_folder(deck); + if (folder.empty()) return out; + std::ifstream in(folder / L"deck.ini"); + std::string line; + while (std::getline(in, line)) { + if (const auto hash = line.find_first_of("#;"); hash != std::string::npos) line.erase(hash); + const auto eq = line.find('='); + if (eq == std::string::npos) continue; + auto key = trimmed(std::string_view(line).substr(0, eq)); + for (auto& c : key) c = static_cast(std::tolower(static_cast(c))); + const auto value = trimmed(std::string_view(line).substr(eq + 1)); + float amount{}; + const auto parsed = std::from_chars(value.data(), value.data() + value.size(), amount); + const bool whole = parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size(); + if (key == "axle" && whole && std::isfinite(amount)) out.axle = std::clamp(amount, 0.8f, 1.6f); + else if (key == "label") out.label = value; + } + } catch (...) {} + return out; +} +// The same, kept a minute per deck: the menus ask every frame, and each look lists every mod's folder. +Setup setup_of(std::string_view deck) { + struct Kept { + Setup setup; + std::uint64_t at{}; + }; + static std::mutex mutex; + static auto* kept = new std::map>; + const auto now = GetTickCount64(); + { + std::lock_guard lock(mutex); + if (const auto at = kept->find(deck); at != kept->end() && now - at->second.at < 60000) return at->second.setup; + } + auto setup = read_setup(deck); + std::lock_guard lock(mutex); + (*kept)[std::string(deck)] = {setup, now}; + return setup; +} +// The deck `wanted` means, of those installed: a name, a number in the list (from 1), or "next" +// for the one after the deck in the game. Empty when there is no such deck. +std::string meant(std::string wanted, const std::string& in_game, const std::vector& installed) { + for (auto& c : wanted) c = c == ' ' ? '_' : static_cast(std::tolower(static_cast(c))); + if (installed.empty()) return {}; + if (wanted.empty() || wanted == "next") { + const auto now = std::find(installed.begin(), installed.end(), in_game); + return now == installed.end() || now + 1 == installed.end() ? installed.front() : *(now + 1); + } + if (std::all_of(wanted.begin(), wanted.end(), [](char c) { return c >= '0' && c <= '9'; }) && wanted.size() <= 3) { + // The decks the packs were made with keep their numbers whether they are installed or not; + // the others follow, in the order they are listed. + const auto number = static_cast(std::stoi(wanted)); + const auto& known = mods::board_break_decks; + if (number >= 1 && number <= known.size()) return std::string(known[number - 1]); + std::size_t next = known.size(); + for (const auto& deck : installed) + if (std::find(known.begin(), known.end(), deck) == known.end() && ++next == number) return deck; + return {}; + } + return wanted; +} +std::mutex wanted_mutex; +std::string wanted_deck; // asked for, done at the next tick +} + +std::string in_play() noexcept { return mods::board_break_in_play(); } +std::string label(std::string_view deck) { + if (deck.empty()) return "None"; + if (auto title = setup_of(deck).label; !title.empty()) return title; + std::string name(deck); + bool start = true; + for (auto& c : name) { + if (c == '_') c = ' '; + else if (start) c = static_cast(std::toupper(static_cast(c))); + start = c == ' '; + } + return name; +} +float axle(std::string_view deck) { return deck.empty() ? 1.0f : setup_of(deck).axle; } + +std::string choose(std::string wanted) { + const auto installed = mods::installed_board_break_decks(); + if (installed.empty()) return "None of the breakable deck packs is installed."; + const auto now = in_play(); + const auto chosen = meant(std::move(wanted), now, installed); + if (chosen.empty()) return "There is no such deck."; + if (std::find(installed.begin(), installed.end(), chosen) == installed.end()) + return "The " + label(chosen) + " deck is not installed: its mod (Board_Break_" + chosen + ") is not in the Mods folder."; + if (chosen == now) return "The " + label(chosen) + " deck is the one in the game."; + { + std::lock_guard lock(wanted_mutex); + wanted_deck = chosen; + } + return "The " + label(chosen) + " deck: the mods are applied and the level loads again with it."; +} +std::string list() { + const auto installed = mods::installed_board_break_decks(); + const auto& known = mods::board_break_decks; + std::string out; + std::size_t number{}; + const auto add = [&](std::string_view name, bool there) { + out += (++number > 1 ? ", " : "") + std::to_string(number) + " " + label(name) + (there ? "" : " (not installed)"); + }; + for (const auto name : known) add(name, std::find(installed.begin(), installed.end(), name) != installed.end()); + for (const auto& name : installed) + if (std::find(known.begin(), known.end(), name) == known.end()) add(name, true); + return out; +} +void tick() noexcept { + try { + std::string wanted; + { + std::lock_guard lock(wanted_mutex); + wanted.swap(wanted_deck); + } + if (!wanted.empty() && wanted != in_play() && mods::save_board_break_deck(wanted)) (void)live_mods::apply(true); + } catch (...) {} +} +} diff --git a/Extension/Skater/breakable_deck.h b/Extension/Skater/breakable_deck.h new file mode 100644 index 00000000..e900c444 --- /dev/null +++ b/Extension/Skater/breakable_deck.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +// The breakable deck packs (the Board_Break_ mods; Engine/Vfs/mod_catalog.h): which deck every +// board has, its name for people and how far apart its wheels sit. Breaking a board is a plugin's +// (the board-break mod, through reskate.board); this is what every board needs whichever plugins run, +// as board_pose draws it: the deck's four stretches out of sight, the baseplates on the deck and the +// wheels at the deck's own spacing. +namespace dingosdk::breakable_deck { +// The deck in the game, or empty. +std::string in_play() noexcept; +// A deck's name in the menus: its deck.ini's `label`, else its folder's name, in words. +std::string label(std::string_view deck); +// How far apart the deck's wheels sit, as a standard board's are (deck.ini `axle`); 1 without one. +float axle(std::string_view deck); +// Puts `wanted` in the game (by name, by number, or "next"): the mods are applied again and the level +// loads, at the next client tick. What happens, for people. +std::string choose(std::string wanted); +// The decks by number for the console ("1 Standard, 2 Wide (not installed), ..."). +std::string list(); +// Each client tick. +void tick() noexcept; +} diff --git a/Extension/Skater/breakable_deck_commands.cpp b/Extension/Skater/breakable_deck_commands.cpp new file mode 100644 index 00000000..021fe52a --- /dev/null +++ b/Extension/Skater/breakable_deck_commands.cpp @@ -0,0 +1,25 @@ +#include "breakable_deck.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Extension/Console/commands.h" + +// Which breakable deck every board has; the Mods page's deck card sends it too. +namespace dingosdk::console { +void register_breakable_deck_commands(Commands ®istry) { + auto which = argument("deck", Type::text, true); + which.complete = [](const Model &, auto) { + auto names = mods::installed_board_break_decks(); + names.insert(names.begin(), "next"); + return names; + }; + auto deck = action("boarddeck", "Which breakable deck every board has, by its name or its number, or next; the level loads again with it", + Group::gameplay, {std::move(which)}); + deck.run = [](const Model &, const Values &args, const Output &out) { + if (!args.empty() && !std::get(args[0]).empty()) { + out(breakable_deck::choose(std::get(args[0]))); + return; + } + out("In the game: " + breakable_deck::label(breakable_deck::in_play()) + ". Decks: " + breakable_deck::list() + "."); + }; + registry.add(std::move(deck)); +} +} diff --git a/Extension/Skater/client_debug.cpp b/Extension/Skater/client_debug.cpp index 5148b502..df809f85 100644 --- a/Extension/Skater/client_debug.cpp +++ b/Extension/Skater/client_debug.cpp @@ -8,8 +8,6 @@ #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" @@ -35,8 +33,7 @@ double source_flight_now() { return static_cast(counter.QuadPart) / frequency; } namespace { -// 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). +// Free-flight speed: any speed between the slowest and fastest the console offers (0.6 to 1500). 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{}; }; @@ -119,7 +116,7 @@ void debug_restore_camera(SourceTrial& trial, std::uintptr_t client, bool phase, auto& debug = trial.debug; debug_stop_noclip(debug); source_require(!debug.camera_ambiguous, "Native camera call had an uncertain result; restart before changing camera mode."); - if (!debug.camera_owned) { debug.first_person = false; debug.drone_follow = false; debug.first_person_saved_fov = 0; return; } + if (!debug.camera_owned) { debug.first_person = false; debug.first_person_saved_fov = 0; return; } source_require(phase, "Waiting for the native camera update phase."); auto camera = source_camera_snapshot(trial, client); if (debug.camera_owned) { @@ -143,34 +140,6 @@ void debug_restore_camera(SourceTrial& trial, std::uintptr_t client, bool phase, debug.camera_owned = false; debug.flight_ready = false; debug.first_person = false; - debug.drone_follow = false; - } -} -// The game's own collision query (the Park Editor's ray), so the drone hits ground, walls and ramps. -const drone::World& drone_world() { - 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; - }}; - return world; -} -// Drone follow: the pilot's tuning from the DRONE page's follow distance and aggression. -drone::follow::Tuning follow_tuning() { - const auto& values = drone::knobs().value; - return drone::follow::tuning_for(values[drone::kFollowDistance].load(std::memory_order_relaxed), - values[drone::kFollowAggression].load(std::memory_order_relaxed)); -} -// Drone follow: the skater's position and which way it faces, or nothing between lives. -std::optional> follow_target(SourceTrial& trial, std::uintptr_t client) { - try { - overlay::DebugModel unused; - const auto skater = debug_skater(trial.base, client, unused); - return std::pair{drone::detail::Vec3{skater[12], skater[13], skater[14]}, drone::detail::row(skater, 2)}; - } catch (...) { - return std::nullopt; } } // `taken` receives the camera snapshot this step read, for the tick's model to reuse: @@ -254,71 +223,8 @@ void debug_flight_tick(SourceTrial& trial, std::uintptr_t client, bool ready, bo ? "First person on. True first person keeps the view level and steady." : "First person on. The camera follows the skater's head."; } - } else if (debug.drone_follow) { - // The autopilot flies the drone behind the skater through the flight model; between - // lives (a bail, a respawn) it holds on to where the skater was last. - drone::audio::ensure_started(); - const auto tuning = follow_tuning(); - const auto cfg = drone::follow::flight_settings(drone::live_settings(), tuning); - const auto& world = drone_world(); - const auto target = follow_target(trial, client); - const drone::detail::Vec3 at{debug.flight_matrix[12], debug.flight_matrix[13], debug.flight_matrix[14]}; - const drone::detail::Vec3 skater = target ? target->first : debug.follow_pilot.last_target; - const drone::detail::Vec3 facing = target ? target->second : debug.follow_pilot.heading; - if (target && drone::detail::length(drone::detail::sub(skater, at)) > 60.0f) { - // Left far behind (a respawn, a teleport): it is back where it should be. - const auto spot = drone::follow::spot(skater, {0, 0, 0}, debug.follow_pilot.heading, tuning, 0.0f); - debug.flight_matrix = drone::follow::pose_facing(spot, skater); - debug.drone_state.valid = false; - debug.follow_pilot = {}; - } - const auto cmd = drone::follow::fly(debug.follow_pilot, debug.drone_state, at, skater, facing, seconds, cfg, tuning, &world); - next = drone::step_drone_cmd(debug.flight_matrix, debug.drone_state, cmd, seconds, std::max(debug.flight_speed, 8.0f), cfg, &world); } else { - if (drone::is_enabled()) { - drone::audio::ensure_started(); - const auto& world = drone_world(); - 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() && - drone::gamepad_mode()) { - // A gamepad, read directly (it is kept from the game while the drone has it). - overlay::Gamepad pad; - { - drone::radio::detail::InputAccess access; - pad = overlay::read_gamepad(); - } - if (pad.valid) { - // While the ReSkate menu is open (no flight input) the pad is the menu's: the drone - // holds still in the air and the buttons do nothing. - const bool menu_closed = input && input->active; - drone::apply_gamepad_buttons(pad.buttons, menu_closed); - overlay::Gamepad resting; - resting.valid = true; - next = drone::step_drone_radio(debug.flight_matrix, debug.drone_state, - drone::read_gamepad(menu_closed ? pad : resting), seconds, debug.flight_speed, drone::live_settings(), &world); - 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); - } + 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. @@ -426,18 +332,6 @@ 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) { @@ -794,7 +688,6 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b source_require(camera.identity == debug.camera_identity && camera.mode == 1 && camera.active == camera.identity.camera, "First person could not take the camera."); debug.first_person = true; - debug.drone_follow = false; debug.first_person_waiting = false; { float saved{}; @@ -817,47 +710,6 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b debug.status = "First person on. The camera follows the skater's head."; return; } - if (request.action == overlay::DebugAction::set_drone_follow) { - if (!request.enabled) { - if (!debug.drone_follow) return; - debug_restore_camera(trial, client, phase, error); // Also ends drone follow. - debug.status = "Drone follow off. Original camera restored."; - return; - } - if (debug.drone_follow) return; - // The skater to follow comes first, so nothing is taken without one. - const auto target = follow_target(trial, client); - source_require(target.has_value(), "Drone follow needs the local skater."); - // Take mode 1 through the verified Freecam path, as first person does, then fly it. - if (!debug.camera_owned) - debug_action(trial, client, can_control, phase, {overlay::DebugAction::set_free_camera, true}, error); - camera = source_camera_snapshot(trial, client); - source_require(camera.identity == debug.camera_identity && camera.mode == 1 && - camera.active == camera.identity.camera, "Drone follow could not take the camera."); - if (debug.first_person) { - first_person_disarm(); - first_person_restore_fov(debug); - debug.first_person = false; - } - // It takes off from its spot behind the skater, facing them. - debug.follow_pilot = {}; - drone::detail::Vec3 heading{target->second[0], 0.0f, target->second[2]}; - heading = drone::detail::length(heading) > 0.1f ? drone::detail::normalize(heading) : drone::detail::Vec3{0, 0, -1}; - const auto tuning = follow_tuning(); - const auto spot = drone::follow::spot(target->first, {0, 0, 0}, heading, tuning, 0.0f); - auto pose = drone::follow::pose_facing(spot, target->first); - source_require(valid_flight_transform(pose), "Drone follow pose is invalid."); - debug.flight_ready = false; - SetLastError(error); - trial.camera_transform(camera.identity.camera, &pose); - debug.flight_matrix = pose; - debug.flight_time = source_flight_now(); - debug.drone_state.valid = false; - debug.drone_follow = true; - debug.flight_ready = true; - debug.status = "Drone follow on. The drone flies itself behind you; skate as normal."; - return; - } if (request.action == overlay::DebugAction::set_free_camera) { debug_stop_noclip(debug); if (!request.enabled) { @@ -876,11 +728,6 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b debug.first_person = false; debug.status = "Freecam enabled from the first-person view."; } - if (debug.drone_follow) { - // Keep the owned camera and fly on from where the drone was. - debug.drone_follow = false; - debug.status = "Freecam enabled from drone follow."; - } return; } source_require(camera.mode == 0 && camera.view_enabled && !camera.input_active, @@ -900,9 +747,7 @@ 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.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."; + debug.status = "Freecam enabled. WASD / Q E move; hold RMB to look; Shift boosts."; return; } source_require(false, "Unknown debug request."); @@ -1054,10 +899,9 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli first_person_cycle_tick(trial, client, can_control, camera_phase_observed, error.value); // Camera ownership survives a busy tick or a failed presentation snapshot. // Input capture must follow the lease, not those transient model failures. - // (First person and drone follow own the same camera but leave the skater its controls.) - state.free_camera_active.store(debug.camera_owned && !debug.first_person && !debug.drone_follow, std::memory_order_release); - model.free_camera = debug.camera_owned && !debug.first_person && !debug.drone_follow; - model.drone_follow = debug.camera_owned && debug.drone_follow; + // (First person owns the same camera but leaves the skater its controls.) + state.free_camera_active.store(debug.camera_owned && !debug.first_person, std::memory_order_release); + model.free_camera = debug.camera_owned && !debug.first_person; std::optional flight_camera; try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); } catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; } @@ -1075,8 +919,7 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli try { const auto camera = flight_camera ? *flight_camera : source_camera_snapshot(trial, client); const bool owned_view = camera.mode == 1 && camera.active == camera.identity.camera; - model.free_camera = owned_view && !debug.first_person && !debug.drone_follow; - model.drone_follow = owned_view && debug.drone_follow; + model.free_camera = owned_view && !debug.first_person; // Handed back on foot still reads as on, so the toggle can switch it off. model.first_person = (owned_view && debug.first_person) || debug.first_person_paused; model.first_person_fov = debug.first_person_fov; diff --git a/Extension/Skater/client_first_person.cpp b/Extension/Skater/client_first_person.cpp index dc07d259..6e9d9b4c 100644 --- a/Extension/Skater/client_first_person.cpp +++ b/Extension/Skater/client_first_person.cpp @@ -244,3 +244,13 @@ void first_person_on_animation(std::uintptr_t component) noexcept { SetLastError(error); } } + +namespace dingosdk { +std::uintptr_t local_skater_component(std::uintptr_t base, std::uintptr_t client) noexcept { + try { + return client_source::detail::first_person_component(base, client); + } catch (...) { + return 0; + } +} +} // namespace dingosdk diff --git a/Extension/Skater/client_source_spawn.h b/Extension/Skater/client_source_spawn.h index 186323b7..974bedac 100644 --- a/Extension/Skater/client_source_spawn.h +++ b/Extension/Skater/client_source_spawn.h @@ -36,6 +36,8 @@ bool client_free_camera_active() noexcept; // `fov` is applied to the spectate camera while it is held; its own comes back after. bool update_party_camera(std::uintptr_t base, std::uintptr_t client, bool ready, bool phase, const std::array* transform, std::string& detail, float fov = 0) noexcept; +// The local skater's animation component (what the pose hooks are called with), or 0. Client thread. +std::uintptr_t local_skater_component(std::uintptr_t base, std::uintptr_t client) noexcept; bool read_local_camera_transform(std::uintptr_t base, std::uintptr_t client, std::array& transform) noexcept; // Publishes the local view's camera (Engine/Game/UI/game_view.h) for things drawn over the // world. Client thread, every tick while something needs it. diff --git a/Extension/Skater/client_source_spawn_internal.h b/Extension/Skater/client_source_spawn_internal.h index 0873564c..4282cfde 100644 --- a/Extension/Skater/client_source_spawn_internal.h +++ b/Extension/Skater/client_source_spawn_internal.h @@ -5,8 +5,6 @@ #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 "Extension/Skater/drone_follow.h" #include #include #include @@ -124,11 +122,6 @@ 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) - // Drone follow: shares Freecam's owned camera like first person, and the skater keeps its - // controls; each tick the autopilot flies the drone (drone_follow.h) and the view is its camera. - bool drone_follow{}; - drone::follow::Pilot follow_pilot; 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 0272cc89..631e2803 100644 --- a/Extension/Skater/console_commands.cpp +++ b/Extension/Skater/console_commands.cpp @@ -1,10 +1,6 @@ #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 "Extension/Multiplayer/Remote/native_prop.h" -#include "Extension/Multiplayer/Remote/drone_sound.h" #include #include #include @@ -30,8 +26,6 @@ void register_movement_commands(Commands ®istry) { &overlay::DebugModel::free_camera, &overlay::DebugModel::camera_available}, {"firstperson", "debug.first_person", "See through the skater's eyes", Debug::set_first_person, &overlay::DebugModel::first_person, &overlay::DebugModel::camera_available}, - {"dronefollow", "drone.follow", "Skate while the FPV drone flies itself behind you, as if someone were flying it", - Debug::set_drone_follow, &overlay::DebugModel::drone_follow, &overlay::DebugModel::camera_available}, {"hideui", "debug.game_ui_hidden", "Hide the game's interface", Debug::set_game_ui_hidden, &overlay::DebugModel::game_ui_hidden, &overlay::DebugModel::ui_available}}; for (const auto &setting : settings) { @@ -65,241 +59,8 @@ 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 Addons > Drone in the ReSkate menu (Insert): Start flying", - Group::movement, argument("0|1", Type::boolean)); - drone_mode.inspect = [](const Model &m) { - return boolean_state(m.debug.available, drone::is_enabled(), "Waiting for local player controls.", - "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", "Open the ReSkate menu on Addons > Drone", Group::movement); - menu_command.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; - menu_command.run = [](const Model &, const Values &, const Output &out) { - drone::menu_flag().store(true); - out("Opening Addons > Drone."); - }; - registry.add(std::move(menu_command)); - auto open_menu = action("menu", "Open the ReSkate menu (bind it to a controller combo to open it with a pad)", Group::console); - open_menu.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; - open_menu.run = [](const Model &, const Values &, const Output &out) { - overlay::menu_open_flag().store(true); - out("Opening the ReSkate menu."); - }; - registry.add(std::move(open_menu)); - 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 Addons > Drone (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 = true; - profile_runtime::set_local_values({{"Drone.Radio", 1.0}}); - }; - registry.add(std::move(radio_mode)); - // A test toward the drone's 3D model (Multiplayer/Remote/native_prop.h): can a spawned prop - // be moved every frame? - auto model_name = argument("blueprint", Type::text, true); - auto model_mode = argument("mode", Type::integer, true); - auto model_test = action("dronemodeltest", - "Test for the drone model: spawns the drone (dronemodeltest drone), the traffic cone (cone) or a named blueprint in front of you and moves it " - "round a circle. Mode 0, 1 or 2 tries a different way of moving it. 'dronemodeltest remote' has a pretend other player fly a drone " - "in front of you, shown as theirs would be. 'dronemodeltest stop' removes them; on its own it reports", - Group::movement, {model_name, model_mode}); - model_test.inspect = [](const Model &) { return State{true, {}, {}, {}, false}; }; - model_test.run = [](const Model &, const Values &args, const Output &out) { - const auto name = args.empty() ? std::string() : std::get(args[0]); - const int mode = args.size() > 1 ? static_cast(std::get(args[1])) : 1; - if (name.empty() || name == "status") { - out(multiplayer::prop_test_status()); - out("Other players' drones: " + multiplayer::drone_models_status()); - out("Their sound: " + multiplayer::drone_sounds_status()); - return; - } - if (name == "stop") { - multiplayer::request_prop_test({}, 0); - multiplayer::request_fake_remote_drone(false); - out("Removing the test prop."); - return; - } - if (name == "remote") { - multiplayer::request_fake_remote_drone(true); - out("A pretend other player takes off in front of you. Run dronemodeltest again in a few seconds for what happened."); - return; - } - const std::string blueprint = name == "drone" ? std::string(multiplayer::drone_model_blueprint) - : name == "cone" || name == "start" ? std::string(multiplayer::prop_test_default) : name; - multiplayer::request_prop_test(blueprint, mode); - out(std::format("Spawning {} with mode {}. Run dronemodeltest again in a moment for what happened.", blueprint, mode)); - }; - registry.add(std::move(model_test)); - 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); - // Any speed in range, so the drone's presets and top speed slider can set theirs; the usual - // ones are still offered as completions. + // Any speed in range; the usual ones are offered as completions. speed.minimum = 0.6; speed.maximum = 1500.0; speed.complete = [](const Model &, auto) { diff --git a/Extension/Skater/drone_audio.h b/Extension/Skater/drone_audio.h deleted file mode 100644 index 2cdd0859..00000000 --- a/Extension/Skater/drone_audio.h +++ /dev/null @@ -1,137 +0,0 @@ -#pragma once -// Synthesised FPV motor sound. The maths (MotorSynth) is plain C++ and testable anywhere. -// In a level the pilot's motor plays through the game's own sound, with the other players' drones -// (Multiplayer/Remote/drone_sound.cpp); the Windows output thread here (winmm waveOut, already -// linked for the radio) only plays it where the game's sound is not there to take it. -// 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; // the pilot's own sound; other players' drones play at 16 kHz - explicit MotorSynth(int sample_rate = rate) - : rate_(sample_rate > 0 ? sample_rate : rate), - // the wind's low-pass at about 900 Hz whatever the rate (0.12 a sample at 44.1 kHz) - wind_k_(1.0f - std::exp(-6.2831853f * 900.0f / static_cast(rate_))) {} - // 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 * static_cast(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] / static_cast(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_) * wind_k_; - 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: - int rate_; - float wind_k_; - float rpm_ = 0, lp_ = 0; - float phase_[4] = {0, 0.25f, 0.5f, 0.75f}; - std::uint32_t seed_ = 12345u; -}; - -// The game's sound took the pilot's motor this recently (drone_sound.cpp marks it each frame it -// does); while it does, the Windows output stays quiet. -inline std::atomic& game_sound_ms() { - static std::atomic at{0}; - return at; -} -inline bool played_by_game() { return now_ms() - game_sound_ms().load(std::memory_order_relaxed) < 300; } - -#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 && !played_by_game(); - 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 deleted file mode 100644 index cef34ea9..00000000 --- a/Extension/Skater/drone_beacon.h +++ /dev/null @@ -1,255 +0,0 @@ -#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; - // For its motor sound (Multiplayer/Remote/drone_sound.h): how fast it goes and the throttle it - // seems to be on, worked out from how it moves (estimate_throttle), as packets carry no stick. - Position velocity{}; - float speed = 0.0f; - float throttle = 0.0f; -}; -// The throttle a drone is on, from how it accelerates and which way its props point: thrust is the -// acceleration it has besides gravity, along its up axis. Thrust goes with output^1.5 and hovering -// takes `hover` (drone_flight.h), so output = hover * (thrust / g)^(2/3). Drag is left out: a fast -// drone sounds a little less busy than it is. -inline constexpr float assumed_hover = 0.35f, assumed_gravity = 9.81f; -inline float estimate_throttle(const Position& acceleration, const Rotation& facing) { - const auto up = basis_from_rotation(facing)[1]; - const float thrust = acceleration[0] * up[0] + (acceleration[1] + assumed_gravity) * up[1] + acceleration[2] * up[2]; - if (!std::isfinite(thrust) || thrust <= 0.0f) return 0.0f; - return std::clamp(assumed_hover * std::pow(thrust / assumed_gravity, 2.0f / 3.0f), 0.0f, 1.0f); -} -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; - // Velocity and acceleration between the last two positions as they arrived; smoothed, - // as arrival times jitter. A long gap or a jump starts over. - const float dt = std::chrono::duration(now - it->seen).count(); - if (!snap && dt >= 0.02f && dt <= 0.5f) { - Position velocity{}, acceleration{}; - float speed = 0.0f; - for (std::size_t i = 0; i < 3; ++i) { - velocity[i] = (position[i] - it->to[i]) / dt; - acceleration[i] = (velocity[i] - it->velocity[i]) / dt; - speed += velocity[i] * velocity[i]; - } - const float throttle = estimate_throttle(acceleration, facing); - it->throttle += (throttle - it->throttle) * 0.35f; - it->speed += (std::sqrt(speed) - it->speed) * 0.5f; - it->velocity = velocity; - } else { - it->velocity = {}; - it->speed = 0.0f; - it->throttle = assumed_hover; - } - 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; - Remote added{id, std::move(name), position, now, position, position, {}, facing, facing, facing}; - added.throttle = assumed_hover; - r.list.push_back(std::move(added)); -} -// 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 deleted file mode 100644 index acfc46e6..00000000 --- a/Extension/Skater/drone_flight.h +++ /dev/null @@ -1,846 +0,0 @@ -#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 -> half as much thrust again -// -// The model: the throttle sets motor output, which spools up and down over some tens of -// milliseconds, and thrust grows with output to the power 1.5, so the hover throttle fixes the -// thrust-to-weight ratio (a 22% hover is about 10:1, a 5-inch's). Thrust falls off as the drone -// climbs into its own inflow and comes back near the ground. The flight controller turns the -// drone at the rate the sticks ask, catching up in about 30 ms, or in angle mode leans it to the -// angle they ask. Drag is quadratic and larger across the props than edge on; the top speed -// setting sets how much, so full throttle flat out settles near it. Hits bounce off the surface -// they meet, knock the drone about, and a hard one cuts the motors until it is armed again. -#include "free_flight.h" -#include "drone_radio.h" -#include "drone_beacon.h" -#include -#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 camera_uptilt_deg = 0.0f; - bool angle_mode = false; // true = self-levelling (easier), false = acro (full flips) - float max_angle_deg = 55.0f; - float angle_response = 8.0f; // 1/s: how quickly angle mode leans to the asked angle - bool alt_hold = false; // a throttle that springs back holds the height in its middle - float climb_rate = 20.0f; // m/s at full stick while holding height - bool crash = true; // hits bounce and tumble; a hard one cuts the motors - float crash_speed = 7.0f; // m/s into a surface that cuts the motors; 0 = never - float propwash = 0.5f; // shake when dropping through its own prop wash, 0-1 - 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) - std::array rate{}; // turn rates, rad/s: about right (+ nose up), up (+ left), forward (+ roll right) - float motor = 0; // motor output 0..1, following the throttle with the spool-up lag - bool crashed = false; // a hard hit cut the motors: arming again (off, then on) puts it right - bool armed_last = true; // armed last tick, to see an arm after a crash - bool grounded = false; // touched something underneath last tick - float glitch = 0; // video break-up after a hit, 0..1, fading - std::array wash{}; // prop-wash wobble now, rad/s - std::uint32_t noise = 0x2545f491u; -}; - -// 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 stick_throttle{-2}; // -1..1 when the throttle stick springs back, else below -1 (shown from the throttle) - std::atomic crashed{false}; // a hard hit cut the motors - std::atomic glitch{0}; // video break-up after a hit, 0..1 - 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); } -// `dronemenu` asks for the ReSkate menu to open on its DRONE page (UI/Overlay/drone_page.cpp); -// the menu takes the request when it next draws. -inline std::atomic& menu_flag() { - static std::atomic value{false}; - 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, - kGamepad, kPadRate, kPadExpo, kAltHold, kClimbRate, kFlightMode, kMaxAngle, kCrash, kCrashSpeed, kPropwash, kPadInvert, - kFollowDistance, kFollowAggression }; -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, 9.81f}, - {"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 in the air (0.1-0.9). Lower means more power to spare: 0.22 is about ten times its weight in thrust, a 5-inch's", 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, 0.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, speed and signal. The range warnings show either way", 0.0f, 1.0f, 1.0f}, - {"volume", "Motor sound volume (0 = silent)", 0.0f, 1.0f, 0.09f}, - {"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 Addons > Drone", 0.0f, 8.0f, 1.0f}, - {"hideui", "Hide the game's own interface while you fly or the drone follows you (Start flying or Follow me hides it, stopping brings it back)", 0.0f, 1.0f, 1.0f}, - {"gamepad", "Fly with a gamepad (Xbox or PlayStation) instead of an RC radio: left stick throttle and yaw, right stick pitch and roll, with gentler sticks. A arms, Menu / Options puts the drone back", 0.0f, 1.0f, 1.0f}, - {"padrate", "Gamepad: how much of your rates the sticks reach at full tilt (0.2-1). Lower is calmer. In angle mode it slows turning only: full stick still leans all the way", 0.2f, 1.0f, 0.6f}, - {"padexpo", "Gamepad: how much softer the middle of the sticks is, for small corrections (0 = none, 1 = most)", 0.0f, 1.0f, 0.45f}, - {"althold", "With a throttle that springs back to the middle (a gamepad, or the keyboard): hold your height (1), adding throttle as you lean to keep it; up climbs, down descends. Off (0): the middle is hover power and the throttle is yours alone, so a hard lean loses height until you add some", 0.0f, 1.0f, 0.0f}, - {"climbrate", "Height hold: how fast full throttle stick climbs or descends, in metres per second", 1.0f, 40.0f, 20.0f}, - {"mode", "Flight mode: 0 = acro (the stick sets how fast it turns, full flips), 1 = angle (self-levelling: the stick sets how far it leans, and letting go levels it)", 0.0f, 1.0f, 0.0f}, - {"maxangle", "Angle mode: how far the drone leans at full stick, in degrees", 10.0f, 80.0f, 55.0f}, - {"crash", "Crashes: hits bounce and tumble the drone, and a hard one cuts the motors until you arm again (1). Off: it just stops against things (0)", 0.0f, 1.0f, 1.0f}, - {"crashspeed", "Crashes: how hard a hit cuts the motors, in metres per second into the surface. 0 = never", 0.0f, 30.0f, 7.0f}, - {"propwash", "How much the drone shakes when it drops through its own prop wash (0-1)", 0.0f, 1.0f, 0.5f}, - {"padinvert", "Gamepad: push the right stick up to tip the nose up, like aiming a camera (1); or to tip it down and fly forward, like an RC radio (0)", 0.0f, 1.0f, 1.0f}, - {"followdistance", "Follow me: how far behind you the drone flies, in metres (it flies a little higher the further back it is)", 3.0f, 12.0f, 4.5f}, - {"followaggression", "Follow me: how hard the pilot flies (0-1). Low is a calm cinematic pilot who lags a little and leans gently; high reacts fast, leans hard and stays right on you", 0.0f, 1.0f, 0.5f}, -}; -inline constexpr std::size_t knob_count = std::size(knob_info); -static_assert(knob_count == kFollowAggression + 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 bool gamepad_mode() { return knobs().value[kGamepad].load(std::memory_order_relaxed) >= 0.5f; } -// A gamepad's sticks, Mode 2: left stick throttle (springing back) and yaw, right stick pitch and -// roll. Each stick has a round dead zone (a pad's drifts more than a radio's gimbals); then the -// middle is softened (`expo`) and the whole travel scaled down (`scale`), since a pad's short -// sticks at a radio's rates make every touch a flip. `invert`: up on the right stick tips the nose -// up (as a camera is aimed) rather than down (as on a radio). In angle mode the right stick sets -// how far the drone leans, not how fast it turns, so `scale` leaves it alone there: full stick is -// the full lean. -inline radio::Command gamepad_sticks(const overlay::Gamepad& pad, float dz, float scale, float expo, float hover, bool invert = false, - bool angle = false) { - radio::Command out; - if (!pad.valid) return out; - out.valid = true; - const float zone = std::clamp(std::isfinite(dz) ? dz : 0.0f, 0.08f, 0.5f); - const auto stick = [zone](float x, float y) -> std::array { - x = std::isfinite(x) ? std::clamp(x, -1.0f, 1.0f) : 0.0f; - y = std::isfinite(y) ? std::clamp(y, -1.0f, 1.0f) : 0.0f; - const float length = std::sqrt(x * x + y * y); - if (!(length > zone)) return {0.0f, 0.0f}; - const float k = std::min(1.0f, (length - zone) / (1.0f - zone)) / length; - return {x * k, y * k}; - }; - const float e = std::clamp(std::isfinite(expo) ? expo : 0.0f, 0.0f, 1.0f); - const float s = std::clamp(std::isfinite(scale) ? scale : 1.0f, 0.05f, 1.0f); - const auto shape = [e](float v, float most) { const float a = std::abs(v); return std::copysign((e * a * a * a + (1.0f - e) * a) * most, v); }; - const float tilt = angle ? 1.0f : s; - const auto left = stick(pad.left_x, pad.left_y), right = stick(pad.right_x, pad.right_y); - out.yaw = shape(left[0], s); - out.roll = shape(right[0], tilt); - out.pitch = shape(invert ? -right[1] : right[1], tilt); // + = forward = nose down - out.centred = true; - out.climb = left[1]; - out.throttle = radio::spring_throttle(left[1], hover); - return out; -} -inline radio::Command read_gamepad(const overlay::Gamepad& pad) { - const auto get = [](Knob k) { return knobs().value[k].load(std::memory_order_relaxed); }; - return gamepad_sticks(pad, get(kDeadzone), get(kPadRate), get(kPadExpo), get(kHover), get(kPadInvert) >= 0.5f, - get(kFlightMode) >= 0.5f); -} -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; - s.angle_mode = get(kFlightMode) >= 0.5f; s.max_angle_deg = get(kMaxAngle); - s.alt_hold = get(kAltHold) >= 0.5f; s.climb_rate = get(kClimbRate); - s.crash = get(kCrash) >= 0.5f; s.crash_speed = get(kCrashSpeed); s.propwash = get(kPropwash); - 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 page applies them through the same commands -// a player could type, so they are saved like any other change, and shows "Custom" once one is changed. -struct PresetValue { Knob knob; float value; }; -struct Preset { - 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; -} -// A camera on a tilt gimbal (as on a DJI Avata): it faces the way the body faces and holds its own -// pitch, `tilt_rad` (+ = look up), however the body leans, keeping only a share of the body's roll. -inline std::array camera_on_gimbal(const std::array& body, const std::array& position, - float tilt_rad, float roll_share) { - Vec3 forward = scale(row(body, 2), -1.0f); - forward[1] = 0.0f; - if (dot(forward, forward) < 0.0025f) forward = cross(row(body, 0), Vec3{0, 1, 0}); - forward = normalize(forward); - auto cam = body; - set_row(cam, 0, Vec3{-forward[2], 0.0f, forward[0]}); - set_row(cam, 1, Vec3{0.0f, 1.0f, 0.0f}); - set_row(cam, 2, scale(forward, -1.0f)); - rotate_basis(cam, row(cam, 0), tilt_rad); - // The body's roll (+ = right wing down), about the camera's own view axis. - const float roll = -std::asin(std::clamp(row(body, 0)[1], -1.0f, 1.0f)); - rotate_basis(cam, scale(row(cam, 2), -1.0f), roll * roll_share); - 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; - } -} - -// A gamepad's buttons (XInput's bits; a PlayStation pad's are given the same): A (cross) arms or -// disarms, Menu (Options) puts the drone back where it took off. Each on the press, not the hold; -// with `act` false (the ReSkate menu has the pad) they are only watched, so a button still held -// when the menu closes does nothing. -inline constexpr unsigned pad_start = 0x0010, pad_a = 0x1000; -inline void apply_gamepad_buttons(unsigned buttons, bool act = true) { - static unsigned before = ~0u; // nothing counts as pressed until it has been seen up - const unsigned pressed = buttons & ~before; - before = buttons; - if (!act) return; - if (pressed & pad_a) set_armed(!armed()); - if (pressed & pad_start) request_reset(); -} - -// 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 hold = false; // hold the height: `climb` asks for a climb or a descent instead of `throttle` - float climb = 0; // -1..1 of the climb rate, while holding height - bool boost = false; - bool park = false; // motors off: hold position - float gimbal_tilt_rad = std::numeric_limits::quiet_NaN(); // set: the camera is on a tilt gimbal at this pitch (+ = up) -}; - -// The physical constants of the model (see the top of this file). -inline constexpr float gimbal_roll_share = 0.3f; // how much of the body's roll a tilt gimbal's picture keeps -inline constexpr float rate_lag = 0.03f; // s: the flight controller's catch-up to the asked turn rate -inline constexpr float spool_up = 0.04f, spool_down = 0.07f; // s: motor output following the throttle -inline constexpr float thrust_power = 1.5f; // thrust grows with motor output to this power -inline constexpr float boost_thrust = 1.5f; // boost: this much more thrust -inline constexpr float hard_hit = 2.0f; // m/s into a surface: a bump rather than a touch -inline constexpr float bounce = 0.35f; // how much of a hard hit's speed it bounces back with -inline constexpr float hold_gain = 4.0f; // 1/s: height hold's pull toward the asked climb - -namespace detail { -inline Vec3 sub(const Vec3& a, const Vec3& b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } -inline float length(const Vec3& a) { return std::sqrt(dot(a, a)); } -inline float noise(std::uint32_t& s) { - s ^= s << 13; s ^= s >> 17; s ^= s << 5; - return static_cast(s & 0xffffff) / 8388608.0f - 1.0f; // -1..1 -} -inline float hover_of(const Settings& c) { return std::clamp(std::isfinite(c.hover_throttle) ? c.hover_throttle : 0.35f, 0.05f, 0.95f); } -// Thrust (m/s² along the drone's up) at full output, and at `output`. -inline float max_thrust(const Settings& c) { return std::max(0.0f, c.gravity) / std::pow(hover_of(c), thrust_power); } -inline float thrust_at(float output, const Settings& c) { - return max_thrust(c) * std::pow(std::clamp(output, 0.0f, 1.0f), thrust_power); -} -// The output that gives `accel` m/s² of thrust. -inline float output_for(float accel, const Settings& c) { - const float most = max_thrust(c); - if (!(most > 0.0f) || !std::isfinite(accel)) return hover_of(c); - return std::pow(std::clamp(accel / most, 0.0f, 1.0f), 1.0f / thrust_power); -} -// The top speed (m/s) the speed setting asks for, and the quadratic drag that gives it: flat out, -// the thrust left over from holding the drone up is spent on drag at that speed. (Scaled for the -// drag across the props and the inflow below, so that leaning anywhere from 45 to 85 degrees -// settles within a few percent of it.) -inline float top_speed(float speed) { return std::clamp(std::isfinite(speed) ? speed : 10.0f, 0.6f, 1500.0f) * 4.0f; } -inline float drag_for(const Settings& c, float speed) { - const float most = max_thrust(c), g = std::max(0.0f, c.gravity); - const float spare = std::sqrt(std::max(most * most - g * g, 0.01f)); - const float top = top_speed(speed); - return 0.36f * spare / (top * top); -} -// The surface at a hit: two more rays beside the first find two more points of it. Without them -// (an edge, or a thin thing) the surface is taken to face straight back along the move. -inline Vec3 surface_normal(const World& world, const Vec3& from, const Vec3& dir, float reach, const Vec3& hit, float spread) { - const Vec3 side = normalize(cross(dir, std::abs(dir[1]) < 0.9f ? Vec3{0, 1, 0} : Vec3{1, 0, 0})); - const Vec3 other = cross(dir, side); - std::array points{}; - int found = 0; - for (const auto& offset : {side, other, scale(side, -1.0f), scale(other, -1.0f)}) { - if (found == 2) break; - const Vec3 start = add(from, scale(offset, spread)); - Vec3 at{}; - if (world.cast(start, add(start, scale(dir, reach)), at)) points[static_cast(found++)] = at; - } - if (found == 2) { - Vec3 n = normalize(cross(sub(points[0], hit), sub(points[1], hit))); - if (dot(n, dir) > 0.0f) n = scale(n, -1.0f); - if (length(n) > 0.5f && dot(n, dir) < -0.05f) return n; - } - return scale(dir, -1.0f); -} -// Body turn rates (about right, up, forward) from a world-space spin vector, and back. -inline std::array body_rates(const std::array& body, const Vec3& spin) { - return {dot(spin, row(body, 0)), dot(spin, row(body, 1)), -dot(spin, row(body, 2))}; -} -} - -// 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 level, facing the way the view faces, at rest and hovering, as a drone sits at takeoff. - Vec3 heading = scale(row(camera, 2), -1.0f); - heading[1] = 0.0f; - if (length(heading) < 0.05f) heading = cross(row(camera, 0), Vec3{0, 1, 0}); - heading = normalize(heading); - state.body = camera; - set_row(state.body, 0, Vec3{-heading[2], 0.0f, heading[0]}); - set_row(state.body, 1, Vec3{0.0f, 1.0f, 0.0f}); - set_row(state.body, 2, scale(heading, -1.0f)); - 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; - state.rate = {0, 0, 0}; - state.motor = cfg.armed ? hover_of(cfg) : 0.0f; - state.crashed = false; - state.armed_last = cfg.armed; - state.glitch = 0; - 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; - state.rate = {0, 0, 0}; - state.motor = cfg.armed ? hover_of(cfg) : 0.0f; - state.crashed = false; - state.glitch = 0; - } - // After a crash the motors stay off until the drone is armed again: off, then on. - if (cfg.armed && !state.armed_last) state.crashed = false; - state.armed_last = cfg.armed; - 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 = length(sub(position, state.start_pos)); - 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); - } - // Disarmed, crashed or out of range: the pilot's inputs and the motors are off. A crashed - // drone falls even when a disarmed one would hang. - const bool motors = cfg.armed && !state.failsafe && !state.crashed; - if (!motors) { - const float gimbal = cmd.gimbal_tilt_rad; // the gimbal keeps working without the motors - cmd = Cmd{}; - cmd.gimbal_tilt_rad = gimbal; - cmd.throttle = 0.0f; - cmd.park = cfg.disarm_freeze && !state.crashed; - } - - const float pitch_in = clamp1(cmd.pitch); - const float roll_in = clamp1(cmd.roll); - const float yaw_rate = dt > 0.0f && std::isfinite(cmd.yaw_rad) ? std::clamp(cmd.yaw_rad / dt, -40.0f, 40.0f) : 0.0f; - const float look_pitch = std::isfinite(cmd.look_pitch_rad) ? cmd.look_pitch_rad : 0.0f; - - // Attitude: the flight controller asks for a turn rate on each axis and the drone catches up - // with it; with the motors off nothing drives it and only the air slows a spin. - if (motors) { - std::array want{}; - if (!cfg.angle_mode) { - want = {actual_rate(pitch_in, cfg.pitch) * kDegToRad, yaw_rate, actual_rate(roll_in, cfg.roll) * kDegToRad}; - } else { - // Lean toward the angle the sticks ask, about the heading the drone faces. - const Vec3 fwd = scale(row(state.body, 2), -1.0f); - Vec3 level{fwd[0], 0.0f, fwd[2]}; - if (length(level) < 0.05f) level = cross(row(state.body, 0), Vec3{0, 1, 0}); - level = normalize(level); - const Vec3 level_right{-level[2], 0.0f, level[0]}; - const float most = std::clamp(cfg.max_angle_deg, 1.0f, 89.0f) * kDegToRad; - Vec3 desired = rotate(Vec3{0, 1, 0}, level_right, pitch_in * most); - desired = rotate(desired, rotate(level, level_right, pitch_in * most), roll_in * most); - const Vec3 up = row(state.body, 1); - const Vec3 axis = cross(up, desired); - const float angle = std::atan2(length(axis), dot(up, desired)); - const Vec3 spin = scale(normalize(axis), angle * std::max(cfg.angle_response, 0.5f)); - const auto r = body_rates(state.body, spin); - const float limit_pitch = std::max(cfg.pitch.max, 30.0f) * kDegToRad, limit_roll = std::max(cfg.roll.max, 30.0f) * kDegToRad; - want = {std::clamp(r[0], -limit_pitch, limit_pitch), yaw_rate, std::clamp(r[2], -limit_roll, limit_roll)}; - } - const float k = 1.0f - std::exp(-dt / rate_lag); - for (std::size_t i = 0; i < 3; ++i) state.rate[i] += (want[i] - state.rate[i]) * k; - } else { - const float damp = std::exp(-dt * (state.grounded ? 8.0f : 0.6f)); - for (auto& r : state.rate) r *= damp; - } - // Prop wash: dropping straight down through its own wash shakes it. - { - const Vec3 up = row(state.body, 1); - const float axial = dot(state.velocity, up); - const float across = length(sub(state.velocity, scale(up, axial))); - const float sink = std::clamp((-axial - 1.5f) / 6.0f, 0.0f, 1.0f) * std::clamp(1.0f - across / 8.0f, 0.0f, 1.0f); - const float amount = motors ? std::clamp(cfg.propwash, 0.0f, 1.0f) * sink * 3.0f : 0.0f; - const float k = 1.0f - std::exp(-dt * 20.0f); - for (std::size_t i : {0u, 2u}) state.wash[i] += (noise(state.noise) * amount - state.wash[i]) * k; - state.wash[1] = 0.0f; - } - for (auto& r : state.rate) if (!std::isfinite(r)) r = 0.0f; - rotate_basis(state.body, row(state.body, 1), state.rate[1] * dt); // yaw about body up - rotate_basis(state.body, row(state.body, 0), (state.rate[0] + state.wash[0]) * dt - look_pitch); // pitch about body right - rotate_basis(state.body, scale(row(state.body, 2), -1.0f), (state.rate[2] + state.wash[2]) * dt); // roll about body forward - - // Translation - Vec3 next_pos = position; - float hardest = 0.0f; - if (cmd.park) { - state.velocity = {0, 0, 0}; - state.motor = 0.0f; - } else { - const Vec3 right = row(state.body, 0), up = row(state.body, 1), back = row(state.body, 2); - // The throttle, or with height hold the output that keeps the asked climb, leaning or not. - // Height hold never holds the stick back: the stick's own throttle wins whenever it asks - // for more (up) or less (down), so full stick is always full or no throttle. - float want = std::clamp(cmd.throttle, 0.0f, 1.0f); - if (cmd.hold && motors) { - const float stick = clamp1(cmd.climb); - const float direct = radio::spring_throttle(stick, hover_of(cfg)); - const float lift = std::max(0.0f, cfg.gravity + hold_gain * (stick * std::max(cfg.climb_rate, 0.0f) - state.velocity[1])); - const float held = up[1] > 0.25f ? output_for(lift / up[1], cfg) : direct; - want = stick > 0.0f ? std::max(held, direct) : stick < 0.0f ? std::min(held, direct) : held; - } - if (!motors) want = 0.0f; - state.motor += (want - state.motor) * (1.0f - std::exp(-dt / (want > state.motor ? spool_up : spool_down))); - float thrust = thrust_at(state.motor, cfg) * (cmd.boost && motors ? boost_thrust : 1.0f); - // Inflow: climbing into its own air it pushes less, sinking a little more. - const float axial = dot(state.velocity, up); - thrust *= std::clamp(1.0f - axial / std::max(top_speed(speed) * 2.5f, 5.0f), 0.3f, 1.25f); - // Ground effect: within a couple of prop sizes of the ground it pushes more. - const float r = std::max(cfg.body_radius, 0.01f); - if (thrust > 0.0f && world && world->cast && cfg.collide) { - Vec3 below{}; - const float reach = 4.0f * r; - if (world->cast(position, add(position, Vec3{0, -reach, 0}), below)) - thrust *= 1.0f + 0.25f * std::clamp(1.0f - (position[1] - below[1]) / reach, 0.0f, 1.0f); - } - // Drag: quadratic, larger across the props than edge on, plus the plain drag setting. - const float k = drag_for(cfg, speed) * length(state.velocity); - const Vec3 drag = add(add(scale(right, -dot(state.velocity, right) * k), scale(up, -dot(state.velocity, up) * 1.5f * k)), - scale(back, -dot(state.velocity, back) * k)); - const Vec3 accel = add(add(scale(up, thrust), drag), Vec3{-state.velocity[0] * cfg.lin_drag, - -cfg.gravity - state.velocity[1] * cfg.lin_drag, -state.velocity[2] * cfg.lin_drag}); - for (std::size_t i = 0; i < 3; ++i) state.velocity[i] += accel[i] * dt; - // (Only a guard against runaway numbers: drag sets the real top speed.) - const float cap = std::max(top_speed(speed) * 3.0f, 30.0f); - if (const float now = length(state.velocity); now > cap) state.velocity = scale(state.velocity, cap / now); - next_pos = add(position, scale(state.velocity, dt)); - state.grounded = false; - if (world && world->cast && cfg.collide) { - // Swept move: up to the first surface on the way, then what is left of the move slides - // along it. A hit takes away the speed into the surface; a hard one bounces back some - // of it and knocks the drone about. - Vec3 p = position; - Vec3 move = sub(next_pos, position); - const float spread = std::max(r, 0.05f); - for (int pass = 0; pass < 3; ++pass) { - const float len = length(move); - if (!(len > 1e-6f)) break; - const Vec3 dir = scale(move, 1.0f / len); - Vec3 hit{}; - if (!world->cast(p, add(p, scale(dir, len + r)), hit)) { p = add(p, move); break; } - const float gap = length(sub(hit, p)) - r; - const float allowed = std::clamp(gap, 0.0f, len); - p = add(p, scale(dir, allowed)); - const Vec3 n = surface_normal(*world, p, dir, len + r + 2.0f * spread, hit, spread); - if (gap < 0.0f) p = add(p, scale(n, std::min(-gap, r))); // started too close: step out - if (n[1] > 0.6f) state.grounded = true; - const float into = dot(state.velocity, n); - if (into < 0.0f) { - const float impact = -into; - const Vec3 along = sub(state.velocity, scale(n, into)); - if (cfg.crash && impact > hard_hit) { - state.velocity = add(scale(along, 0.75f), scale(n, impact * bounce)); - // Knocked about: spun away from the surface by the speed along it, plus some - // of the hit at random. - const float slide = std::min(length(along), 15.0f); - Vec3 spin = scale(normalize(cross(n, along)), slide * 0.6f); - spin = add(spin, scale(Vec3{noise(state.noise), noise(state.noise), noise(state.noise)}, impact * 0.4f)); - const auto kick = body_rates(state.body, spin); - for (std::size_t i = 0; i < 3; ++i) state.rate[i] = std::clamp(state.rate[i] + kick[i], -30.0f, 30.0f); - } else { - state.velocity = along; - } - hardest = std::max(hardest, impact); - } - if (n[1] > 0.6f) { // on the ground: skid to a stop - const float grip = std::exp(-6.0f * dt); - const float normal = dot(state.velocity, n); - state.velocity = add(scale(sub(state.velocity, scale(n, normal)), grip), scale(n, normal)); - } - Vec3 rest = scale(dir, len - allowed); - if (const float push = dot(rest, n); push < 0.0f) rest = sub(rest, scale(n, push)); - move = rest; - } - 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; - state.grounded = true; - } - } - } - // A hit hard enough cuts the motors (the drone falls and tumbles); any real one breaks up the picture. - if (cfg.crash && hardest > hard_hit) { - state.glitch = std::max(state.glitch, std::clamp(hardest / std::max(cfg.crash_speed * 1.5f, 4.0f), 0.2f, 1.0f)); - if (motors && cfg.crash_speed > 0.0f && hardest >= cfg.crash_speed) { - state.crashed = true; - set_armed(false); - } - } - state.glitch *= std::exp(-dt * 2.5f); - - const auto out = std::isfinite(cmd.gimbal_tilt_rad) ? camera_on_gimbal(state.body, next_pos, cmd.gimbal_tilt_rad, gimbal_roll_share) - : 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(state.motor, 0.0f, 1.0f), std::memory_order_relaxed); - t.speed.store(length(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 && !state.crashed, std::memory_order_relaxed); - t.crashed.store(state.crashed, std::memory_order_relaxed); - t.glitch.store(state.glitch, 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). The keyboard has no -// throttle that stays put: with height hold the middle holds the height and Q / E climb and -// descend; without it the middle is hover power. -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; - const float up_in = clamp1(input.up); - cmd.throttle = radio::spring_throttle(up_in, cfg.hover_throttle); - cmd.hold = cfg.alt_hold; - cmd.climb = up_in; - 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); - t.stick_throttle.store(up_in, std::memory_order_relaxed); - } - return step_drone_cmd(camera, state, cmd, seconds, speed, cfg, world); -} - -// A controller: an RC radio, or a gamepad read as one (gamepad_sticks). The sticks drive the rates; -// a throttle stick that stays put is absolute thrust, and at the bottom lets the drone fall, as a -// real one would. One that springs back holds the height (height hold) or hovers in its middle. -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); - cmd.hold = sticks.centred && cfg.alt_hold; - cmd.climb = clamp1(sticks.climb); - { - 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); - t.stick_throttle.store(sticks.centred ? clamp1(sticks.climb) : -2.0f, std::memory_order_relaxed); - } - return step_drone_cmd(camera, state, cmd, seconds, speed, cfg, world); -} -} diff --git a/Extension/Skater/drone_follow.h b/Extension/Skater/drone_follow.h deleted file mode 100644 index 5968a4cc..00000000 --- a/Extension/Skater/drone_follow.h +++ /dev/null @@ -1,473 +0,0 @@ -#pragma once -// Follow mode: the FPV drone flies itself behind the skater while the player skates, and the -// view is its camera. It is not a chase camera placed each frame: an autopilot works the same -// sticks a pilot would (angle mode with height hold) through the real flight model -// (drone_flight.h), so it has to lean to speed up, overshoots a turn, swings round to keep the -// skater in shot and climbs over what is in the way, the way someone flying it would. -// -// It sees the skater a reaction time late and moves its sticks with a hand's smoothness and a -// little wander, which keeps the shot from looking machine-perfect. When the skater rides back and -// forth in one place (a halfpipe, a bowl, laps of a spot) it stops chasing and holds a spot off to -// the side to film the session. It looks where it flies and keeps off walls and ceilings rather -// than flying into them. Plain C++, testable off the game. -#include "drone_flight.h" -#include -#include -#include -#include - -namespace dingosdk::drone::follow { -using namespace dingosdk::drone::detail; // Vec3 and the vector helpers of the flight model - -struct Tuning { - float distance = 4.5f; // m behind the skater - float height = 1.4f; // m above the skater - float lead = 0.15f; // s: aims where the skater will be this soon (about its reaction, so it sits `distance` back at any speed) - float reaction = 0.12f; // s: how late the pilot sees what the skater does - float hands = 0.10f; // s: how smoothly the sticks move - float wander = 0.035f; // stick unsteadiness, of full stick - float max_lean_deg = 40.0f; - float brake = 2.0f; // m/s/s the pilot plans to slow down at when closing a gap - float catch_up = 6.0f; // m/s faster than the skater it closes a gap at, most - float gap_gain = 0.8f; // 1/s: how fast it closes a gap to its spot - float speed_gain = 1.5f; // 1/s: how hard it leans to match the speed it wants - float uptilt_deg = 5.0f; // camera tilt on the drone without a gimbal (the flight model's own) - float tilt_ease = 0.4f; // s: how smoothly the gimbal tilts to keep the skater in the middle - float session_distance = 7.0f; // m from the middle of a session it films from, at least - float margin = 1.2f; // m it keeps from walls and ceilings - float max_yaw_rate = 4.5f; // rad/s the pilot turns the drone at most - float clearance = 1.4f; // m the drone keeps above the ground -}; - -// The tuning for a follow distance (m) and how aggressively the pilot flies (0 calm, 0.5 as above, -// 1 hard). Aggression is how quickly the pilot sees and answers the skater, how hard it closes a -// gap, leans and turns. Further back it also flies a little higher, and films a session from -// further away. -inline Tuning tuning_for(float distance, float aggression) { - const auto mix = [a = std::clamp(std::isfinite(aggression) ? aggression : 0.5f, 0.0f, 1.0f)](float calm, float mid, float hard) { - return a < 0.5f ? calm + (mid - calm) * (a / 0.5f) : mid + (hard - mid) * ((a - 0.5f) / 0.5f); - }; - Tuning t; - t.distance = std::clamp(std::isfinite(distance) ? distance : t.distance, 1.0f, 30.0f); - t.height = 1.0f + 0.09f * t.distance; - t.session_distance = std::max(t.distance + 2.5f, 5.0f); - t.reaction = mix(0.20f, 0.12f, 0.06f); - t.hands = mix(0.16f, 0.10f, 0.05f); - t.gap_gain = mix(0.5f, 0.8f, 1.2f); - t.speed_gain = mix(1.0f, 1.5f, 2.0f); - t.catch_up = mix(4.0f, 6.0f, 10.0f); - t.brake = mix(1.2f, 2.0f, 3.5f); - t.max_lean_deg = mix(25.0f, 40.0f, 55.0f); - t.max_yaw_rate = mix(2.5f, 4.5f, 6.0f); - t.tilt_ease = mix(0.6f, 0.4f, 0.25f); - return t; -} - -// The pilot's memory between ticks. -struct Pilot { - bool valid = false; - Vec3 last_target{}; // the skater's position last tick - Vec3 target_velocity{}; // smoothed - Vec3 heading{0, 0, -1}; // which way the skater is going, level - struct Seen { float age; Vec3 position, velocity; }; - std::array seen{}; // what the skater did lately, newest at `head` - std::size_t head = 0, count = 0; - float ride_y = 0; // the skater's height, without ollies and bumps - float climb_extra = 0; // extra height while something blocks the shot - float blocked_for = 0; // s the shot has been blocked - float floor_lift = 0; // extra height to clear the ground under the spot - float pitch = 0, roll = 0, yaw = 0, climb = 0; // stick positions now - float tilt = 0; // the camera gimbal's pitch, radians, + = up - // The skater every 0.1 s over the last six seconds, to tell a session (riding back and forth - // in one place) from a line. - struct Sample { Vec3 position, velocity; }; - std::array history{}; - std::size_t history_head = 0, history_count = 0; - float history_clock = 0; - bool session = false; // holding a spot to film a session - float session_for = 0; // s it has held it - Vec3 session_spot{}, session_middle{}, session_side{}; - float session_reach = 0; // m from the middle the riding goes along its axis - // m to the nearest surface each way around the drone (+x, -x, +z, -z, up); 0 = nothing near. - std::array nearby{}; - std::size_t near_next = 0; - std::uint32_t noise = 0x9e3779b9u; - Vec3 wander_now{}, wander_to{}; // slow unsteadiness, eased toward a new value now and then - float wander_time = 0; -}; - -// Where the drone wants to be: behind the skater along its heading, up and a little ahead in time. -inline Vec3 spot(const Vec3& target, const Vec3& velocity, const Vec3& heading, const Tuning& t, float extra) { - const Vec3 ahead = add(target, scale(velocity, t.lead)); - return add(add(ahead, scale(heading, -t.distance)), Vec3{0, t.height + extra, 0}); -} - -// A drone pose (camera matrix rows: right, up, back, position) hovering at `at`, facing `look`. -inline std::array pose_facing(const Vec3& at, const Vec3& look) { - Vec3 forward = detail::sub(look, at); - forward[1] = 0.0f; - forward = detail::length(forward) > 1e-3f ? normalize(forward) : Vec3{0, 0, -1}; - const Vec3 right{-forward[2], 0.0f, forward[0]}; - std::array m{}; - set_row(m, 0, right); - set_row(m, 1, Vec3{0, 1, 0}); - set_row(m, 2, scale(forward, -1.0f)); - m[12] = at[0]; m[13] = at[1]; m[14] = at[2]; m[15] = 1.0f; - return m; -} - -// Reads the skater's last few seconds: riding back and forth in one place starts a session (the -// drone holds a spot off to the side), riding away or stopping for a while ends it. -inline void read_session(Pilot& p, const Vec3& drone_at, const Tuning& t, const World* world) { - const std::size_t n = std::min(p.history_count, 50); // the last five seconds - if (n < 30) { p.session = false; return; } - const auto at = [&](std::size_t back) -> const Pilot::Sample& { - return p.history[(p.history_head + p.history.size() - back) % p.history.size()]; - }; - // The way it rides most (across a halfpipe), from how its velocity spreads. - float xx = 0, xz = 0, zz = 0, low = 1e9f, high = -1e9f, path = 0; - Vec3 middle{}; - for (std::size_t back = 0; back < n; ++back) { - const auto& sample = at(back); - xx += sample.velocity[0] * sample.velocity[0]; - xz += sample.velocity[0] * sample.velocity[2]; - zz += sample.velocity[2] * sample.velocity[2]; - middle = add(middle, sample.position); - low = std::min(low, sample.position[1]); - high = std::max(high, sample.position[1]); - if (back) path += std::hypot(sample.position[0] - at(back - 1).position[0], sample.position[2] - at(back - 1).position[2]); - } - middle = scale(middle, 1.0f / static_cast(n)); - const float angle = 0.5f * std::atan2(2.0f * xz, xx - zz); - const Vec3 axis{std::cos(angle), 0.0f, std::sin(angle)}; - const float half = 0.5f * (xx + zz), spread = std::sqrt(std::max(half * half - (xx * zz - xz * xz), 0.0f)); - const bool line = half + spread > 3.0f * std::max(half - spread, 1e-3f); // one way and back, not round - int reversals = 0; - std::size_t since_reversal = n; - float sign = 0, lo = 1e9f, hi = -1e9f; - for (std::size_t back = n; back-- > 0;) { - const auto& sample = at(back); - const float along = detail::dot(sample.velocity, axis); - const float x = detail::dot(detail::sub(sample.position, middle), axis); - lo = std::min(lo, x); - hi = std::max(hi, x); - if (std::abs(along) < 1.5f) continue; - const float now = along > 0.0f ? 1.0f : -1.0f; - if (sign != 0.0f && now != sign) { ++reversals; since_reversal = back; } - sign = now; - } - const Vec3 first = at(n - 1).position, last = at(0).position; - const float net = std::hypot(last[0] - first[0], last[2] - first[2]); - const bool back_and_forth = reversals >= 2 && path > 8.0f && net < 0.4f * path; - if (!p.session) { - if (!back_and_forth) return; - p.session = true; - p.session_for = 0.0f; - // Watched from off to the side of the way it rides (down the length of a halfpipe), on the - // side the drone is already on; round riding (a bowl) from where the drone already is. - const Vec3 across{-axis[2], 0.0f, axis[0]}; - Vec3 from{drone_at[0] - middle[0], 0.0f, drone_at[2] - middle[2]}; - if (line) p.session_side = detail::dot(from, across) >= 0.0f ? across : scale(across, -1.0f); - else p.session_side = detail::length(from) > 0.5f ? normalize(from) : across; - } else { - // Ends: no turn back for a while, or the skater has left the middle. - const float out = std::hypot(last[0] - p.session_middle[0], last[2] - p.session_middle[2]); - if (p.session_for > 3.0f && (since_reversal > 35 || out > p.session_reach + 6.0f)) { p.session = false; return; } - if (line) { - const Vec3 across{-axis[2], 0.0f, axis[0]}; - p.session_side = detail::dot(across, p.session_side) >= 0.0f ? across : scale(across, -1.0f); - } - } - p.session_middle = middle; - p.session_reach = std::max(-lo, hi); - // Far enough to see both ends of the riding, high enough to see the airs. - const float distance = std::clamp(0.6f * (hi - lo), t.session_distance, 14.0f); - const float height = low + std::clamp(0.5f * (high - low) + 1.5f, 1.8f, 6.0f); - const Vec3 look_from{middle[0], height, middle[2]}; - const auto place = [&](const Vec3& side, float& room) { - Vec3 spot = add(look_from, scale(side, distance)); - room = distance; - Vec3 hit{}; - if (world && world->cast && world->cast(look_from, add(spot, scale(side, t.margin)), hit)) { - room = std::max(detail::length(detail::sub(hit, look_from)) - t.margin, 0.5f); - spot = add(look_from, scale(side, room)); - } - return spot; - }; - float room = 0; - p.session_spot = place(p.session_side, room); - if (room < 3.0f) { // boxed in on this side: the other side, if it is any better - float other_room = 0; - const Vec3 other = place(scale(p.session_side, -1.0f), other_room); - if (other_room > room + 1.0f) { p.session_side = scale(p.session_side, -1.0f); p.session_spot = other; } - } -} - -// Keeps off walls and ceilings: looks along the way it flies and the way it wants to, and slows -// toward anything there in time to stop short of it (sliding along it); and pushes away from -// anything already close around it. Changes `want_velocity` (level) and returns whether the way up -// is blocked. -inline bool avoid(Pilot& p, const State& drone, const Vec3& position, Vec3& want_velocity, const Tuning& t, const World& world) { - using detail::length; - for (const Vec3& going : {drone.velocity, want_velocity}) { - Vec3 dir{going[0], 0.0f, going[2]}; - const float speed = length(dir); - if (speed < 0.3f) continue; - dir = scale(dir, 1.0f / speed); - const float reach = t.margin + 0.5f + speed * 0.7f; - Vec3 hit{}; - if (!world.cast(position, add(position, scale(dir, reach)), hit)) continue; - Vec3 n = detail::surface_normal(world, position, dir, reach, hit, 0.3f); - n[1] = 0.0f; - if (length(n) < 0.2f) continue; // the ground, not a wall - n = normalize(n); - const float room = length(detail::sub(hit, position)) - t.margin; - const float allowed = room > 0.0f ? std::sqrt(2.0f * 4.0f * room) : room * 2.0f; // m/s toward it; < 0 backs off - const float toward = -detail::dot(want_velocity, n); - if (toward > allowed) want_velocity = add(want_velocity, scale(n, toward - allowed)); - } - // Two of the five ways around it each tick. - static constexpr std::array ways{{{1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1}, {0, 1, 0}}}; - const float reach = t.margin + 0.3f; - for (int i = 0; i < 2; ++i) { - const std::size_t w = p.near_next; - p.near_next = (p.near_next + 1) % ways.size(); - Vec3 hit{}; - p.nearby[w] = world.cast(position, add(position, scale(ways[w], reach)), hit) ? std::max(length(detail::sub(hit, position)), 0.01f) : 0.0f; - } - for (std::size_t w = 0; w < 4; ++w) - if (p.nearby[w] > 0.0f) want_velocity = add(want_velocity, scale(ways[w], -(reach - p.nearby[w]) * 3.0f)); - return p.nearby[4] > 0.0f && p.nearby[4] < t.margin; -} - -// The flight settings it flies with: the player's own drone, made easy to fly well (self-levelling -// and height hold), and bumps never cut the motors (nobody is there to re-arm it). -inline Settings flight_settings(Settings cfg, const Tuning& t) { - cfg.angle_mode = true; - cfg.alt_hold = true; - cfg.max_angle_deg = std::clamp(t.max_lean_deg + 10.0f, 20.0f, 80.0f); - cfg.camera_uptilt_deg = t.uptilt_deg; - cfg.crash_speed = 0.0f; - cfg.range = 0.0f; - cfg.armed = true; - return cfg; -} - -// One tick: what the pilot does with the sticks. `drone` is the flight state, `position` the -// drone's, `target` the skater's position and `skater_forward` which way the skater faces. -inline Cmd fly(Pilot& p, const State& drone, const Vec3& position, const Vec3& target, const Vec3& skater_forward, - float seconds, const Settings& cfg, const Tuning& t, const World* world = nullptr) { - using detail::length; - using detail::sub; - const float dt = std::clamp(std::isfinite(seconds) ? seconds : 0.0f, 0.0f, 0.05f); - Cmd cmd; - cmd.hold = true; - cmd.throttle = detail::hover_of(cfg); - cmd.gimbal_tilt_rad = p.tilt; - if (!(dt > 0.0f)) return cmd; - - // What the skater is doing: velocity from its moves (a jump of more than a few metres in one - // tick is a respawn, not a move), and the way it is going. - if (!p.valid || length(sub(target, p.last_target)) > 8.0f) { - p.valid = true; - p.target_velocity = {0, 0, 0}; - Vec3 f{skater_forward[0], 0.0f, skater_forward[2]}; - p.heading = length(f) > 0.1f ? normalize(f) : Vec3{0, 0, -1}; - p.count = 0; - p.ride_y = target[1]; - p.tilt = std::clamp(std::atan2(target[1] + 0.9f - position[1], std::max(std::hypot(target[0] - position[0], target[2] - position[2]), 0.5f)), -1.2f, 0.35f); - p.climb_extra = p.blocked_for = p.floor_lift = 0; - p.history_count = 0; - p.history_clock = 0; - p.session = false; - p.nearby = {}; - } else { - const Vec3 now = scale(sub(target, p.last_target), 1.0f / dt); - const float k = 1.0f - std::exp(-dt / 0.15f); - p.target_velocity = add(p.target_velocity, scale(sub(now, p.target_velocity), k)); - // A pilot follows the lie of the ground, not every ollie: height is eased toward slowly, - // slower still going up (most rises are jumps that come straight back down). - const float tau = target[1] > p.ride_y ? 1.4f : 0.6f; - p.ride_y += (target[1] - p.ride_y) * (1.0f - std::exp(-dt / tau)); - } - p.last_target = target; - // Remembered, so the pilot can act on what it saw a reaction time ago. - for (std::size_t i = 0; i < p.count; ++i) p.seen[i].age += dt; - p.head = (p.head + 1) % p.seen.size(); - p.seen[p.head] = {0.0f, Vec3{target[0], p.ride_y, target[2]}, Vec3{p.target_velocity[0], 0.0f, p.target_velocity[2]}}; - p.count = std::min(p.count + 1, p.seen.size()); - Pilot::Seen seen = p.seen[p.head]; - for (std::size_t back = 0; back < p.count; ++back) { - const auto& s = p.seen[(p.head + p.seen.size() - back) % p.seen.size()]; - seen = s; - if (s.age >= t.reaction) break; - } - // Every 0.1 s: is it riding a session? - p.history_clock += dt; - if (p.history_clock >= 0.1f) { - p.history_clock = std::min(p.history_clock - 0.1f, 0.1f); - p.history_head = (p.history_head + 1) % p.history.size(); - p.history[p.history_head] = {target, Vec3{p.target_velocity[0], 0.0f, p.target_velocity[2]}}; - p.history_count = std::min(p.history_count + 1, p.history.size()); - read_session(p, position, t, world); - } - if (p.session) p.session_for += dt; - // Heading: the way the skater moves once it moves at all, else the way it faces; turned toward - // gently, as a pilot swings round behind. - { - Vec3 want{seen.velocity[0], 0.0f, seen.velocity[2]}; - if (length(want) < 1.5f) want = Vec3{skater_forward[0], 0.0f, skater_forward[2]}; - if (length(want) > 0.1f) { - const float k = 1.0f - std::exp(-dt * (length(seen.velocity) > 4.0f ? 2.2f : 0.8f)); - const Vec3 blended = add(p.heading, scale(sub(normalize(want), p.heading), k)); - if (length(blended) > 0.1f) p.heading = normalize(blended); - } - } - - // Where to be: behind the skater, or in a session the spot it films from. A wall between the - // skater and the spot behind brings the spot in to this side of it; one that leaves it too close, - // or stands between the drone and its spot, is climbed over (a little, and only if it lasts). - // The ground under the spot keeps it up. - Vec3 goal = p.session ? p.session_spot : spot(seen.position, seen.velocity, p.heading, t, p.climb_extra); - const Vec3 chest = add(seen.position, Vec3{0, 1.0f, 0}); - if (world && world->cast) { - Vec3 hit{}; - bool blocked = false; - if (!p.session) { - const Vec3 out = sub(goal, chest); - if (const float out_length = length(out); out_length > 1.0f) { - const Vec3 dir = scale(out, 1.0f / out_length); - if (world->cast(add(chest, scale(dir, 0.6f)), add(goal, scale(dir, t.margin)), hit)) { - const float room = length(sub(hit, chest)) - t.margin; - goal = add(chest, scale(dir, std::max(room, 0.0f))); - blocked = room < t.distance * 0.5f; - } - } - } - blocked = blocked || world->cast(position, goal, hit); - p.blocked_for = blocked ? p.blocked_for + dt : 0.0f; - p.climb_extra = p.blocked_for > 0.3f ? std::min(p.climb_extra + 1.5f * dt, 3.0f) : std::max(p.climb_extra - 0.8f * dt, 0.0f); - // Ground under the spot (a bank, a ledge) lifts it, quickly up and slowly back down. - Vec3 ground{}; - float lift = 0.0f; - if (world->cast(add(goal, Vec3{0, 2.0f, 0}), add(goal, Vec3{0, -(t.height + 6.0f), 0}), ground)) - lift = std::max(ground[1] + t.clearance - goal[1], 0.0f); - p.floor_lift += (lift - p.floor_lift) * (1.0f - std::exp(-dt / (lift > p.floor_lift ? 0.2f : 1.0f))); - goal[1] += p.floor_lift; - } - - // Speed: match the skater, closing the gap; then lean to get it, no further than a pilot would. - const Vec3 error = sub(goal, position); - // Closing a gap: no faster than it can still slow down from gently by the time it gets there, - // so it does not overshoot and have to stand on the brakes (nose up, the skater out of shot). - Vec3 want_velocity = p.session ? Vec3{0, 0, 0} : seen.velocity; - { - const Vec3 level{error[0], 0.0f, error[2]}; - if (const float d = length(level); d > 0.01f) { - const float close = std::min({t.gap_gain * d, std::sqrt(2.0f * t.brake * d), t.catch_up}); - want_velocity = add(want_velocity, scale(level, close / d)); - } - } - // Too close (the skater stopped short, or came back at it): back off as a pilot pulls away, - // rather than flying past and losing the shot. - { - // Measured from where the skater must be by now, not where it was seen a reaction ago. - const Vec3 now_at = add(seen.position, scale(seen.velocity, t.reaction)); - Vec3 away{position[0] - now_at[0], 0.0f, position[2] - now_at[2]}; - const float gap = length(away), keep = t.distance * 0.7f; - if (gap < keep) { - away = gap > 0.05f ? scale(away, 1.0f / gap) : scale(p.heading, -1.0f); - want_velocity = add(want_velocity, scale(away, (keep - gap) * 2.0f)); - } - } - const bool ceiling = world && world->cast && avoid(p, drone, position, want_velocity, t, *world); - Vec3 accel = scale(sub(want_velocity, drone.velocity), t.speed_gain); - const float g = std::max(cfg.gravity, 1.0f); - const float most = std::tan(std::clamp(t.max_lean_deg, 5.0f, 70.0f) * kDegToRad) * g; - { - Vec3 level{accel[0], 0.0f, accel[2]}; - if (const float a = length(level); a > most) { accel[0] *= most / a; accel[2] *= most / a; } - } - const Vec3 forward = [&] { - Vec3 f = scale(row(drone.body, 2), -1.0f); - f[1] = 0.0f; - return length(f) > 0.05f ? normalize(f) : Vec3{0, 0, -1}; - }(); - const Vec3 right{-forward[2], 0.0f, forward[0]}; - const float lean = std::clamp(cfg.max_angle_deg, 1.0f, 89.0f) * kDegToRad; - // Angle mode: pitch stick back (+) leans the nose up, so speeding up forward is stick forward. - const float want_pitch = clamp1(-std::atan(dot(accel, forward) / g) / lean); - const float want_roll = clamp1(std::atan(dot(accel, right) / g) / lean); - // Height: eased toward, a few metres a second at most. The ground under the drone and just - // ahead of where it is going comes first: it climbs off it (harder the closer it is, and as - // fast as it must to clear a rise ahead in time) and only sinks slowly near it, whatever its - // spot asks. - const float climb_rate = std::max(cfg.climb_rate, 1.0f); - float want_up = std::clamp(1.5f * error[1], -2.5f, 3.0f); // m/s - bool ground_close = false; - if (world && world->cast) { - const float reach = t.clearance + 4.0f; - float room = 1e9f, rise = -1e9f; - Vec3 hit{}; - if (world->cast(add(position, Vec3{0, 0.5f, 0}), add(position, Vec3{0, -reach, 0}), hit)) room = position[1] - hit[1]; - const Vec3 going{drone.velocity[0], 0.0f, drone.velocity[2]}; - if (const float speed = length(going); speed > 1.0f) { - constexpr float soon = 0.8f; // s ahead it looks - const Vec3 at = add(position, scale(going, std::min(soon, 10.0f / speed))); - if (world->cast(add(at, Vec3{0, 3.0f, 0}), add(at, Vec3{0, -reach, 0}), hit)) - rise = (hit[1] + t.clearance - position[1]) / soon; - } - if (room < t.clearance) want_up = std::max(want_up, (t.clearance - room) * 4.0f + 0.5f); - if (rise > 0.0f) want_up = std::max(want_up, rise); - if (want_up < 0.0f && room < t.clearance + 1.5f) want_up *= std::clamp((room - t.clearance) / 1.5f, 0.0f, 1.0f); - ground_close = room < t.clearance; - } - float want_climb = clamp1(std::min(want_up, 8.0f) / climb_rate); - if (ceiling) want_climb = std::min(want_climb, ground_close ? 0.0f : -1.0f / climb_rate); // something just above - // Yaw: keep the skater in the middle of the shot, at a pilot's turn rate. - float want_yaw{}; - { - Vec3 to{chest[0] - position[0], 0.0f, chest[2] - position[2]}; - if (length(to) > 0.5f) { - to = normalize(to); - const float across = dot(cross(forward, to), Vec3{0, 1, 0}); // + = the skater is to the left - const float angle = std::atan2(across, dot(forward, to)); - // Far off the middle of the shot the pilot snaps round faster. - const float gain = 3.0f + 3.0f * std::clamp(std::abs(angle) / 0.6f, 0.0f, 1.0f); - want_yaw = std::clamp(angle * gain, -t.max_yaw_rate, t.max_yaw_rate); - } - } - - // Hands: the sticks follow what the pilot wants smoothly, with a little wander. - p.wander_time -= dt; - if (p.wander_time <= 0.0f) { - p.wander_time = 0.4f + 0.5f * (detail::noise(p.noise) + 1.0f); - p.wander_to = {detail::noise(p.noise), detail::noise(p.noise), detail::noise(p.noise)}; - } - const float w = 1.0f - std::exp(-dt / 0.35f); - p.wander_now = add(p.wander_now, scale(sub(p.wander_to, p.wander_now), w)); - const float h = 1.0f - std::exp(-dt / std::max(t.hands, 0.01f)); - p.pitch += (want_pitch + t.wander * p.wander_now[0] - p.pitch) * h; - p.roll += (want_roll + t.wander * p.wander_now[1] - p.roll) * h; - p.yaw += (want_yaw * (1.0f + 0.15f * p.wander_now[2]) - p.yaw) * h; - p.climb += (want_climb - p.climb) * h; - if (ground_close) p.climb = std::max(p.climb, want_climb); // (no easing into the ground) - - cmd.pitch = clamp1(p.pitch); - cmd.roll = clamp1(p.roll); - cmd.yaw_rad = p.yaw * dt; // + = turn left - cmd.climb = clamp1(p.climb); - // The camera sits on a tilt gimbal, eased toward the skater: leaning to speed up or slow down - // no longer swings the shot to the ground or the sky. It follows half of a jump; in a session - // all of it (the airs are the shot). - { - const float aim = p.ride_y + (p.session ? 1.0f : 0.5f) * (target[1] - p.ride_y); - const Vec3 to = sub(Vec3{target[0], aim + 0.9f, target[2]}, position); - const float level = std::hypot(to[0], to[2]); - const float want_tilt = std::clamp(std::atan2(to[1], std::max(level, 0.5f)), -1.2f, 0.6f); - p.tilt += (want_tilt - p.tilt) * (1.0f - std::exp(-dt / std::max(t.tilt_ease, 0.05f))); - cmd.gimbal_tilt_rad = p.tilt; - } - return cmd; -} -} // namespace dingosdk::drone::follow diff --git a/Extension/Skater/drone_font.h b/Extension/Skater/drone_font.h deleted file mode 100644 index 2d2e4a5c..00000000 --- a/Extension/Skater/drone_font.h +++ /dev/null @@ -1,64 +0,0 @@ -#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 deleted file mode 100644 index c3222a2a..00000000 --- a/Extension/Skater/drone_osd.h +++ /dev/null @@ -1,112 +0,0 @@ -#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); - // The goggle display as a whole (`osd`): off hides every readout. The range warnings below - // still show, since flying on without them loses the drone. - const bool osd = knob(kOsd) >= 0.5f; - add(osd && knob(kShowBattery) >= 0.5f, m, m, Align::left, false, std::format("{:.1f}V", volts), low && blink); - const bool armed_now = t.armed.load(std::memory_order_relaxed); - add(osd && knob(kShowArmed) >= 0.5f, 0.5f, m, Align::centre, false, armed_now ? "ARMED" : "DISARMED", !armed_now); - add(osd && knob(kShowTimer) >= 0.5f, 1.0f - m, m, Align::right, false, std::format("{:02}:{:02}", total / 60, total % 60)); - add(osd && knob(kShowThrottle) >= 0.5f, m, 1.0f - m, Align::left, true, std::format("THR {:.0f}%", throttle * 100.0f)); - add(osd && knob(kShowAlt) >= 0.5f, 0.5f, 1.0f - m, Align::centre, true, std::format("ALT {:.1f}M", altitude)); - add(osd && knob(kShowSpeed) >= 0.5f, 1.0f - m, 1.0f - m, Align::right, true, std::format("{:.0f}KM/H", speed * 3.6f)); - // A crash cut the motors: say how to fly on. (Shown with or without the goggle display.) - const bool crashed = t.crashed.load(std::memory_order_relaxed); - if (crashed) add(true, 0.5f, 0.42f, Align::centre, false, "CRASHED - ARM TO FLY AGAIN", true); - // 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(osd, 1.0f - m, m + 0.05f, Align::right, false, - std::format("RSSI {:.0f}% {:.0f}M", signal * 100.0f, t.home_distance.load(std::memory_order_relaxed)), signal < 0.35f && blink); - if (t.failsafe.load(std::memory_order_relaxed)) add(true, 0.5f, crashed ? 0.47f : 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); - // A throttle stick that springs back is shown where it is; one that stays put, by the throttle. - const float stick = t.stick_throttle.load(std::memory_order_relaxed); - out.sticks.throttle = stick >= -1.0f ? std::clamp(stick, -1.0f, 1.0f) : 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 deleted file mode 100644 index c29cba2b..00000000 --- a/Extension/Skater/drone_outfit.cpp +++ /dev/null @@ -1,164 +0,0 @@ -#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 deleted file mode 100644 index 74ede703..00000000 --- a/Extension/Skater/drone_outfit.h +++ /dev/null @@ -1,31 +0,0 @@ -#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 deleted file mode 100644 index ad90dc07..00000000 --- a/Extension/Skater/drone_radio.h +++ /dev/null @@ -1,340 +0,0 @@ -#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{true}; // with nothing plugged in, the drone flies with the keyboard - 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) - bool centred = false; // the throttle stick springs back to the middle (a gamepad's) - float climb = 0; // that stick, -1..1 past its dead zone: what height hold reads -}; - -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.centred = centred_throttle; - if (centred_throttle) out.climb = deadzone(pick_axis(axes, map[2]), dz); - out.throttle = centred_throttle ? spring_throttle(out.climb, 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 page'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 page runs. Overlay thread only; the game thread reads calibrating(). -inline Calibration& calibration() { static Calibration value; return value; } -// While the setup is on screen the game holds the drone still instead of flying it with the sticks. -// The DRONE page marks each frame it shows the setup; leaving the tab or closing the menu lets -// the drone fly again within half a second, even with the setup unfinished. -inline std::atomic& calibration_seen() { static std::atomic value{0}; return value; } -inline std::int64_t calibration_clock() { - 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/Skater/motor_synth.h b/Extension/Skater/motor_synth.h new file mode 100644 index 00000000..620ded53 --- /dev/null +++ b/Extension/Skater/motor_synth.h @@ -0,0 +1,56 @@ +#pragma once +// A synthesised motor: four detuned rotors and a rush of wind, its pitch following the throttle with a +// little lag, as real motors spin up. Plain C++, testable anywhere. Plugins' motor sounds +// (reskate.audio "motor", Extension/Plugins/plugin_sound.cpp) play it. +#include +#include +#include + +namespace dingosdk::audio { +class MotorSynth { +public: + static constexpr int rate = 44100; // plugins' sounds play at 16 kHz + explicit MotorSynth(int sample_rate = rate) + : rate_(sample_rate > 0 ? sample_rate : rate), + // the wind's low-pass at about 900 Hz whatever the rate (0.12 a sample at 44.1 kHz) + wind_k_(1.0f - std::exp(-6.2831853f * 900.0f / static_cast(rate_))) {} + // 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 * static_cast(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] / static_cast(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_) * wind_k_; + 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: + int rate_; + float wind_k_; + float rpm_ = 0, lp_ = 0; + float phase_[4] = {0, 0.25f, 0.5f, 0.75f}; + std::uint32_t seed_ = 12345u; +}; +} // namespace dingosdk::audio diff --git a/Extension/Skater/no_bail.cpp b/Extension/Skater/no_bail.cpp index 8d2c3eb5..a3e7fb9d 100644 --- a/Extension/Skater/no_bail.cpp +++ b/Extension/Skater/no_bail.cpp @@ -1,5 +1,5 @@ #include "no_bail.h" -#include "Extension/BoardBreak/board_break.h" +#include "Extension/Skater/skater_events.h" #include "Engine/Core/Hooks/hooks.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/memory.h" @@ -192,7 +192,9 @@ bool suppress_cause(std::uintptr_t causes, std::int32_t reason, std::uintptr_t c } void record_cause(std::uintptr_t causes, std::int32_t reason, float magnitude) { const auto caller = reinterpret_cast(_ReturnAddress()); - if (causes == state_watch().causes.load(std::memory_order_acquire)) board_break::note_cause(reason, magnitude); + if (causes == state_watch().causes.load(std::memory_order_acquire)) { + skater_events::note_cause(reason, magnitude); + } const bool protect = suppress_cause(causes, reason, caller); // Do not force the native recovery/stumble predicate (recovery_predicate). Its // result is also exported to animation at +0x9e, even without a collision. @@ -250,7 +252,7 @@ std::uint32_t choose_state(std::uintptr_t selector, std::uint32_t current) { w.changes.fetch_add(1, std::memory_order_relaxed); if (chosen == wipeout_physics_state) { // Before the count: the tick that sees the new wipeout finds its cause there. - board_break::note_wipeout(w.causes.load(std::memory_order_acquire)); + skater_events::note_wipeout(w.causes.load(std::memory_order_acquire)); w.wipeouts.fetch_add(1, std::memory_order_relaxed); } } diff --git a/Extension/Skater/outfit_overlay.cpp b/Extension/Skater/outfit_overlay.cpp new file mode 100644 index 00000000..dc0382c7 --- /dev/null +++ b/Extension/Skater/outfit_overlay.cpp @@ -0,0 +1,205 @@ +#include "outfit_overlay.h" +#include "Extension/Skater/skater_items.h" +#include "Extension/Customization/local_customization_runtime.h" +#include "Extension/Multiplayer/Hud/game_ui_state.h" +#include "Extension/Skater/no_bail.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/content_catalogs.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dingosdk::outfit_overlay { +namespace { +using skater_items::SkaterItems; +using skater_items::lower; +constexpr std::string_view allowed[] = {"tattoo", "moles", "eyewear"}; + +struct Wanted { + std::string owner, item; + std::vector placement; + bool covers{}; +}; +struct State { + std::map wanted; // by slot name + std::map found; // installed items by ending + bool own{}, dirty{}, worn{}; + std::uintptr_t skater{}, component{}, slots_of{}; + std::map slot_index; // by slot name, for slots_of + std::optional feed; // how the game fed the skater before a recipe of ours + std::set ours; // items we put on + std::uint64_t given_back{}, menu_seen{}, checked{}, applied{}, last_error{}; + std::string status{"nothing worn"}; +}; +State& state() { + static auto* s = new State; + return *s; +} +bool blank(const std::string& asset) { return asset.empty() || lower(asset).ends_with("_empty"); } +int slot_of(State& s, const SkaterItems& m, std::uintptr_t resource, const std::string& wanted) { + if (s.slots_of != resource) { + s.slot_index.clear(); + s.slots_of = 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}; + s.slot_index.emplace(lower(m.text(m.ptr(slot, 0x20))), static_cast(i)); + } + } + for (const auto& [name, index] : s.slot_index) + if (name.find(wanted) != std::string::npos) return index; + return -1; +} +void wear(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { + const auto component = m.component(skater); + auto recipe = m.capture(skater); + const auto resource = m.resource(component); + bool marked{}; + for (const auto& item : recipe.items) marked |= s.ours.contains(item.asset); + if (const auto fed = skater_items::feed(m, component); fed.follows_outfit()) { + SkaterItems::check(!marked || now - s.given_back > 3000, "Waiting for the game to put the outfit back."); + s.feed = fed; + } else { + SkaterItems::check(s.feed && (marked || component == s.component), "The skater does not follow its outfit: left alone."); + } + bool changed{}, worn{}; + for (const auto& [slot_name, want] : s.wanted) { + const int i = slot_of(s, m, resource, slot_name); + if (i < 0 || static_cast(i) >= recipe.items.size()) continue; + auto& slot = recipe.items[static_cast(i)]; + if (!want.covers && !blank(slot.asset) && !s.ours.contains(slot.asset)) continue; // the player's own stays + worn = true; + auto parameters = slot.parameters; + for (std::size_t k = 0; k < want.placement.size() && k < parameters.size(); ++k) parameters[k] = std::bit_cast(want.placement[k]); + if (slot.asset == want.item && slot.parameters == parameters) continue; + slot.asset = want.item; + slot.parameters = std::move(parameters); + s.ours.insert(want.item); + changed = true; + } + s.worn = worn; + if (!changed) return; + skater_items::apply(m, component, recipe); + s.own = true; + s.component = component; + s.applied = now; +} +void give_back(State& s, const SkaterItems& m, std::uintptr_t skater, std::uint64_t now) { + const auto component = m.component(skater); + if (const auto fed = skater_items::feed(m, component); fed.follows_outfit() || s.feed) { + skater_items::hand_back(m, component, fed.follows_outfit() ? fed : *s.feed); + s.given_back = now; + } + s.own = s.worn = false; +} +} + +bool allowed_slot(std::string_view slot) noexcept { + const auto name = lower(slot); + return std::any_of(std::begin(allowed), std::end(allowed), [&](std::string_view a) { return name.find(a) != std::string::npos; }); +} + +std::string set(std::string owner, std::string slot, std::string item, std::vector placement, bool covers) { + auto& s = state(); + slot = lower(slot); + if (!allowed_slot(slot)) return "only tattoo, moles and eyewear slots"; + if (const auto taken = s.wanted.find(slot); taken != s.wanted.end() && taken->second.owner != owner) return taken->second.owner + " has that slot"; + if (!content_cache::catalogs().available) return "the paid items are not known yet; try again in a moment"; + if (profile_runtime::reserved_cosmetic(item)) return "that is a paid store item"; + if (placement.size() > 8 || !std::all_of(placement.begin(), placement.end(), [](float v) { return std::isfinite(v) && std::abs(v) <= 100; })) + return "a placement is at most 8 finite numbers"; + s.wanted[slot] = {std::move(owner), std::move(item), std::move(placement), covers}; + s.dirty = true; + return {}; +} +void clear(std::string_view owner, std::string_view slot) { + auto& s = state(); + const auto name = lower(slot); + if (std::erase_if(s.wanted, [&](const auto& w) { return w.second.owner == owner && (name.empty() || w.first == name); })) s.dirty = true; +} +std::string find_item(std::string_view ending) { + auto& s = state(); + const auto key = lower(ending); + if (const auto known = s.found.find(key); known != s.found.end()) return known->second; + NoBailSkater skater; + if (!no_bail_skater(skater)) return {}; + try { + auto name = skater_items::installed_item(SkaterItems{skater_items::readable, skater.base}, key); + if (s.found.size() < 512) s.found.emplace(key, name); + return name; + } catch (...) { + return {}; + } +} +std::vector slots() { + std::vector out; + NoBailSkater skater; + if (!no_bail_skater(skater)) return out; + try { + const SkaterItems m{skater_items::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); + for (std::size_t i = 0; i < count; ++i) { + const auto slot = m.ptr(table, i * 24 + 8) & ~std::uintptr_t{4}; + Slot view{m.text(m.ptr(slot, 0x20)), i < recipe.items.size() ? recipe.items[i].asset : std::string{}, {}}; + if (i < recipe.items.size()) + for (const auto word : recipe.items[i].parameters) view.parameters.push_back(std::bit_cast(word)); + out.push_back(std::move(view)); + } + } catch (...) { + } + return out; +} + +void tick() noexcept { + auto& s = state(); + if (s.wanted.empty() && !s.own) return; + try { + NoBailSkater skater; + if (!no_bail_skater(skater)) { + // A respawn or a level change: the next skater comes from the profile, with nothing of ours. + s.skater = s.slots_of = 0; + s.own = s.worn = false; + s.feed.reset(); + s.dirty = true; + return; + } + if (skater.entity != s.skater) { + s.skater = skater.entity; + s.own = false; + s.feed.reset(); + s.dirty = true; + } + const auto now = GetTickCount64(); + const SkaterItems m{skater_items::readable, skater.base}; + const bool menu = multiplayer::sample_game_ui_state(skater.base).in_menu; + if (menu) s.menu_seen = now; + if (s.wanted.empty() || menu) { + if (s.own) give_back(s, m, skater.entity, now); + s.status = s.wanted.empty() ? "nothing worn" : "a game menu is up: the outfit is the game's"; + s.dirty = true; + return; + } + // Not before the game has had a second after a menu, nor more than once a second; while worn, + // a look every 3 s whether the game put its own items over ours. + if (now - s.given_back < 300 || now - s.menu_seen < 1000 || now - s.applied < 1000 || !(s.dirty || (s.worn && now - s.checked > 3000))) + return; + s.checked = now; + wear(s, m, skater.entity, now); + s.dirty = false; + s.status = std::format("{} item(s) worn", s.wanted.size()); + } catch (const std::exception& error) { + s.status = std::string("waiting: ") + error.what(); + } catch (...) { + } +} +std::string status() { return state().status; } +} diff --git a/Extension/Skater/outfit_overlay.h b/Extension/Skater/outfit_overlay.h new file mode 100644 index 00000000..0ce6e786 --- /dev/null +++ b/Extension/Skater/outfit_overlay.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +#include + +// Items worn over the local skater's outfit, for plugins (reskate.outfit): the way Road Rash wore +// its marks before it became a plugin, as one service every plugin shares. Each +// owner puts an item in a slot of the skater's recipe (by the slot's name: "tattoo_legl", "moles", +// "eyewear"); the skater wears them over the outfit through the game's own recipe copy, and is given +// back to the game while one of its menus is up (the menus change the outfit), when nothing is worn, +// and when a plugin stops. Only the face and skin layers (tattoos, moles) and eyewear, and never a +// paid store item. Others see what the skater wears. Client thread throughout. +namespace dingosdk::outfit_overlay { +// The slots items may go in: a slot's name holds one of these. +bool allowed_slot(std::string_view slot) noexcept; +// `placement`, when given, is written over the item's first parameters (as floats): where a tattoo +// sits. `covers`: it goes over the player's own item in the slot (else only over an empty one). +// Empty when it is taken, otherwise why not. +std::string set(std::string owner, std::string slot, std::string item, std::vector placement, bool covers); +void clear(std::string_view owner, std::string_view slot = {}); +// The installed cosmetic whose name ends in `ending` (any case), or empty. +std::string find_item(std::string_view ending); +struct Slot { + std::string name, item; + std::vector parameters; +}; +// The skater's recipe slots now; empty without a skater. +std::vector slots(); +// Each client tick. +void tick() noexcept; +std::string status(); +} diff --git a/Extension/Skater/score_hud.cpp b/Extension/Skater/score_hud.cpp index 0750188e..4bc1ec11 100644 --- a/Extension/Skater/score_hud.cpp +++ b/Extension/Skater/score_hud.cpp @@ -28,12 +28,16 @@ struct Watched { const char* name; std::uint32_t hash; }; -enum : std::size_t { line_score, is_slam, multiplier, action_active }; -constexpr std::array watched{{ +enum : std::size_t { line_score, is_slam, multiplier, action_active, sequence_header, sequence_descriptor, sequence_state }; +constexpr std::array watched{{ {"LineScore", 1086322279u}, {"IsSlam", 2943226809u}, {"Multiplier", 1782700230u}, {"ActionActive", 2058053468u}, + // LabelViewModels inside the model: each one's Text (a string, the game's text value) first. + {"SequenceHeader", 3018447676u}, + {"SequenceDescriptor", 3894822438u}, + {"SequenceState", 2134799591u}, }}; constexpr std::size_t value_bytes = 16; // read per field @@ -65,6 +69,35 @@ float score_of(const Bound& field) { return static_cast(i); } bool flag_of(const Bound& field) { return field.present && field.read && field.now[0] != 0; } +// A label's text: the game's text value is a pointer to its characters. Printable text only, so a +// value that is something else reads as none. +std::string text_of(const Bound& field) { + if (!field.present || !field.read || field.size < 8) return {}; + Address at{}; + std::memcpy(&at, field.now.data(), sizeof at); + char text[96]{}; + const auto length = memory::peek_cstring(at, text, sizeof text); + if (length <= 0) return {}; + for (std::ptrdiff_t i = 0; i < length; ++i) + if (static_cast(text[i]) < 0x20) return {}; + return std::string(text, static_cast(length)); +} +std::uint32_t enum_of(const Bound& field) { + if (!field.present || !field.read || field.size < 4) return 0; + std::uint32_t value{}; + std::memcpy(&value, field.now.data(), 4); + return value; +} +// (What the sequence labels say as they change, into the log while manuals are worked out.) +void trace_sequence(const ScoreHud& now) { + static std::string last; + static int said{}; + const auto text = std::format("\"{}\" / \"{}\" state {}", now.header, now.descriptor, now.sequence_state); + if (text == last || said >= 200) return; + last = text; + ++said; + logging::write(logging::Level::info, logging::Channel::skater, "Score HUD sequence: " + text); +} // Runs `use(context)` on the game's UI model registry under its model lock (as Hall of Meat's // HUD code does). False while there is no registry. @@ -136,6 +169,17 @@ std::optional read_score_hud(std::uintptr_t image_base) noexcept { } if (!ok) return std::nullopt; const auto& f = h.fields; - return ScoreHud{flag_of(f[action_active]), flag_of(f[is_slam]), score_of(f[line_score]), score_of(f[multiplier])}; + ScoreHud result{flag_of(f[action_active]), flag_of(f[is_slam]), score_of(f[line_score]), score_of(f[multiplier]), + text_of(f[sequence_header]), text_of(f[sequence_descriptor]), enum_of(f[sequence_state])}; + trace_sequence(result); + return result; +} + +bool names_manual(const ScoreHud& now) { + const auto says = [](std::string text) { + for (auto& c : text) c = static_cast(c >= 'A' && c <= 'Z' ? c + 32 : c); + return text.find("manual") != std::string::npos; + }; + return now.action && (says(now.header) || says(now.descriptor)); } } diff --git a/Extension/Skater/score_hud.h b/Extension/Skater/score_hud.h index 495805f1..2eaa078a 100644 --- a/Extension/Skater/score_hud.h +++ b/Extension/Skater/score_hud.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include // skate.'s score HUD as values (ScoringHUDViewModel, read through the game's UI models), what // trick_watch.h turns into tricks. Moved from Domination (PR 279). @@ -9,7 +10,13 @@ struct ScoreHud { bool action{}; // a trick is going on bool slam{}; float line_score{}, multiplier{}; + // The sequence's header ("Kickflip", "Manual"...) and descriptor labels as the HUD shows them, + // and its sequence state: empty, or 0, when this build's model does not have them readable. + std::string header, descriptor; + std::uint32_t sequence_state{}; }; +// Whether the HUD names a manual going on (its header or descriptor says one). +bool names_manual(const ScoreHud& now); // Client thread. Empty while there is no score HUD model (the menu, a level loading), or when this // game build's model has none of the fields; the model is looked for at most once a second. std::optional read_score_hud(std::uintptr_t image_base) noexcept; diff --git a/Extension/Skater/skater_animation.cpp b/Extension/Skater/skater_animation.cpp new file mode 100644 index 00000000..af3c2449 --- /dev/null +++ b/Extension/Skater/skater_animation.cpp @@ -0,0 +1,657 @@ +#include "skater_animation.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Game/Build/20260929/no_bail.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include "Extension/Multiplayer/Remote/native_pose_layout.h" +#include "Extension/Multiplayer/Remote/native_skater.h" +#include +#include +#include +#include +#include +#include +#include + +// The pose paths of Animation Studio (PR 297, anim_studio.cpp: on_animation, on_render, +// on_board_render, on_send), for clips plugins play; see the header. +namespace dingosdk::skater_animation { +namespace { +using namespace anim_studio; +using logging::Channel; +using logging::Level; + +// ---- guarded memory -------------------------------------------------------------------------------- +constexpr std::uintptr_t highest_user = 0x00007fffffffffffULL; +bool peek(std::uintptr_t address, void* out, std::size_t size) noexcept { + if (address < 0x10000 || address > highest_user - size) return false; + __try { + std::memcpy(out, reinterpret_cast(address), size); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +bool poke(std::uintptr_t address, const void* data, std::size_t size) noexcept { + if (address < 0x10000 || address > highest_user - size) return false; + __try { + std::memcpy(reinterpret_cast(address), data, size); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +template std::optional read(std::uintptr_t address) noexcept { + T value{}; + if (!peek(address, &value, sizeof(T))) return std::nullopt; + return value; +} +struct alignas(16) NativeBone { + std::array scale, rotation, position; +}; +static_assert(sizeof(NativeBone) == 0x30); + +double now_seconds() noexcept { + static const double frequency = [] { + LARGE_INTEGER f{}; + QueryPerformanceFrequency(&f); + return static_cast(f.QuadPart); + }(); + LARGE_INTEGER c{}; + QueryPerformanceCounter(&c); + return static_cast(c.QuadPart) / frequency; +} + +// ---- transforms ------------------------------------------------------------------------------------ +// One transform after another (parent, then child in the parent's frame); uniform scale. +Joint compose(const Joint& parent, const Joint& child) noexcept { + const float k = (parent.scale[0] + parent.scale[1] + parent.scale[2]) / 3.0f; + const auto moved = rotate(parent.rotation, {child.position[0] * k, child.position[1] * k, child.position[2] * k}); + Joint out; + out.position = {parent.position[0] + moved[0], parent.position[1] + moved[1], parent.position[2] + moved[2]}; + out.rotation = normalised(multiply(parent.rotation, child.rotation)); + const float c = (child.scale[0] + child.scale[1] + child.scale[2]) / 3.0f; + out.scale = {k * c, k * c, k * c}; + return out; +} +// `world` seen from `parent`'s frame. +Joint relative(const Joint& parent, const Joint& world) noexcept { + const float k = std::max((parent.scale[0] + parent.scale[1] + parent.scale[2]) / 3.0f, 1e-4f); + const auto back = conjugate(parent.rotation); + const auto d = rotate(back, {world.position[0] - parent.position[0], world.position[1] - parent.position[1], world.position[2] - parent.position[2]}); + Joint out; + out.position = {d[0] / k, d[1] / k, d[2] / k}; + out.rotation = normalised(multiply(back, world.rotation)); + const float c = (world.scale[0] + world.scale[1] + world.scale[2]) / 3.0f; + out.scale = {c / k, c / k, c / k}; + return out; +} +Joint joint_of(const NativeBone& b) noexcept { + Joint j; + j.scale = {b.scale[0], b.scale[1], b.scale[2]}; + j.rotation = b.rotation; + j.position = {b.position[0], b.position[1], b.position[2]}; + return j; +} +// The board's rig (board_pose.h): 16 bones, 0 its reference, 1 its place in the world, 2 the deck's +// mount, which carries the deck, the trucks and the wheels. +constexpr std::uint32_t board_bones = 16; +constexpr std::size_t board_mount = 2; +Joint board_parent(const std::array& b) noexcept { return compose(joint_of(b[0]), joint_of(b[1])); } + +// The body: Hips (7) and every joint below it. A clip replaces these only, never the placement +// joints the game moves the skater with, nor the board's bones in the skater's rig. +const std::array& body_joints() { + static const auto mask = [] { + std::array m{}; + for (std::uint16_t i = 0; i < skeleton_joints; ++i) + for (int at = i; at >= 0; at = parent(static_cast(at))) + if (at == hips_joint) { + m[i] = true; + break; + } + return m; + }(); + return mask; +} + +// ---- the skater ------------------------------------------------------------------------------------ +// The local skater's physics state (no_bail.cpp's selector): component +0x70 the core, core +0x3c0 its +// context, context +0x1414 the state. In the air on the board: 200, 201. +std::optional in_the_air(std::uintptr_t base, std::uintptr_t component) noexcept { + const auto core = read(component + 0x70); + if (!core || !*core) return std::nullopt; + const auto type = read(*core); + if (!type || *type != base + addr::no_bail::bail_core_vtable) return std::nullopt; + const auto context = read(*core + 0x3c0); + if (!context || !*context) return std::nullopt; + const auto value = read(*context + addr::skater_state::physics_state_offset); + if (!value) return std::nullopt; + return std::find(addr::skater_state::board_air_states.begin(), addr::skater_state::board_air_states.end(), *value) != + addr::skater_state::board_air_states.end(); +} +// The game's name for the trick the skater is doing ("ollie", "kickflip"): the animation holder +// (component +0xa0) -> +0x20 -> +0x250 -> +0x98, a C string (found 2026-10-18 for PR 297; right from +// 0.05 s after takeoff and through the whole air). Empty when the chain or the text looks wrong. +std::string trick_of(std::uintptr_t component) noexcept { + const auto holder = read(component + 0xa0).value_or(0); + const auto a = holder ? read(holder + 0x20).value_or(0) : 0; + const auto b = a ? read(a + 0x250).value_or(0) : 0; + const auto c = b ? read(b + 0x98).value_or(0) : 0; + char text[49]{}; + if (!c || !peek(c, text, 48)) return {}; + std::string name; + for (std::size_t i = 0; i < 48 && text[i]; ++i) { + const char ch = text[i]; + if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == ' ' || ch == '-')) return {}; + name += static_cast(ch >= 'A' && ch <= 'Z' ? ch + 32 : ch); + } + return name.size() >= 2 ? name : std::string{}; +} +// The board's upward speed (m/s): its physics' root body (skater_state.h). +std::optional board_up_speed(std::uintptr_t base, std::uintptr_t component) noexcept { + namespace st = addr::skater_state; + const auto core = read(component + 0x70).value_or(0); + const auto holder = core ? read(core + st::board_holder_offset).value_or(0) : 0; + const auto physics = holder ? read(holder + st::board_physics_offset).value_or(0) : 0; + if (!physics || read(physics).value_or(0) != base + st::board_physics_vtable) return std::nullopt; + const auto bodies = read(physics + st::physics_bodies_offset).value_or(0); + if (!bodies || read(bodies).value_or(0) != st::board_body_count) return std::nullopt; + if (read(bodies + st::body_owner_offset).value_or(0) != physics) return std::nullopt; + const auto v = read>(bodies + st::body_velocity_offset); + if (!v || !std::isfinite((*v)[1])) return std::nullopt; + return (*v)[1]; +} +std::string pop_of(float air) { + if (!(air > 0)) return {}; + return air < 0.45f ? "low" : air < 0.65f ? "mid" : "high"; +} + +// ---- what the client thread asks ------------------------------------------------------------------- +struct Plan { + std::optional play; + double started{}; + std::uint64_t serial{}; + std::vector replacements; + bool allowed{}; + float record_seconds{}; // > 0: record this long, from the next update + std::uint64_t record_serial{}; +}; +std::mutex plan_mutex; +std::shared_ptr plan_now = std::make_shared(); +Plan plan_draft; // the client thread's, published as plan_now +std::shared_ptr plan() { + std::lock_guard lock(plan_mutex); + return plan_now; +} +void publish() { + auto next = std::make_shared(plan_draft); + std::lock_guard lock(plan_mutex); + plan_now = std::move(next); +} +std::atomic finished_serial{}; // the play the animation thread saw finish +std::mutex recorded_mutex; +std::shared_ptr recorded; // under recorded_mutex +std::atomic record_running{}; + +// ---- the animation thread's own ---------------------------------------------------------------------- +struct Live { + std::atomic base{}, component{}, board_holder{}; + std::vector bones; + std::vector pose, custom; + std::array board{}; + bool board_ok{}; + // A game trick played as a replacement. + bool game_air{}; + double game_takeoff{}, game_landing{-1}; + int game_ground{}; + float predicted_air{}; + const Replacement* game_trick{}; + std::shared_ptr game_plan; // keeps game_trick alive + // Recording. + std::atomic record_serial{}; + double record_start{-1}, record_until{}; + Clip taking; + bool taking_board{true}, was_air{}; + std::uint64_t failures{}; +}; +Live& live() { + static auto* l = new Live; + return *l; +} +// The pose to draw, from the animation thread to the render publication. +struct Pending { + std::mutex mutex; + std::uintptr_t buffer{}; + std::uint32_t first{}, last{}; + std::vector bones; // [first, last) + double at{}; +}; +Pending& pending() { + static auto* p = new Pending; + return *p; +} +// Where the board's mount goes in the world, and how much of that. +struct PendingBoard { + std::mutex mutex; + Joint mount; + float weight{}; + double at{}; +}; +PendingBoard& pending_board() { + static auto* p = new PendingBoard; + return *p; +} +// What other players get of the local skater: the same joints as the renderer. +struct SentPose { + std::mutex mutex; + std::uintptr_t component{}; + std::uint32_t first{}, last{}; + std::vector joints; + double at{}; +}; +SentPose& sent_pose() { + static auto* value = new SentPose; + return *value; +} + +void finish_recording(Live& l) { + record_running.store(false, std::memory_order_release); + l.record_start = -1; + auto clip = std::make_shared(std::move(l.taking)); + l.taking = {}; + if (clip->times.size() < 2) return; + if (!l.taking_board) clip->board.clear(); + std::lock_guard lock(recorded_mutex); + recorded = std::move(clip); +} +void record_frame(Live& l, double at, const std::optional& air) { + if (l.record_start < 0) { + l.record_start = at; + l.taking = {}; + l.taking.joints = skeleton_joints; + l.taking_board = true; + l.was_air = air && *air; + } + const float t = static_cast(at - l.record_start); + l.taking.times.push_back(t); + l.taking.frames.insert(l.taking.frames.end(), l.pose.begin(), l.pose.begin() + skeleton_joints); + const auto placement = world(l.pose, placement_joint); + if (l.board_ok) l.taking.board.push_back(relative(placement, compose(board_parent(l.board), joint_of(l.board[board_mount])))); + else l.taking_board = false; + const bool up = air && *air; + if (up && !l.was_air && l.taking.takeoff < 0) l.taking.takeoff = t; + if (!up && l.was_air && l.taking.takeoff >= 0 && l.taking.landing < 0) l.taking.landing = t; + l.was_air = up; + if (at >= l.record_until || l.taking.times.size() >= 900) finish_recording(l); +} + +// ---- the animation thread ------------------------------------------------------------------------ +void on_animation(std::uintptr_t component) noexcept { + auto& l = live(); + if (!component || component != l.component.load(std::memory_order_acquire)) return; + const auto error = GetLastError(); + try { + const auto snap = plan(); + const bool wants_record = snap->record_seconds > 0 && snap->record_serial != l.record_serial.load(std::memory_order_acquire); + const bool busy = record_running.load(std::memory_order_acquire) || wants_record; + if (!busy && (!snap->allowed || (!snap->play && snap->replacements.empty()))) { + std::lock_guard lock(pending().mutex); + pending().bones.clear(); + SetLastError(error); + return; + } + const auto base = l.base.load(std::memory_order_acquire); + const auto holder = read(component + 0xa0); + if (!holder || !*holder) throw std::runtime_error("the skater has no animation yet"); + const auto layout = multiplayer::read_native_pose_layout(peek, base, *holder, 512); + if (!layout.buffer) throw std::runtime_error("the skater's pose is still loading"); + if (layout.count != skeleton_joints) + throw std::runtime_error(std::format("the skater has {} joints, not the standard {}", layout.count, skeleton_joints)); + l.board_ok = false; + if (const auto bh = l.board_holder.load(std::memory_order_acquire)) { + const auto bl = multiplayer::read_native_pose_layout(peek, base, bh, 64); + l.board_ok = bl.buffer && bl.count == board_bones && peek(bl.buffer, l.board.data(), sizeof(l.board)) && + finite(compose(board_parent(l.board), joint_of(l.board[board_mount]))); + } + l.bones.resize(layout.count); + if (!peek(layout.buffer, l.bones.data(), l.bones.size() * sizeof(NativeBone))) throw std::runtime_error("the skater's pose is unreadable"); + l.pose.resize(layout.count); + for (std::size_t i = 0; i < layout.count; ++i) l.pose[i] = joint_of(l.bones[i]); + const double at = now_seconds(); + const auto air = in_the_air(base, component); + // Recording: the pose as the game animated it, before anything here changes it. + if (wants_record) { + l.record_serial.store(snap->record_serial, std::memory_order_release); + l.record_start = -1; + l.record_until = at + std::clamp(snap->record_seconds, 0.2f, 10.0f); + record_running.store(true, std::memory_order_release); + } + if (record_running.load(std::memory_order_acquire)) record_frame(l, at, air); + + const Clip* clip = nullptr; + float t = -1, weight = 0; + if (snap->allowed && snap->play && snap->play->clip && !snap->play->clip->empty()) { + // A clip played in place from when it was asked for. + const auto& p = *snap->play; + clip = p.clip.get(); + const float elapsed = static_cast(at - snap->started); + const float blend = std::max(p.blend, 0.0f); + const float run = elapsed * std::max(p.speed, 0.05f); + const float length = clip->duration(); + if (p.loop && length > 0) t = std::fmod(run, length); + else t = std::min(run, length); + weight = blend > 0 ? std::clamp(elapsed / blend, 0.0f, 1.0f) : 1.0f; + if (!p.loop && run >= length) { + const float after = (run - length) / std::max(p.speed, 0.05f); + weight = std::min(weight, blend > 0 ? 1 - std::clamp(after / blend, 0.0f, 1.0f) : 0.0f); + if (weight <= 0) finished_serial.store(snap->serial, std::memory_order_release); + } + } else if (snap->allowed && !snap->replacements.empty() && air) { + // A game trick with a replacement: the clip plays on the body from the takeoff, blended in + // and out, its air stretched to this one's (anim_studio.cpp's play in game). + if (*air) { + if (!l.game_air) { + l.game_air = true; + l.game_takeoff = at; + l.game_landing = -1; + l.game_trick = nullptr; + l.game_plan = snap; + l.predicted_air = 0; + } + l.game_ground = 0; + if (l.predicted_air == 0 && at - l.game_takeoff >= 0.03) { + const double elapsed = at - l.game_takeoff; + const auto vy = board_up_speed(base, component); + l.predicted_air = vy && *vy > 0.3f + ? static_cast(std::clamp(2 * (*vy + 9.81 * elapsed) / 9.81 - elapsed, 0.15, 3.0)) : -1.0f; + l.game_trick = nullptr; + } + if (at - l.game_takeoff < 0.25 && l.game_plan) { + const auto name = trick_of(component); + const auto pop = pop_of(l.predicted_air); + const Replacement* found = nullptr; + for (const auto& r : l.game_plan->replacements) + if (r.trick == name && r.clip && (r.pop == pop || (r.pop.empty() && !found))) found = &r; + l.game_trick = found; + } + } else if (l.game_air && ++l.game_ground >= 3) { + l.game_air = false; + l.game_landing = at; + } + if (const auto* trick = l.game_trick) { + const auto& c = *trick->clip; + const float speed = std::max(trick->speed, 0.1f); + if (l.game_air && c.takeoff >= 0) { + float rate = speed; + if (l.predicted_air > 0 && c.landing > c.takeoff) rate *= (c.landing - c.takeoff) / l.predicted_air; + t = c.takeoff + static_cast(at - l.game_takeoff) * rate; + weight = std::clamp(static_cast(at - l.game_takeoff) / 0.08f, 0.0f, 1.0f); + if (c.landing > c.takeoff) t = std::min(t, c.landing); + } else if (l.game_landing > 0 && c.landing >= 0) { + const float after = static_cast(at - l.game_landing) * speed; + const float span = std::max(0.05f, std::min(0.35f, c.duration() - c.landing)); + t = std::min(c.landing + after, c.duration()); + weight = 1 - std::clamp(after / span, 0.0f, 1.0f); + if (weight <= 0) { + l.game_trick = nullptr; + l.game_plan.reset(); + l.game_landing = -1; + t = -1; + } + } + if (l.game_trick) clip = &c; + } + } + + std::size_t first = 0, last = 0; // [first, last) of l.pose written + Joint board_target; + float board_weight = 0; + if (clip && t >= 0 && weight > 0) { + const auto placement = world(l.pose, placement_joint); + l.custom.resize(layout.count); + std::copy(l.pose.begin(), l.pose.end(), l.custom.begin()); + clip->sample(t, std::span(l.custom.data(), std::min(l.custom.size(), clip->joints))); + if (Joint rel; clip->sample_board(t, rel)) { + board_target = compose(placement, rel); + board_weight = weight; + } + const auto& body = body_joints(); + for (std::size_t i = 0; i < std::min(layout.count, clip->joints); ++i) { + if (!body[i]) continue; + auto& j = l.pose[i]; + const auto& c = l.custom[i]; + j.rotation = slerp(j.rotation, c.rotation, weight); + for (std::size_t k = 0; k < 3; ++k) j.position[k] += (c.position[k] - j.position[k]) * weight; + } + first = 2; + last = layout.count; + } + if (last > first) { + for (std::size_t i = first; i < last; ++i) + if (!finite(l.pose[i])) throw std::runtime_error("a clip made a broken pose; nothing was written"); + for (std::size_t i = first; i < last; ++i) { + auto& b = l.bones[i]; + const auto& j = l.pose[i]; + std::memcpy(b.scale.data(), j.scale.data(), 12); + b.rotation = j.rotation; + std::memcpy(b.position.data(), j.position.data(), 12); + } + // Written when the pose goes to the renderer (on_render): the body physics rebuild this + // buffer after the animation update. + { + auto& p = pending(); + std::lock_guard lock(p.mutex); + p.buffer = layout.buffer; + p.first = static_cast(first); + p.last = static_cast(last); + p.bones.assign(l.bones.begin() + static_cast(first), l.bones.begin() + static_cast(last)); + p.at = at; + } + auto& sp = sent_pose(); + std::lock_guard sent_lock(sp.mutex); + sp.component = component; + sp.first = static_cast(first); + sp.last = static_cast(last); + sp.joints.assign(l.pose.begin() + static_cast(first), l.pose.begin() + static_cast(last)); + sp.at = at; + } else { + auto& p = pending(); + std::lock_guard lock(p.mutex); + p.bones.clear(); + } + auto& pb = pending_board(); + std::lock_guard lock(pb.mutex); + pb.weight = board_weight > 0 && finite(board_target) ? board_weight : 0; + pb.mount = board_target; + pb.at = at; + } catch (const std::exception& e) { + if (l.failures++ % 600 == 0) logging::log(Level::warning, Channel::skater, "Plugin animation: {}", e.what()); + } catch (...) { + } + SetLastError(error); +} + +// What other players get of the local skater: the clip, as drawn here. +void on_send(std::uintptr_t component, std::vector& skater) noexcept { + try { + auto& sp = sent_pose(); + std::lock_guard lock(sp.mutex); + if (component != sp.component || now_seconds() - sp.at > 0.15 || sp.last > skater.size()) return; + for (std::uint32_t i = sp.first; i < sp.last; ++i) { + const auto& j = sp.joints[i - sp.first]; + skater[i].rotation = j.rotation; + skater[i].position = j.position; + } + } catch (...) { + } +} + +// The local skater's pose handed to the renderer: the clip's pose goes in now, over whatever the +// body physics left there, each joint's own lanes only (each bone's 4th lane stays). +void on_render(std::uintptr_t animation_interface) noexcept { + auto& l = live(); + const auto component = l.component.load(std::memory_order_acquire); + if (!component) return; + const auto error = GetLastError(); + try { + const auto holder = read(component + 0xa0); + if (!holder || !*holder || animation_interface != *holder + 0xc0) { + SetLastError(error); + return; + } + auto& p = pending(); + std::lock_guard lock(p.mutex); + if (p.bones.empty() || now_seconds() - p.at > 0.1) { + SetLastError(error); + return; + } + const auto layout = multiplayer::read_native_pose_layout(peek, l.base.load(std::memory_order_acquire), *holder, 512); + if (layout.buffer != p.buffer || layout.count != skeleton_joints || p.last > layout.count || p.first >= p.last) { + SetLastError(error); + return; + } + thread_local std::vector current; + current.resize(p.last - p.first); + if (!peek(layout.buffer + p.first * sizeof(NativeBone), current.data(), current.size() * sizeof(NativeBone))) { + SetLastError(error); + return; + } + for (std::size_t i = 0; i < current.size(); ++i) { + std::memcpy(current[i].scale.data(), p.bones[i].scale.data(), 12); + current[i].rotation = p.bones[i].rotation; + std::memcpy(current[i].position.data(), p.bones[i].position.data(), 12); + } + (void)poke(layout.buffer + p.first * sizeof(NativeBone), current.data(), current.size() * sizeof(NativeBone)); + } catch (...) { + } + SetLastError(error); +} + +// The board's pose, just before it goes to the renderer: the whole rig moved so its mount lands where +// the clip has it (anim_studio.cpp's on_board_render). +void on_board_render(std::uintptr_t animation_interface, std::uintptr_t /*render_data*/) noexcept { + auto& l = live(); + const auto error = GetLastError(); + try { + const auto bh = l.board_holder.load(std::memory_order_acquire); + if (!bh || animation_interface != bh + 0xc0) { + SetLastError(error); + return; + } + Joint target; + float weight{}; + { + auto& pb = pending_board(); + std::lock_guard lock(pb.mutex); + if (pb.weight > 0 && now_seconds() - pb.at < 0.1) { + target = pb.mount; + weight = pb.weight; + } + } + if (weight > 0) { + const auto layout = multiplayer::read_native_pose_layout(peek, l.base.load(std::memory_order_acquire), bh, 64); + std::array b{}; + if (layout.buffer && layout.count == board_bones && peek(layout.buffer, b.data(), sizeof(b))) { + const auto now_mount = compose(board_parent(b), joint_of(b[board_mount])); + Joint want = target; + want.rotation = slerp(now_mount.rotation, target.rotation, weight); + for (std::size_t i = 0; i < 3; ++i) want.position[i] = now_mount.position[i] + (target.position[i] - now_mount.position[i]) * weight; + want.scale = now_mount.scale; + const auto mount_local = joint_of(b[board_mount]); + const auto root_world = compose(want, relative(mount_local, Joint{})); + const auto root_local = relative(joint_of(b[0]), root_world); + if (finite(root_local)) { + auto& r = b[1]; + r.rotation = root_local.rotation; + std::memcpy(r.position.data(), root_local.position.data(), 12); + (void)poke(layout.buffer + sizeof(NativeBone), &r, sizeof(NativeBone)); + } + } + } + } catch (...) { + } + SetLastError(error); +} + +bool hooked{}; +std::string failure; +std::uint64_t serial{}; +} + +void play(std::optional clip) { + plan_draft.play = std::move(clip); + plan_draft.started = now_seconds(); + plan_draft.serial = ++serial; + publish(); +} +bool playing() noexcept { + return plan_draft.play.has_value() && finished_serial.load(std::memory_order_acquire) != plan_draft.serial; +} +void set_replacements(std::vector list) { + plan_draft.replacements = std::move(list); + publish(); +} +bool record(float seconds) { + if (record_running.load(std::memory_order_acquire) || !(seconds > 0)) return false; + plan_draft.record_seconds = std::min(seconds, 10.0f); + plan_draft.record_serial = ++serial; + publish(); + return true; +} +bool recording() noexcept { + return record_running.load(std::memory_order_acquire) || (plan_draft.record_seconds > 0 && live().record_serial.load(std::memory_order_acquire) != plan_draft.record_serial); +} +std::shared_ptr take_recording() { + std::lock_guard lock(recorded_mutex); + return std::exchange(recorded, {}); +} +std::string current_trick() noexcept { + const auto component = live().component.load(std::memory_order_acquire); + return component ? trick_of(component) : std::string{}; +} + +void tick(std::uintptr_t base, std::uintptr_t component, bool allowed) { + auto& l = live(); + if (component && base) { + // The skater's board (its own entity, with a 16-bone rig): its animation holder. + std::uintptr_t holder{}; + try { + const auto collection = read(component + 0x18).value_or(0); + const auto entity = collection ? read(collection).value_or(0) : 0; + if (entity) holder = multiplayer::read_native_board(peek, base, entity).holder; + } catch (...) {} + l.board_holder.store(holder, std::memory_order_release); + } + if (component != l.component.load(std::memory_order_acquire)) { + l.base.store(base, std::memory_order_release); + l.component.store(component, std::memory_order_release); + } + if (allowed != plan_draft.allowed) { + plan_draft.allowed = allowed; + publish(); + } + // A play that finished is over. + if (plan_draft.play && finished_serial.load(std::memory_order_acquire) == plan_draft.serial) { + plan_draft.play.reset(); + publish(); + } + const bool wanted = plan_draft.play || !plan_draft.replacements.empty() || recording(); + if (wanted && !hooked && base) { + std::string detail; + if (multiplayer::install_entity_hooks(base, detail)) { + multiplayer::set_pose_override_listener(&on_animation); + multiplayer::set_pose_override_render_listener(&on_render); + multiplayer::set_pose_override_board_listener(&on_board_render); + multiplayer::set_local_pose_filter(&on_send); + hooked = true; + logging::write(Level::info, Channel::skater, "Plugin animation: listening to the skater's animation."); + } else failure = "the animation hook is unavailable: " + detail; + } +} + +std::string status() { + if (!hooked) return failure.empty() ? "not hooked (no clip asked for yet)" : failure; + return std::format("hooked; {}playing, {} replacement(s){}", playing() ? "" : "not ", plan_draft.replacements.size(), + recording() ? ", recording" : ""); +} +} diff --git a/Extension/Skater/skater_animation.h b/Extension/Skater/skater_animation.h new file mode 100644 index 00000000..b1c6f56f --- /dev/null +++ b/Extension/Skater/skater_animation.h @@ -0,0 +1,50 @@ +#pragma once +#include "Extension/AnimationStudio/anim_core.h" +#include +#include +#include +#include +#include + +// Clips on the local skater's body, for plugins (reskate.animation): Animation Studio's pose +// override (PR 297) without the studio. A clip (anim_core.h's, recorded from the game) replaces the +// body, Hips and every joint below it, blended in and out; the placement joints the game moves the +// skater with are never written, and the board follows the clip only when it recorded one. Other +// players see what is drawn here (the local pose filter). Nothing is hooked until a clip is asked for. +// +// The pose is read on the thread that evaluates animation and written when it goes to the renderer +// (the body physics rebuild the buffer in between); the client thread only hands over what to play. +namespace dingosdk::skater_animation { +using anim_studio::Clip; + +// A clip from now on, in place: `speed` times as fast, blended in and out over `blend` seconds, +// once or over and over. +struct Play { + std::shared_ptr clip; + float speed{1}, blend{0.15f}; + bool loop{}; +}; +// A clip the game's own trick plays as, from its takeoff: the clip's air (its takeoff to its +// landing) stretched to the trick's. `trick` is the game's name ("kickflip"); `pop` "", "low", +// "mid" or "high". +struct Replacement { + std::string trick, pop; + std::shared_ptr clip; + float speed{1}; +}; + +// Client thread throughout. +void play(std::optional clip); +bool playing() noexcept; // a clip of play() has not finished +void set_replacements(std::vector list); +// Records the pose for `seconds` (at most 10); the clip comes from take_recording once done. +bool record(float seconds); +bool recording() noexcept; +std::shared_ptr take_recording(); +// The game's name for the trick the skater is doing now, or empty. +std::string current_trick() noexcept; +// Each client tick: the local skater's animation component (0 for none), and whether clips may be +// written now (not while a throwdown or a S.K.A.T.E. game runs). +void tick(std::uintptr_t base, std::uintptr_t component, bool allowed); +std::string status(); +} diff --git a/Extension/Skater/skater_events.cpp b/Extension/Skater/skater_events.cpp new file mode 100644 index 00000000..9efb47a1 --- /dev/null +++ b/Extension/Skater/skater_events.cpp @@ -0,0 +1,230 @@ +#include "skater_events.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include "Extension/HallOfMeat/hall_of_meat_skater.h" +#include "Extension/Skater/no_bail.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk::skater_events { +namespace { +namespace sstate = addr::skater_state; +constexpr std::size_t most_kept = 32; +constexpr std::size_t cause_count = 35; +constexpr float heard_hit = 3.0f; // m/s: lighter touches are not news (Board Break's body_hit) +constexpr float fall_at_least = 0.4f, fall_at_most = 3.0f; + +struct Shared { + std::mutex mutex; + std::deque landings; + std::deque hits; + std::deque falls; + std::array velocity{}; + std::uint64_t velocity_at{}; +}; +Shared& shared() { + static auto* value = new Shared; + return *value; +} +struct SlamLatch { + std::atomic force{}; + std::atomic cause{-1}; + std::atomic at{}; +}; +SlamLatch& slam() { + static SlamLatch value; + return value; +} +template void keep(std::deque& list, T value) { + if (list.size() >= most_kept) list.pop_front(); + list.push_back(std::move(value)); +} +bool left_limb(skater_body::Bone bone) { + using B = skater_body::Bone; + switch (bone) { + case B::left_hand: case B::left_forearm: case B::left_arm: case B::left_shoulder: case B::left_toe: case B::left_foot: case B::left_leg: + case B::left_upleg: return true; + default: return false; + } +} +bool right_limb(skater_body::Bone bone) { + using B = skater_body::Bone; + switch (bone) { + case B::right_hand: case B::right_forearm: case B::right_arm: case B::right_shoulder: case B::right_toe: case B::right_foot: case B::right_leg: + case B::right_upleg: return true; + default: return false; + } +} +// The board's root body velocity (as the grind exit control and Board Break read it). +bool read_board_velocity(const NoBailSkater& skater, std::array& velocity) noexcept { + std::uintptr_t holder{}, physics{}, type{}, bodies{}, owner{}; + std::uint32_t count{}; + if (!memory::peek(skater.core + sstate::board_holder_offset, holder) || !memory::peek(holder + sstate::board_physics_offset, physics) || + !memory::peek(physics, type) || type != skater.base + sstate::board_physics_vtable || + !memory::peek(physics + sstate::physics_bodies_offset, bodies) || !memory::peek(bodies, count) || count != sstate::board_body_count) + return false; + const auto body = bodies + sstate::board_root_body * sstate::physics_body_size; + if (!memory::peek(body + sstate::body_owner_offset, owner) || owner != physics || !memory::peek(body + sstate::body_velocity_offset, velocity)) + return false; + return std::all_of(velocity.begin(), velocity.end(), [](float v) { return std::isfinite(v) && std::abs(v) < 1000; }); +} + +// The physics thread's own. +struct Watch { + bool was_air{}; + float air_down{}, air_speed{}; + std::array hit_at{}; + std::uint64_t any_hit_at{}; + std::optional fall; + std::uint64_t fall_since{}; + bool was_wipeout{}; +}; +void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { + static Watch w; + const auto error = GetLastError(); + try { + const auto now = GetTickCount64(); + auto& s = shared(); + std::uint32_t physics{}; + if (memory::peek(skater.context + sstate::physics_state_offset, physics)) { + std::array v{}; + const bool known = read_board_velocity(skater, v); + const bool air = physics == 200 || physics == 201, grinding = physics >= 400 && physics < 500, + rolling = physics >= 100 && physics < 200; + if (known) { + std::lock_guard lock(s.mutex); + s.velocity = v; + s.velocity_at = now; + } + if (air && known) { + w.air_down = -v[1]; + w.air_speed = std::hypot(v[0], v[2]); + } + if (w.was_air && (rolling || grinding)) { + std::lock_guard lock(s.mutex); + keep(s.landings, Landing{w.air_down, w.air_speed, grinding}); + } + w.was_air = air; + // A fall starts as the wipeout does. + const bool wiping = physics == 300; + if (wiping && !w.was_wipeout && !w.fall) { + w.fall = Fall{}; + w.fall_since = now; + } + w.was_wipeout = wiping; + } + if (hall_of_meat::skater_available()) { + const auto step = hall_of_meat::read_step(skater, seconds, wipeout); + const bool ragdoll = step.ragdoll.value_or(false); + if (ragdoll) { + std::optional heard; + for (std::size_t i = 1; i < skater_body::count; ++i) { + const auto& c = step.body.bodies[i]; + if (!c.touching) continue; + const float slide = std::sqrt(c.slide[0] * c.slide[0] + c.slide[1] * c.slide[1] + c.slide[2] * c.slide[2]); + if (w.fall) { + w.fall->impact[i] += c.impact; + w.fall->slide[i] += std::isfinite(slide) ? slide * seconds : 0.0f; + const auto bone = static_cast(i); + if (left_limb(bone)) w.fall->left += c.impact; + if (right_limb(bone)) w.fall->right += c.impact; + } + if (heard || skater_body::foot(static_cast(i)) || c.impact < heard_hit) continue; + if (now - w.hit_at[i] < 220 || now - w.any_hit_at < 70) continue; + w.hit_at[i] = w.any_hit_at = now; + heard = BodyHit{static_cast(i), c.impact, c.hit.vehicle, c.hit.board}; + } + if (heard) { + std::lock_guard lock(s.mutex); + keep(s.hits, *heard); + } + } + if (w.fall) { + const float lasted = static_cast(now - w.fall_since) / 1000.0f; + if (lasted >= fall_at_most || (lasted >= fall_at_least && !ragdoll && step.ragdoll.has_value())) { + w.fall->seconds = lasted; + std::lock_guard lock(s.mutex); + keep(s.falls, *w.fall); + w.fall.reset(); + } + } + } + } catch (...) { + } + SetLastError(error); +} +std::atomic listening{}; +} + +void want() noexcept { + if (listening.exchange(true)) return; + if (!add_no_bail_step_listener(&on_step)) { + logging::write(logging::Level::warning, logging::Channel::skater, + "Plugins' skater events: No Bail has no room for another physics step listener."); + listening.store(false); + } +} +std::vector take_landings() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + std::vector out(s.landings.begin(), s.landings.end()); + s.landings.clear(); + return out; +} +std::vector take_hits() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + std::vector out(s.hits.begin(), s.hits.end()); + s.hits.clear(); + return out; +} +std::vector take_falls() { + auto& s = shared(); + std::lock_guard lock(s.mutex); + std::vector out(s.falls.begin(), s.falls.end()); + s.falls.clear(); + return out; +} +std::optional> board_velocity() noexcept { + auto& s = shared(); + std::lock_guard lock(s.mutex); + if (!s.velocity_at || GetTickCount64() - s.velocity_at > 250) return std::nullopt; + return s.velocity; +} +Slam last_slam() noexcept { + auto& l = slam(); + return {l.force.load(std::memory_order_relaxed), l.cause.load(std::memory_order_relaxed), l.at.load(std::memory_order_acquire)}; +} +void note_cause(std::int32_t reason, float magnitude) noexcept { + auto& l = slam(); + const auto now = GetTickCount64(); + // The strongest of a burst: one slam records several causes. + if (now - l.at.load(std::memory_order_relaxed) < 250 && magnitude <= l.force.load(std::memory_order_relaxed)) return; + l.force.store(magnitude, std::memory_order_relaxed); + l.cause.store(reason, std::memory_order_relaxed); + l.at.store(now, std::memory_order_release); +} +void note_wipeout(std::uintptr_t causes) noexcept { + std::array set{}; + std::array force{}; + std::int32_t reason{-1}; + float strongest{}; + if (causes && memory::peek_bytes(causes + 0x28, set.data(), set.size()) && memory::peek_bytes(causes + 0x4c, force.data(), sizeof(force))) + for (std::size_t i = 0; i < cause_count; ++i) + if (set[i] && (reason < 0 || force[i] > strongest)) { + reason = static_cast(i); + strongest = force[i]; + } + if (reason < 0) return; + auto& l = slam(); + l.force.store(strongest, std::memory_order_relaxed); + l.cause.store(reason, std::memory_order_relaxed); + l.at.store(GetTickCount64(), std::memory_order_release); +} +} diff --git a/Extension/Skater/skater_events.h b/Extension/Skater/skater_events.h new file mode 100644 index 00000000..a5a52ad7 --- /dev/null +++ b/Extension/Skater/skater_events.h @@ -0,0 +1,47 @@ +#pragma once +#include "Engine/Game/Skater/skater_body.h" +#include +#include +#include +#include + +// What the local skater's physics does, as news for plugins (reskate's board_landed, body_hit and +// bail_end events, board.motion, the bail's force): read in each physics step No Bail hands on +// (no_bail.h), where the values are exact, and kept for the client tick to take. Nothing is read +// until want() is first called. +namespace dingosdk::skater_events { +// The board came down from the air: how fast it was coming down, how fast along, onto a rail. +struct Landing { + float down_speed{}, speed{}; + bool rail{}; +}; +// A body hit something in a bail, hard enough to be heard (at most one a bone every 220 ms). +struct BodyHit { + std::uint8_t bone{}; // skater_body::Bone + float impact{}; // metres per second along the contact's normal + bool vehicle{}, board{}; +}; +// A bail's fall, once the body is up or 3 s have passed: what each body hit and slid, summed. +struct Fall { + float seconds{}; + std::array impact{}, slide{}; + float left{}, right{}; // the limbs of each side, summed +}; +// The force and cause of the last wipeout or slam (the game's cause collector), and when (ms). +struct Slam { + float force{}; + std::int32_t cause{-1}; + std::uint64_t at{}; +}; +// Client thread: starts reading the steps (once). +void want() noexcept; +std::vector take_landings(); +std::vector take_hits(); +std::vector take_falls(); +// The board's velocity in the last step (m/s), while it is known. +std::optional> board_velocity() noexcept; +Slam last_slam() noexcept; +// From No Bail's hooks (no_bail.cpp), beside Board Break's: a cause recorded, a wipeout chosen. +void note_cause(std::int32_t reason, float magnitude) noexcept; +void note_wipeout(std::uintptr_t causes) noexcept; +} diff --git a/Extension/RoadRash/skater_items.h b/Extension/Skater/skater_items.h similarity index 99% rename from Extension/RoadRash/skater_items.h rename to Extension/Skater/skater_items.h index 19d56f9b..3ba6a04f 100644 --- a/Extension/RoadRash/skater_items.h +++ b/Extension/Skater/skater_items.h @@ -15,7 +15,7 @@ // The local skater's cosmetic recipe while it plays: what Road Rash needs to read it, to hand the // game a changed one and to give the skater back to the game afterwards. Client thread only. Everything is read through guarded reads and checked // as the multiplayer code checks a peer's outfit (native_cosmetics_layout.h); a failed check throws. -namespace dingosdk::road_rash { +namespace dingosdk::skater_items { using multiplayer::CosmeticRecipe; using multiplayer::CosmeticSlot; inline bool readable(std::uintptr_t address, void* out, std::size_t size) { diff --git a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp index 95fe1aca..f8d4b3f9 100644 --- a/Extension/Throwdowns/Test/throwdown_relay_tests.cpp +++ b/Extension/Throwdowns/Test/throwdown_relay_tests.cpp @@ -46,6 +46,11 @@ bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::s calls.push_back({"spawn", series, host, 0, 0, token}); return true; } +bool queue_throwdown_host(std::uint64_t token, const std::string &series, const std::array &at, std::uint32_t most_players) noexcept { + last_token = token; + calls.push_back({"host", series, most_players, 0, static_cast(at[0]), token}); + return true; +} bool queue_throwdown_add(std::uint32_t player, const std::string &series, std::uint32_t mmid) noexcept { calls.push_back({"add", series, player, mmid}); return true; @@ -763,6 +768,56 @@ void beacon_flow() { } // A level change takes the event types with it. The relay asks for them again instead of // remembering that it once had them, and shows a drop only when they are back. +// A plugin's drop for chosen players: spawned where the skater stands, shown only to them (an invite +// before each offer), joined by them alone, and started once they all are in. +void plugin_host_flow() { + ThrowdownRelayInput in{me, {{leader, 3}, {other, 5}}, true, 81}; + in.position = std::array{12, 0, 0}; + tick(in); + calls.clear(); + check(throwdown_relay_host("Bowling", {leader}, 0).find("JamSession") != std::string::npos, "A mode that is not linked was taken"); + check(throwdown_relay_host("JamSession", {leader}, 0).empty(), "A plugin's drop was refused"); + tick(in); + const auto *host = find("host"); + check(host && host->series == "JamSession" && host->value == 12 && host->id == 2, "The drop was not spawned where the skater stands"); + check(throwdown_relay_host_status() == "spawning", "The drop's status did not say it is spawning"); + throwdown_relay_host_placement(last_token, {9, 8, 7}); + throwdown_relay_spawned(last_token, 0x77, 4); + auto out = tick(in); + check(out.size() == 2 && out[0].kind == Kind::invite && out[0].order == std::vector{leader} && out[1].kind == Kind::offer && + out[1].placement == std::vector{9, 8, 7} && out[1].id == 0x77, + "The drop was not offered after an invite naming its players"); + check(throwdown_relay_host_status() == "open", "The drop's status did not say it is open"); + receive(other, make(Kind::join, me, 0x77)); + tick(in); + check(!find("add"), "A player who was not invited joined the drop"); + receive(leader, make(Kind::join, me, 0x77)); + tick(in); + const auto *add = find("add"); + check(add && add->id == 0x303 && add->mmid == 0x77, "An invited player's join was not replayed into the drop"); + tick(in); + const auto *started = find("start"); + check(started && started->mmid == 0x77 && throwdown_relay_host_status() == "started", "The drop did not start once everyone invited joined"); + end_session(); + // Another leader's drop for someone else: nothing shows it here. + ThrowdownRelayInput guest{me, {{leader, 3}, {other, 5}}, true, 82}; + tick(guest); + calls.clear(); + auto invite = make(Kind::invite, leader, 0x99); + invite.order = {other}; + receive(leader, invite); + receive(leader, offer(leader, 0x99)); + tick(guest); + check(!find("spawn"), "A drop for other players was shown here"); + invite.id = 0x9a; + invite.order = {me}; + receive(leader, invite); + receive(leader, offer(leader, 0x9a)); + tick(guest); + check(find("spawn") != nullptr, "A drop this player was invited to was not shown"); + end_session(); +} + void level_change_flow() { using namespace std::chrono_literals; const ThrowdownRelayInput in{me, {{leader, 3}}, true, 77}; @@ -813,7 +868,8 @@ int main() { challenge_flow(); beacon_flow(); level_change_flow(); - std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon and level change flows passed.\n"; + plugin_host_flow(); + std::cout << "Throwdown relay: guest, leader, removal, Spot Battle, S.K.A.T.E., last-player, coop challenge, party beacon, level change and plugin drop flows passed.\n"; return 0; } catch (const std::exception &e) { std::cerr << e.what() << '\n'; diff --git a/Extension/Throwdowns/throwdown_lab.cpp b/Extension/Throwdowns/throwdown_lab.cpp index 1003d6c7..14852d8f 100644 --- a/Extension/Throwdowns/throwdown_lab.cpp +++ b/Extension/Throwdowns/throwdown_lab.cpp @@ -1125,6 +1125,12 @@ std::string run_job(const Job& job) { if (const auto f = find_field(p->type, field_party_only); f && f->size == 1) *reinterpret_cast(at + f->offset) = 0; if (const auto f = find_field(p->type, field_instance); f && f->kind == kind_string) assign_string(at + f->offset, ""); e.set_u32(field_number_of_events, 1); + // A plugin's drop: its parameters, as the stream other players' copies are spawned from. + if (job.token && job.position) { + std::vector stream; + capture_value(p->type, at, stream, 0); + throwdown_relay_host_placement(job.token, std::move(stream)); + } const auto summary = describe_params(p->type, at); std::uint32_t player_limit{}; if (const auto f = find_field(p->type, field_max_players); f && f->size == 4) player_limit = read(at + f->offset); @@ -2018,6 +2024,17 @@ bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::s return queue_relay_job(std::move(job), event_debug_spawn); } catch (...) { return false; } } +bool queue_throwdown_host(std::uint64_t token, const std::string& series, const std::array& at, std::uint32_t most_players) noexcept { + const auto host = local_native_player_id(); + if (!token || !host) return false; + try { + Job job{Job::Kind::spawn, host, most_players}; + job.token = token; + job.series = series; + job.position = at; + return queue_relay_job(std::move(job), event_debug_spawn); + } catch (...) { return false; } +} bool queue_throwdown_add(std::uint32_t player, const std::string& series, std::uint32_t mmid) noexcept { if (!player || !mmid) return false; try { diff --git a/Extension/Throwdowns/throwdown_lab.h b/Extension/Throwdowns/throwdown_lab.h index 7d644637..2bcc3532 100644 --- a/Extension/Throwdowns/throwdown_lab.h +++ b/Extension/Throwdowns/throwdown_lab.h @@ -42,6 +42,9 @@ bool prepare_throwdown_injection() noexcept; // throwdown_relay_spawned(token, ...). bool queue_throwdown_spawn(std::uint64_t token, std::uint32_t host, const std::string &series, const std::vector &placement, const std::vector &settings) noexcept; +// A plain `series` queue at `at`, hosted by the local player (a plugin's drop): its MMID comes back +// through throwdown_relay_spawned(token, ...), its parameters through throwdown_relay_host_placement. +bool queue_throwdown_host(std::uint64_t token, const std::string &series, const std::array &at, std::uint32_t most_players) noexcept; bool queue_throwdown_add(std::uint32_t player, const std::string &series, std::uint32_t mmid) noexcept; bool queue_throwdown_remove(std::uint32_t player) noexcept; // RemoveParticipantFromCommunityEvent as `player` bool queue_throwdown_start(const std::string &series, std::uint32_t mmid) noexcept; diff --git a/Extension/Throwdowns/throwdown_relay.cpp b/Extension/Throwdowns/throwdown_relay.cpp index 10577180..062de4eb 100644 --- a/Extension/Throwdowns/throwdown_relay.cpp +++ b/Extension/Throwdowns/throwdown_relay.cpp @@ -100,6 +100,16 @@ struct Hosted { std::vector members, added; // remote joiners in join order; those in the queue here bool relayed{}, started{}, start_sent{}; std::uint64_t next_offer{}; + // A plugin's drop (throwdown_relay_host): only these may join, and it starts once all have. + std::vector invited; + bool plugin{}, start_asked{}; +}; +// A plugin's drop on its way: spawned by the lab, its MMID not known yet. +struct PluginSpawn { + std::uint64_t token{}, asked_at{}; + std::string series; + std::vector invited; + std::uint32_t max_players{}; }; // Another player's drop, spawned on the local server hosted by the leader's virtual id. struct Mirror { @@ -156,6 +166,13 @@ struct Relay { std::uint64_t next_prepare{}, next_info{}; std::map peers; // Steam ID -> slot std::optional hosted; + // A plugin's drop: asked for (under `mutex`), spawning, and its parameters by token (under `mutex`). + std::optional host_request, plugin_spawn; + bool cancel_request{}; + std::map> host_placements; + std::string host_status; // under `mutex` + // Drops shown only to chosen players: who may see each (an invite before its offer). + std::map> invites; std::map mirrors; std::optional running; std::string running_series; @@ -267,6 +284,11 @@ ThrowdownMessage message(Kind kind, std::uint64_t leader, std::uint32_t id) { // The local player's own drop. void send_offer(Relay &r, std::uint64_t now) { auto &h = *r.hosted; + if (!h.invited.empty()) { + auto invite = message(Kind::invite, r.local, h.id); + invite.order = h.invited; + send(r, invite); + } auto m = message(Kind::offer, r.local, h.id); m.series = h.series; m.placement = h.placement; m.settings = h.settings; m.order = h.members; const auto before = r.outgoing.size(); @@ -626,12 +648,20 @@ void on_beacon_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std::uint64_t now) { if (!r.peers.contains(sender)) return; if (m.kind == Kind::beacon) { on_beacon_message(r, sender, m); return; } - if (m.kind >= Kind::challenge_start) { on_challenge_message(r, sender, m, now); return; } + if (m.kind >= Kind::challenge_start && m.kind != Kind::invite) { on_challenge_message(r, sender, m, now); return; } const Key key{m.leader, m.id}; const auto mirror = r.mirrors.find(key); switch (m.kind) { + case Kind::invite: { + if (m.leader != sender) return; + r.invites[key] = m.order; + // Not for this player: nothing shows it here. + if (!contains(m.order, r.local) && mirror != r.mirrors.end()) destroy_mirror(r, mirror->second, now); + return; + } case Kind::offer: { if (m.leader != sender || !relayed_mode(m.series)) return; + if (const auto invite = r.invites.find(key); invite != r.invites.end() && !contains(invite->second, r.local)) return; if (mirror != r.mirrors.end()) { // A copy removed here (full, or left) stays removed while the leader keeps offering it. if (mirror->second.state == Mirror::State::gone) mirror->second.gone_at = now; @@ -658,7 +688,7 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: case Kind::join: if (m.leader == r.local) { if (!r.hosted || r.hosted->id != m.id || r.hosted->started || !r.hosted->relayed || - contains(r.hosted->members, sender)) + contains(r.hosted->members, sender) || (!r.hosted->invited.empty() && !contains(r.hosted->invited, sender))) return; r.hosted->members.push_back(sender); add_to_hosted(r); @@ -722,7 +752,45 @@ void on_message(Relay &r, std::uint64_t sender, const ThrowdownMessage &m, std:: } } +void set_host_status(Relay &r, std::string text) { + std::lock_guard lock(r.mutex); + r.host_status = std::move(text); +} void on_spawned(Relay &r, std::uint64_t token, std::uint32_t mmid, std::uint32_t capacity, std::uint64_t now) { + if (r.plugin_spawn && r.plugin_spawn->token == token) { + auto spawn = std::move(*r.plugin_spawn); + r.plugin_spawn.reset(); + std::vector placement; + { + std::lock_guard lock(r.mutex); + if (const auto found = r.host_placements.find(token); found != r.host_placements.end()) { + placement = std::move(found->second); + r.host_placements.erase(found); + } + } + if (!mmid) { + set_host_status(r, "the drop did not spawn"); + return; + } + if (placement.empty() || !r.in_world) { + if (r.in_world) queue_throwdown_destroy(mmid); + set_host_status(r, "the drop could not be described to the other players"); + return; + } + close_hosted(r); + Hosted h; + h.id = mmid; + h.series = spawn.series; + h.placement = std::move(placement); + h.relayed = true; + h.plugin = true; + h.invited = std::move(spawn.invited); + r.hosted = std::move(h); + send_offer(r, now); + note("a plugin's {} (MMID {:#x}) is offered to {} player(s).", spawn.series, mmid, r.hosted->invited.size()); + set_host_status(r, "open"); + return; + } for (auto &[key, m] : r.mirrors) { if (m.token != token) continue; if (!mmid) { @@ -1051,6 +1119,8 @@ void reset(Relay &r, bool destroy_copies, std::uint64_t now) { if (destroy_copies && r.in_world && r.prepared) for (auto &[key, m] : r.mirrors) destroy_mirror(r, m, now); r.mirrors.clear(); + r.invites.clear(); + r.plugin_spawn.reset(); r.spawn_refused.clear(); r.declined.clear(); r.running.reset(); @@ -1203,6 +1273,39 @@ void apply_queue_timer(Relay &r, bool linked, std::uint64_t now) { } } +// A plugin's drop: spawned once the world, the event types and the players are there. +void pump_plugin_host(Relay &r, const ThrowdownRelayInput &input, std::uint64_t now) { + std::optional request; + bool cancel{}; + { + std::lock_guard lock(r.mutex); + request = std::exchange(r.host_request, std::nullopt); + cancel = std::exchange(r.cancel_request, false); + } + if (cancel && r.hosted && r.hosted->plugin && !r.hosted->started) { + if (r.in_world) queue_throwdown_destroy(r.hosted->id); + close_hosted(r); + set_host_status(r, "cancelled"); + } + if (r.plugin_spawn && now - r.plugin_spawn->asked_at > spawning_wait_ms) { + r.plugin_spawn.reset(); + set_host_status(r, "the drop never spawned"); + } + if (!request) return; + const auto refuse = [&](const char *why) { set_host_status(r, why); }; + if (input.barred) return refuse("your game is out of linked throwdowns (its speed or mods)"); + if (!r.in_world || !r.position) return refuse("the skater is not in the world"); + if (!r.prepared) return refuse("the throwdown events are not found yet; try again in a moment"); + if (r.running || r.challenge) return refuse("a throwdown or challenge is running"); + if (r.plugin_spawn) return refuse("a plugin's drop is spawning already"); + for (const auto player : request->invited) + if (!r.peers.contains(player)) return refuse("a chosen player is not in the session"); + request->token = r.next_token++; + request->asked_at = now; + if (!queue_throwdown_host(request->token, request->series, *r.position, request->max_players)) return refuse("the drop could not be queued"); + r.plugin_spawn = std::move(request); + set_host_status(r, "spawning"); +} void maintain(Relay &r, std::uint64_t now) { if (!r.in_world) return; // The event types belong to the level: each one's are found by a background heap scan, @@ -1245,6 +1348,15 @@ void maintain(Relay &r, std::uint64_t now) { else add_to_mirror(r, m); } add_to_hosted(r); + // A plugin's drop starts once every invited player is in it here. + if (r.hosted && r.hosted->plugin && !r.hosted->started && !r.hosted->start_asked && !r.hosted->invited.empty() && + std::all_of(r.hosted->invited.begin(), r.hosted->invited.end(), [&](std::uint64_t p) { return contains(r.hosted->added, p); })) { + if (queue_throwdown_start(r.hosted->series, r.hosted->id)) { + r.hosted->start_asked = true; + note("everyone invited joined the plugin's {}; starting it.", r.hosted->series); + set_host_status(r, "started"); + } + } if (r.running) { for (auto &[player, score] : r.scores) if (score.dirty) @@ -1399,6 +1511,7 @@ std::vector> tick_throwdown_relay(std::uintptr_t base, refresh_player_info(r, now); for (auto &action : actions) on_local(r, action, now); for (const auto &[token, mmid, max] : spawned) on_spawned(r, token, mmid, max, now); + pump_plugin_host(r, input, now); for (const auto &[sender, bytes] : inbox) { if (const auto m = decode_throwdown(bytes)) on_message(r, sender, *m, now); else logging::write(logging::Level::warning, logging::Channel::progression, "Throwdowns: unreadable message from another player dropped."); @@ -1550,6 +1663,41 @@ std::optional> throwdown_relay_celebration_offset(std::uint void set_challenge_invites(bool enabled) noexcept { challenge_invites_enabled.store(enabled, std::memory_order_relaxed); } bool challenge_invites() noexcept { return challenge_invites_enabled.load(std::memory_order_relaxed); } +std::string throwdown_relay_host(const std::string &series, std::vector players, std::uint32_t most_players) { + if (!relayed_mode(series)) return "a throwdown is \"JamSession\", \"SpotBattle\" or \"ThrowdownSkate\""; + if (players.empty() || players.size() > 7) return "a drop is for 1 to 7 other players"; + auto &r = relay(); + std::lock_guard lock(r.mutex); + if (!r.local) return "not in a session"; + if (r.host_request) return "a drop is asked for already"; + PluginSpawn spawn; + spawn.series = series; + spawn.invited = std::move(players); + spawn.max_players = std::max(most_players, static_cast(spawn.invited.size()) + 1); + r.host_request = std::move(spawn); + r.host_status = "asked"; + return {}; +} +void throwdown_relay_cancel_host() noexcept { + try { + auto &r = relay(); + std::lock_guard lock(r.mutex); + r.cancel_request = true; + r.host_request.reset(); + } catch (...) {} +} +std::string throwdown_relay_host_status() { + auto &r = relay(); + std::lock_guard lock(r.mutex); + return r.host_status; +} +void throwdown_relay_host_placement(std::uint64_t token, std::vector placement) noexcept { + try { + auto &r = relay(); + std::lock_guard lock(r.mutex); + if (r.host_placements.size() < 8) r.host_placements[token] = std::move(placement); + } catch (...) {} +} std::string throwdown_relay_status() { auto &r = relay(); std::lock_guard lock(r.mutex); diff --git a/Extension/Throwdowns/throwdown_relay.h b/Extension/Throwdowns/throwdown_relay.h index d28caca6..ebd23a96 100644 --- a/Extension/Throwdowns/throwdown_relay.h +++ b/Extension/Throwdowns/throwdown_relay.h @@ -44,6 +44,17 @@ std::vector> tick_throwdown_relay(std::uintptr_t base, // One message from another player, as relayed by the session (game thread). void receive_throwdown_relay(std::uint64_t sender, std::span message); std::string throwdown_relay_status(); +// A plugin's drop (reskate.throwdowns.host): a `series` queue ("JamSession", "SpotBattle", +// "ThrowdownSkate") hosted by the local player where the skater stands, shown only to `players` +// (Steam IDs, others in the session) and joined by them with the game's own Join; it starts once +// they all have. "" when it is asked for (the spawn follows at the relay's next tick), otherwise why +// not. Game thread. +std::string throwdown_relay_host(const std::string &series, std::vector players, std::uint32_t max_players); +void throwdown_relay_cancel_host() noexcept; +// What became of the plugin's drop: "", "spawning", "open", "started", or why it failed. +std::string throwdown_relay_host_status(); +// The lab ran a plugin drop's spawn: its parameters as the stream the offer carries. Any thread. +void throwdown_relay_host_placement(std::uint64_t token, std::vector placement) noexcept; // Lines for the local player's chat since the last call (a linked throwdown ended because // everyone else left it). Game thread. std::vector take_throwdown_relay_notices(); diff --git a/Extension/Throwdowns/throwdown_wire.cpp b/Extension/Throwdowns/throwdown_wire.cpp index 775d0a9d..246841aa 100644 --- a/Extension/Throwdowns/throwdown_wire.cpp +++ b/Extension/Throwdowns/throwdown_wire.cpp @@ -60,6 +60,7 @@ bool valid_throwdown(const ThrowdownMessage &m) noexcept { m.settings.size() <= max_throwdown_params && m.order.size() <= max_throwdown_order && std::all_of(m.order.begin(), m.order.end(), individual); case ThrowdownMessage::Kind::start: + case ThrowdownMessage::Kind::invite: return !m.order.empty() && m.order.size() <= max_throwdown_order && std::all_of(m.order.begin(), m.order.end(), individual); case ThrowdownMessage::Kind::row: return m.board < max_throwdown_boards; @@ -101,6 +102,7 @@ std::vector encode_throwdown(const ThrowdownMessage &m) { w.blob(m.settings, 2); [[fallthrough]]; case ThrowdownMessage::Kind::start: + case ThrowdownMessage::Kind::invite: w.integer(m.order.size(), 1); for (const auto id : m.order) w.integer(id, 8); break; @@ -145,7 +147,7 @@ std::optional decode_throwdown(std::span b Reader r{bytes}; ThrowdownMessage m; const auto kind = r.integer(1); - if (kind < 1 || kind > 15) return {}; + if (kind < 1 || kind > 16) return {}; m.kind = static_cast(kind); m.leader = r.integer(8); m.id = static_cast(r.integer(4)); @@ -159,7 +161,8 @@ std::optional decode_throwdown(std::span b m.settings = r.blob(2, max_throwdown_params); [[fallthrough]]; } - case ThrowdownMessage::Kind::start: { + case ThrowdownMessage::Kind::start: + case ThrowdownMessage::Kind::invite: { const auto count = r.integer(1); if (!r.ok || count > max_throwdown_order) return {}; for (std::uint64_t i = 0; i < count; ++i) m.order.push_back(r.integer(8)); diff --git a/Extension/Throwdowns/throwdown_wire.h b/Extension/Throwdowns/throwdown_wire.h index 0058792f..76210a79 100644 --- a/Extension/Throwdowns/throwdown_wire.h +++ b/Extension/Throwdowns/throwdown_wire.h @@ -29,7 +29,10 @@ struct ThrowdownMessage { challenge_leave = 14, // the sender quit its copy // Party beacons (analysis/party-re/beacons.md): the sender's beacon in this world, `id` its // revision. add = it stands at `location` (placed or moved); else it was removed. - beacon = 15 + beacon = 15, + // A drop for chosen players only (a plugin's, reskate.throwdowns.host): order = who may see + // and join the leader's drop `id`; sent before each of its offers. + invite = 16 }; Kind kind = Kind::offer; std::uint64_t leader{}; // Steam ID of the player hosting the throwdown diff --git a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp index 18a94b5e..7d2b1609 100644 --- a/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp +++ b/Extension/UI/NativeMenu/Test/native_tools_view_tests.cpp @@ -124,34 +124,6 @@ int main() { model.hall_of_meat = {true, true}; activate(state, model, cb, "hall-of-meat", ""); check(meat_row().title == "Hall of Meat: On" && capture.command == "hallofmeat 0", "and off again"); - // Road Rash's switches sit there too; its blood and its healing wait for it to be on. - const auto rash_row = [&](std::string_view id) { - const auto page = render(state, model, cb, player_section); - return *find(page.main, id); - }; - check(rash_row("road-rash").title == "Road Rash: Off" && rash_row("road-rash").command.empty(), "Road Rash waits until it started"); - model.road_rash = {true, false, true}; - check(rash_row("road-rash-blood").command.empty() && rash_row("road-rash-heal").command.empty(), "Its blood and healing wait for it"); - activate(state, model, cb, "road-rash", ""); - check(rash_row("road-rash").command == "road-rash" && capture.command == "roadrash 1", "Road Rash turns on"); - model.road_rash = {true, true, true}; - check(rash_row("road-rash").title == "Road Rash: On" && rash_row("road-rash-blood").title == "Road Rash blood: On", "and shows it"); - activate(state, model, cb, "road-rash-blood", ""); - check(capture.command == "roadrashblood 0", "The blood switches off"); - activate(state, model, cb, "road-rash-heal", ""); - check(capture.command == "roadrash heal", "and the skater heals"); - // Board Break's switch sits there too, with the deck that is in the game; another deck waits for a deck pack. - check(rash_row("board-break").title == "Board Break: Off" && rash_row("board-break").command.empty(), "Board Break waits until it started"); - model.board_break = {true, true, false, "None"}; - check(rash_row("board-break").title == "Board Break: On" && rash_row("board-break-deck").command.empty(), - "Without a deck pack there is no deck to change"); - activate(state, model, cb, "board-break", ""); - check(capture.command == "boardbreak 0", "Board Break turns off"); - model.board_break = {true, true, true, "Wide"}; - check(rash_row("board-break-deck").title == "Board Break deck: Wide" && rash_row("board-break-deck").command == "board-break-deck", - "The deck in the game shows"); - activate(state, model, cb, "board-break-deck", ""); - check(capture.command == "boardbreak deck next", "and steps to the next one"); // Grind exit triggers: the switch, what LT / RT mean, and the stance toe/heel needs. const auto grind_row = [&](std::string_view id) { const auto page = render(state, model, cb, player_section); diff --git a/Extension/UI/NativeMenu/native_tools_view.h b/Extension/UI/NativeMenu/native_tools_view.h index 4570819c..c2a45384 100644 --- a/Extension/UI/NativeMenu/native_tools_view.h +++ b/Extension/UI/NativeMenu/native_tools_view.h @@ -202,20 +202,6 @@ inline void activate(State& s, const overlay::Model& m, const overlay::Callbacks cb.queue_offline_feature(cb.user, request, result.data(), result.size())); } else if (command == "hall-of-meat") { console(m.hall_of_meat.enabled ? "hallofmeat 0" : "hallofmeat 1", m.hall_of_meat.available); - } else if (command == "road-rash") { - console(m.road_rash.enabled ? "roadrash 0" : "roadrash 1", m.road_rash.available); - } else if (command == "road-rash-blood") { - console(m.road_rash.blood ? "roadrashblood 0" : "roadrashblood 1", m.road_rash.available && m.road_rash.enabled); - } else if (command == "road-rash-heal") { - console("roadrash heal", m.road_rash.available && m.road_rash.enabled); - } else if (command == "board-break") { - console(m.board_break.enabled ? "boardbreak 0" : "boardbreak 1", m.board_break.available); - } else if (command == "board-break-deck") { - console("boardbreak deck next", m.board_break.available && m.board_break.has_deck); - } else if (command == "board-break-always") { - console(m.board_break.always ? "boardbreak always 0" : "boardbreak always 1", m.board_break.available); - } else if (command == "board-break-wear") { - console(m.board_break.wear ? "boardbreak wear 0" : "boardbreak wear 1", m.board_break.available); } else if (command == "grind-exits") { console(m.grind_exit.enabled ? "grindexit exits off" : "grindexit exits on", m.grind_exit.available); } else if (command == "grind-exit-sides") { @@ -331,23 +317,6 @@ inline Page render(State& s, const overlay::Model& m, const overlay::CallbacksV3 toggle(p.main, "no-bail", "No bail", m.debug.no_bail, (m.debug.no_bail_available || m.debug.no_bail) && cb.queue_debug); toggle(p.main, "freecam", "Freecam", m.debug.free_camera, m.debug.available && m.debug.camera_available && cb.queue_debug); toggle(p.main, "hall-of-meat", "Hall of Meat", m.hall_of_meat.enabled, m.hall_of_meat.available && cb.queue_console_command); - // (Not listed when its official mod is turned off in the launcher.) - if (!m.road_rash.off) { - toggle(p.main, "road-rash", "Road Rash", m.road_rash.enabled, m.road_rash.available && cb.queue_console_command); - toggle(p.main, "road-rash-blood", "Road Rash blood", m.road_rash.blood, - m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); - button(p.main, "road-rash-heal", "Heal Road Rash", "road-rash-heal", - m.road_rash.available && m.road_rash.enabled && cb.queue_console_command); - } - if (!m.board_break.off) { - toggle(p.main, "board-break", "Board Break", m.board_break.enabled, m.board_break.available && cb.queue_console_command); - button(p.main, "board-break-deck", "Board Break deck: " + m.board_break.deck, "board-break-deck", - m.board_break.available && m.board_break.has_deck && cb.queue_console_command); - toggle(p.main, "board-break-always", "Board Break: always break", m.board_break.always, - m.board_break.available && m.board_break.enabled && cb.queue_console_command); - toggle(p.main, "board-break-wear", "Board Break: wear and tear", m.board_break.wear, - m.board_break.available && m.board_break.enabled && cb.queue_console_command); - } const auto& grind = m.grind_exit; const bool grind_console = grind.available && cb.queue_console_command; toggle(p.main, "grind-exits", "Grind exit triggers", grind.enabled, grind_console); diff --git a/Extension/UI/Overlay/drone_hud_overlay.cpp b/Extension/UI/Overlay/drone_hud_overlay.cpp deleted file mode 100644 index d2f969e0..00000000 --- a/Extension/UI/Overlay/drone_hud_overlay.cpp +++ /dev/null @@ -1,357 +0,0 @@ -#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); - // A hit breaks the picture up for a moment too (drone_flight.h: the glitch after a crash). - const float breakup = std::max(1.0f - signal, std::clamp(telemetry().glitch.load(std::memory_order_relaxed), 0.0f, 1.0f)); - 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) { - // The marker sits above the drone's model (Multiplayer/Remote/native_prop.h), not on it: - // anchored this far above the drone, then lifted by half the icon and a gap on screen. - constexpr float above_model = 0.25f; - const float dx = other.position[0] - m[12], dy = other.position[1] + above_model - 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) { - // Stacked upwards from the model: the icon, the distance, then the name. - const float icon = std::clamp(18.0f * k, 12.0f, 32.0f); - const ImVec2 icon_at(at.x, at.y - icon * 0.5f - 6.0f * k); - drone_icon(draw, icon_at, icon, colour, shadow); - const float offset = std::max(1.0f, size / 14.0f); - 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, icon_at.y - icon * 0.5f - 4.0f * k - metres_size.y); - 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()); - 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, metres_at.y - text_size.y); - 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()); - } 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_page.cpp b/Extension/UI/Overlay/drone_page.cpp deleted file mode 100644 index 9a4ce7e6..00000000 --- a/Extension/UI/Overlay/drone_page.cpp +++ /dev/null @@ -1,573 +0,0 @@ -#include "skate_menu_internal.h" -#include "overlay_internal.h" -#include "Extension/Skater/drone_flight.h" -#include "Extension/Skater/drone_beacon.h" -#include "Extension/Skater/drone_outfit.h" -#include -#include -#include -#include -#include - -// The DRONE page of the ReSkate menu: flying, handling, rates, the display, the controller and -// multiplayer. Every change takes effect at once and is saved by running the same `dronetune` -// command a player could type, so the console and this page always agree. - -namespace dingosdk::overlay::menu { -namespace { -namespace drone = dingosdk::drone; -using drone::Knob; - -// Runs a console command on the game's thread, quietly: a slider's save is not news. -void run(const CallbacksV3& callbacks, const std::string& command) { - if (!callbacks.queue_console_command) return; - std::array result{}; - callbacks.queue_console_command(callbacks.user, command.c_str(), result.data(), result.size()); -} - -float get(Knob k) { return drone::knobs().value[k].load(std::memory_order_relaxed); } -void set(Knob k, float v) { drone::knobs().value[k].store(v, std::memory_order_relaxed); } -// Saves a setting by running `dronetune`, which also clamps it and keeps it for next time. -void save(const CallbacksV3& callbacks, Knob k) { run(callbacks, std::format("dronetune {} {}", drone::knob_info[k].name, get(k))); } -std::string tip(Knob k) { - return std::format("{}.\n\nConsole: dronetune {} (default {:g})", drone::knob_info[k].help, drone::knob_info[k].name, - drone::knob_info[k].def); -} - -void slider(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label, const char* format = "%.2f") { - const auto help = tip(k); - field(menu, label, help.c_str()); - float v = get(k); - ImGui::PushID(static_cast(k)); - if (::dingosdk::overlay::menu::slider_float("##value", &v, drone::knob_info[k].lo, drone::knob_info[k].hi, format)) set(k, v); - if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); - ImGui::PopID(); -} -void integer(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label) { - const auto help = tip(k); - field(menu, label, help.c_str()); - int v = static_cast(std::lround(get(k))); - ImGui::PushID(static_cast(k)); - if (::dingosdk::overlay::menu::slider_int("##value", &v, static_cast(drone::knob_info[k].lo), static_cast(drone::knob_info[k].hi))) - set(k, static_cast(v)); - if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, k); - ImGui::PopID(); -} -void toggle(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label) { - const auto help = tip(k); - bool on = get(k) >= 0.5f; - if (toggle_row(menu, label, help.c_str(), on)) { set(k, on ? 1.0f : 0.0f); save(callbacks, k); } -} -void options(SkateMenu& menu, const CallbacksV3& callbacks, Knob k, const char* label, std::initializer_list names) { - const auto help = tip(k); - field(menu, label, help.c_str()); - int v = std::clamp(static_cast(std::lround(get(k))), 0, static_cast(names.size()) - 1); - if (choice(menu, drone::knob_info[k].name, v, names)) { set(k, static_cast(v)); save(callbacks, k); } -} - -// ---- FLY -// The game's interface is hidden for a flight or a follow (the `hideui` drone setting) and only -// brought back if the drone was what hid it. -bool hid_ui = false; -void hide_ui_for_drone(const Model& model, const CallbacksV3& callbacks) { - if (get(Knob::kHideUi) >= 0.5f && !model.debug.game_ui_hidden) { run(callbacks, "hideui 1"); hid_ui = true; } -} -void bring_back_ui(const CallbacksV3& callbacks) { - if (hid_ui) run(callbacks, "hideui 0"); - hid_ui = false; -} - -void fly_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - const bool enabled = drone::is_enabled(); - const bool radio_on = drone::radio::config().enabled.load(); - const bool freecam = model.debug.free_camera; - const bool flying = enabled && freecam; - - begin_card(menu, "drone-fly", "FLY"); - if (primary_button(menu, flying ? "STOP FLYING" : "START FLYING", model.debug.camera_available)) { - if (flying) { - run(callbacks, "dronemode 0"); - run(callbacks, "freecam 0"); // back to the skater - bring_back_ui(callbacks); - } else { - if (!freecam) run(callbacks, "freecam 1"); - if (!enabled) run(callbacks, "dronemode 1"); - hide_ui_for_drone(model, callbacks); - } - } - const bool gamepad = drone::gamepad_mode(); - if (!model.debug.camera_available) warn("Load into a level to fly."); - else if (flying) note(!radio_on ? "Flying with the keyboard and mouse. Close the menu to fly." - : gamepad ? (overlay::read_gamepad().valid ? "Flying with the gamepad. Close the menu to fly." - : "No gamepad found: flying with the keyboard and mouse. Close the menu to fly.") - : "Flying with the controller. Close the menu to fly."); - else if (enabled) note("Drone mode is on, but freecam is off: press Start flying."); - else note("Keyboard: W/S tilt forward/back, A/D roll, E/Q throttle up/down, mouse turns, Shift boosts."); - bool radio = radio_on; - if (toggle_row(menu, "Fly with a controller", "An RC radio in USB Joystick mode, or a gamepad. Set it up on the CONTROLLER tab.", radio)) - run(callbacks, radio ? "droneradio 1" : "droneradio 0"); - if (radio_on) { - options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"}); - if (gamepad) - note("Left stick: throttle (the middle hovers) and turning. Right stick: tilt forward, back and sideways. " - "A (cross) arms and disarms, Menu (Options) puts the drone back."); - } - if (!radio_on && (drone::radio::poll().valid || overlay::read_gamepad().valid)) - warn("A controller is connected. Turn on Fly with a controller to use it, or the game keeps using it for your skater."); - toggle(menu, callbacks, Knob::kHideUi, "Hide the game's interface while flying or following"); - end_card(); - - // Drone follow (Skater/drone_follow.h): the drone flies itself behind the skater. - begin_card(menu, "drone-follow", "FOLLOW ME", "skate while it films you"); - const bool following = model.debug.drone_follow; - if (primary_button(menu, following ? "STOP FOLLOWING" : "FOLLOW ME", model.debug.camera_available)) { - run(callbacks, following ? "dronefollow 0" : "dronefollow 1"); - if (following) bring_back_ui(callbacks); - else hide_ui_for_drone(model, callbacks); - } - if (!model.debug.camera_available) warn("Load into a level to use it."); - else note(following ? "Skate as normal: the drone flies itself behind you, as a pilot would." - : "The drone takes off behind you and flies itself, keeping you in shot while you skate. " - "It uses your drone's settings, flown self-levelling."); - // (Read every tick: a change shows while it follows.) - slider(menu, callbacks, Knob::kFollowDistance, "Follow distance", "%.1f m"); - slider(menu, callbacks, Knob::kFollowAggression, "Aggressiveness"); - end_card(); - - begin_card(menu, "drone-quad", "DRONE"); - { // Presets (drone_flight.h): a whole drone in one choice. "Custom" once anything is changed. - const int current = drone::matching_preset(model.debug.camera_speed); - field(menu, "Preset", "A whole drone in one choice: handling, rates, size, camera tilt, motor sound and top speed."); - if (ImGui::BeginCombo("##preset", current >= 0 ? drone::presets[current].name : "Custom")) { - for (std::size_t p = 0; p < drone::preset_count; ++p) { - const auto& preset = drone::presets[p]; - if (ImGui::Selectable(preset.name, static_cast(p) == current)) { - for (const auto& v : preset.values) { set(v.knob, v.value); save(callbacks, v.knob); } - run(callbacks, std::format("flyspeed {}", preset.top_speed)); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", preset.description); - } - ImGui::EndCombo(); - } - note(current >= 0 ? drone::presets[current].description : "Your own settings. Pick a preset to start from one of them."); - } - { // What the pilot's skater wears over their eyes while flying. - const int worn = std::clamp(static_cast(std::lround(get(Knob::kGoggles))), 0, static_cast(drone::outfit::eyewear_count)); - field(menu, "Goggles", "Your skater wears these while you fly. Your own eyewear comes back when you land."); - if (ImGui::BeginCombo("##goggles", worn ? drone::outfit::eyewear[worn - 1].label : "None")) { - if (ImGui::Selectable("None", worn == 0)) { set(Knob::kGoggles, 0.0f); save(callbacks, Knob::kGoggles); } - for (std::size_t i = 0; i < drone::outfit::eyewear_count; ++i) - if (ImGui::Selectable(drone::outfit::eyewear[i].label, worn == static_cast(i) + 1)) { - set(Knob::kGoggles, static_cast(i + 1)); - save(callbacks, Knob::kGoggles); - } - ImGui::EndCombo(); - } - } - { // The game's value comes back every frame; hold the one being set until the game has it, - // or the slider would snap back while it is dragged. - static float pending = -1.0f; - static double sent_at = 0.0; - if (pending >= 0.0f && (std::abs(model.debug.camera_speed - pending) < 0.01f || - (sent_at > 0.0 && ImGui::GetTime() - sent_at > 1.0))) { pending = -1.0f; sent_at = 0.0; } - float top = pending >= 0.0f ? pending : model.debug.camera_speed; - field(menu, "Top speed", "The same setting as freecam's flight speed.\n\nConsole: flyspeed"); - if (::dingosdk::overlay::menu::slider_float("##top-speed", &top, 0.6f, 300.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) pending = top; - if (ImGui::IsItemDeactivatedAfterEdit()) { run(callbacks, std::format("flyspeed {}", top)); sent_at = ImGui::GetTime(); } - // The flight model's drag is set so full throttle flat out settles near four times the fly speed (m/s). - const auto speed = std::format("Flat out at about {:.0f} km/h (a little faster with boost).", top * 4.0f * 3.6f); - note(speed.c_str()); - } - end_card(); - - begin_card(menu, "drone-motors", "MOTORS"); - const bool armed = drone::armed(); - info(menu, "Motors", armed ? "ARMED" : "DISARMED"); - if (drone::flying()) { - const auto& t = drone::telemetry(); - info(menu, "Flight", std::format("{:.0f}% throttle, {:.0f} km/h, {:.1f} m up, {:.0f} s", t.throttle.load() * 100.0f, - t.speed.load() * 3.6f, t.altitude.load(), t.seconds.load())); - } - const float third = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; - if (ImGui::Button(armed ? "Disarm" : "Arm", ImVec2(third, 0))) drone::set_armed(!armed); - ImGui::SameLine(); - if (ImGui::Button("Back to takeoff", ImVec2(third, 0))) drone::request_reset(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts the drone back where it took off, with a fresh battery and timer."); - ImGui::SameLine(); - if (ImGui::Button("Reset settings", ImVec2(third, 0))) run(callbacks, "dronetune reset"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Puts every drone setting back to its default."); - end_card(); - - ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 170, 170, 255)); - ImGui::TextUnformatted("Created by neekgate for ReSkate"); - ImGui::PopStyleColor(); -} - -// ---- HANDLING -void handling_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - begin_card(menu, "drone-mode", "FLIGHT MODE"); - options(menu, callbacks, Knob::kFlightMode, "Mode", {"Acro", "Angle (self-levelling)"}); - if (get(Knob::kFlightMode) >= 0.5f) { - slider(menu, callbacks, Knob::kMaxAngle, "Most lean", "%.0f degrees"); - note("The sticks set how far the drone leans; let go and it levels out. Easier to learn on, but no flips."); - } else { - note("The sticks set how fast the drone turns, and it stays at whatever angle you leave it: how real FPV pilots fly."); - } - toggle(menu, callbacks, Knob::kAltHold, "Hold height with a springing throttle"); - slider(menu, callbacks, Knob::kClimbRate, "Climb speed", "%.1f m/s"); - note("For gamepads and the keyboard, whose throttle springs back. Off: the middle of the stick is hover power, and the " - "throttle never changes by itself. On: the middle holds your height, adding throttle as you lean to keep it. " - "An RC radio's throttle stays where you put it and is not affected."); - end_card(); - - begin_card(menu, "drone-flight", "FLIGHT"); - slider(menu, callbacks, Knob::kGravity, "Gravity", "%.1f"); - slider(menu, callbacks, Knob::kHover, "Hover throttle"); - slider(menu, callbacks, Knob::kDrag, "Air drag"); - slider(menu, callbacks, Knob::kUptilt, "Camera tilt", "%.0f degrees"); - end_card(); - - begin_card(menu, "drone-collisions", "COLLISIONS"); - toggle(menu, callbacks, Knob::kCollide, "Collide with the world"); - slider(menu, callbacks, Knob::kRadius, "Collision size", "%.2f m"); - slider(menu, callbacks, Knob::kFloor, "Invisible floor", "%.1f m"); - toggle(menu, callbacks, Knob::kDisarmFreeze, "Hang in the air when disarmed"); - end_card(); - - begin_card(menu, "drone-crashes", "CRASHES"); - toggle(menu, callbacks, Knob::kCrash, "Crashes"); - if (get(Knob::kCrash) >= 0.5f) { - slider(menu, callbacks, Knob::kCrashSpeed, "Hit that cuts the motors", "%.1f m/s"); - const auto cut = get(Knob::kCrashSpeed) > 0.0f - ? std::format("Bumps bounce the drone off and knock it about. Hitting something at {:.0f} km/h or more cuts the motors: " - "it tumbles down, and you arm again to fly on.", get(Knob::kCrashSpeed) * 3.6f) - : std::string("Bumps bounce the drone off and knock it about, but never cut the motors."); - note(cut.c_str()); - } else { - note("Off: the drone just stops against whatever it meets."); - } - slider(menu, callbacks, Knob::kPropwash, "Prop wash shake"); - end_card(); - - begin_card(menu, "drone-range", "RANGE"); - bool limited = get(Knob::kRange) > 0.0f; - if (toggle_row(menu, "Limited range", - "Like a real radio and video link: the picture breaks up as you near the limit, and further out the motors cut " - "(failsafe) until you reset. Off: fly anywhere on the map.", limited)) { - set(Knob::kRange, limited ? 300.0f : 0.0f); - save(callbacks, Knob::kRange); - } - if (limited) { - field(menu, "Range"); - float metres = get(Knob::kRange); - if (::dingosdk::overlay::menu::slider_float("##range", &metres, 50.0f, 5000.0f, "%.0f m", ImGuiSliderFlags_Logarithmic)) set(Knob::kRange, metres); - if (ImGui::IsItemDeactivatedAfterEdit()) save(callbacks, Knob::kRange); - const auto explained = std::format( - "Measured from where you took off. The picture starts to break up at {:.0f} m and is lost at {:.0f} m. Stay without a " - "picture for {:.0f} seconds, or go past {:.0f} m, and the motors cut.", - get(Knob::kRange) * drone::range_clean, get(Knob::kRange), drone::failsafe_after_seconds, - get(Knob::kRange) * drone::range_failsafe); - note(explained.c_str()); - } else { - note("Unlimited: fly anywhere on the map."); - } - end_card(); -} - -// ---- RATES -void rates_card(SkateMenu& menu, const CallbacksV3& callbacks, const char* id, const char* title, Knob centre, Knob max, Knob expo) { - begin_card(menu, id, title); - slider(menu, callbacks, centre, "Centre sensitivity", "%.0f deg/s"); - slider(menu, callbacks, max, "Max rate", "%.0f deg/s"); - slider(menu, callbacks, expo, "Expo"); - end_card(); -} -void rates_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - note("How fast the drone turns, set like Betaflight's \"Actual\" rates. Centre: the speed near the middle of the stick. " - "Max: the speed at full stick. Expo: how much softer the middle is."); - rates_card(menu, callbacks, "drone-roll", "ROLL", Knob::kRollCenter, Knob::kRollMax, Knob::kRollExpo); - rates_card(menu, callbacks, "drone-pitch", "PITCH", Knob::kPitchCenter, Knob::kPitchMax, Knob::kPitchExpo); - rates_card(menu, callbacks, "drone-yaw", "YAW", Knob::kYawCenter, Knob::kYawMax, Knob::kYawExpo); -} - -// ---- DISPLAY -void display_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - begin_card(menu, "drone-osd", "GOGGLE DISPLAY", "the OSD"); - toggle(menu, callbacks, Knob::kOsd, "Show the goggle display"); - slider(menu, callbacks, Knob::kOsdScale, "Text size"); - options(menu, callbacks, Knob::kOsdColor, "Colour", {"White", "Green", "Yellow", "Cyan", "Orange", "Pink"}); - options(menu, callbacks, Knob::kOsdFont, "Font", {"Pixel", "Smooth"}); - slider(menu, callbacks, Knob::kOsdMargin, "Distance from the edge"); - toggle(menu, callbacks, Knob::kCrosshair, "Crosshair"); - end_card(); - - begin_card(menu, "drone-osd-show", "SHOW"); - toggle(menu, callbacks, Knob::kShowArmed, "Armed / disarmed"); - toggle(menu, callbacks, Knob::kShowBattery, "Battery"); - toggle(menu, callbacks, Knob::kShowTimer, "Timer"); - toggle(menu, callbacks, Knob::kShowThrottle, "Throttle"); - toggle(menu, callbacks, Knob::kShowAlt, "Altitude"); - toggle(menu, callbacks, Knob::kShowSpeed, "Speed"); - toggle(menu, callbacks, Knob::kSticks, "My sticks on screen"); - slider(menu, callbacks, Knob::kStickSize, "Sticks size"); - end_card(); - - begin_card(menu, "drone-analog", "ANALOGUE VIDEO"); - toggle(menu, callbacks, Knob::kAnalog, "Analogue video look"); - slider(menu, callbacks, Knob::kAnalogNoise, "Static"); - slider(menu, callbacks, Knob::kAnalogScan, "Scanlines"); - slider(menu, callbacks, Knob::kAnalogVignette, "Dark corners"); - slider(menu, callbacks, Knob::kAnalogGlitch, "Rolling glitches"); - end_card(); - - begin_card(menu, "drone-sound", "SOUND"); - slider(menu, callbacks, Knob::kVolume, "Motor volume"); - slider(menu, callbacks, Knob::kMotorPitch, "Motor pitch"); - end_card(); -} - -// ---- CONTROLLER -const char* axis_text(int signed_axis, char* buffer, std::size_t size) { - if (signed_axis == 0) std::snprintf(buffer, size, "none"); - else std::snprintf(buffer, size, "axis %d%s", std::abs(signed_axis), signed_axis < 0 ? " (reversed)" : ""); - return buffer; -} -// Live bars for the six axes; `highlight` (0-5) is drawn as the one being picked. -void axis_bars(const drone::radio::Sample& sample, int highlight) { - for (int i = 0; i < 6; ++i) { - const auto v = sample.axes[static_cast(i)]; - char value[16]; - std::snprintf(value, sizeof value, "%+.2f", static_cast(v)); - const bool lit = i == highlight; - if (lit) ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(120, 220, 120, 255)); - ImGui::ProgressBar((v + 1.0f) * 0.5f, ImVec2(ImGui::GetContentRegionAvail().x * 0.75f, 0), value); - if (lit) ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::Text("Axis %d%s", i + 1, lit ? " <" : ""); - } -} -// The guided setup (drone_radio.h: Calibration). Saves through the same commands as the manual controls. -void setup_wizard(SkateMenu& menu, const CallbacksV3& callbacks, const drone::radio::Sample& sample) { - using Step = drone::radio::Calibration::Step; - auto& c = drone::radio::calibration(); - if (sample.valid) c.update(sample.axes, sample.buttons, ImGui::GetIO().DeltaTime); - const auto step = c.step(); - if (c.active()) drone::radio::mark_calibrating(); - const char* title = ""; - const char* body = ""; - switch (step) { - case Step::rest: - title = "STEP 1 OF 7: STICKS AT REST"; - body = "Let go of both sticks so they centre, and put the throttle all the way down (a gamepad's sticks just centre). " - "Then press Ready."; - break; - case Step::throttle: - title = "STEP 2 OF 7: THROTTLE"; - body = c.waiting_for_return() ? "Got it. Now let the throttle back down." - : "Push the throttle all the way up and hold it there. On a gamepad, that's the left stick, up."; - break; - case Step::yaw: - title = "STEP 3 OF 7: YAW"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the yaw stick (left stick, sideways) fully right and hold it."; - break; - case Step::pitch: - title = "STEP 4 OF 7: PITCH"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully forward (away from you) and hold it."; - break; - case Step::roll: - title = "STEP 5 OF 7: ROLL"; - body = c.waiting_for_return() ? "Got it. Let the stick go." : "Push the right stick fully right and hold it."; - break; - case Step::arm: - title = "STEP 6 OF 7: ARM SWITCH (OPTIONAL)"; - body = "Flip the switch you want to arm the motors with to its ARMED position. No arm switch? Press Skip."; - break; - case Step::reset: - title = "STEP 7 OF 7: RESET SWITCH (OPTIONAL)"; - body = c.waiting_for_return() ? "Got it. Let the switch go back." - : "Flick the switch or press the button you want to put the drone back at its start. No reset switch? Press Skip."; - break; - default: break; - } - if (step == Step::done) { - // Save once, then show what was found. - static bool saved = false; - if (!saved) { - const auto m = c.mapping(); - run(callbacks, std::format("droneaxes {} {} {} {}", m[0], m[1], m[2], m[3])); - set(Knob::kArmAxis, static_cast(c.arm_axis())); save(callbacks, Knob::kArmAxis); - set(Knob::kArmButton, static_cast(c.arm_button())); save(callbacks, Knob::kArmButton); - set(Knob::kResetAxis, static_cast(c.reset_axis())); save(callbacks, Knob::kResetAxis); - set(Knob::kResetButton, static_cast(c.reset_button())); save(callbacks, Knob::kResetButton); - set(Knob::kThrottleCentre, c.throttle_centred() ? 1.0f : 0.0f); save(callbacks, Knob::kThrottleCentre); - // Gamepad sticks drift more than a radio's gimbals. - if (c.throttle_centred() && get(Knob::kDeadzone) < 0.12f) { set(Knob::kDeadzone, 0.12f); save(callbacks, Knob::kDeadzone); } - // Setting a controller up means flying with it. - if (!drone::radio::config().enabled.load()) run(callbacks, "droneradio 1"); - saved = true; - } - begin_card(menu, "drone-setup-done", "SETUP SAVED", "the drone now flies with this controller"); - const auto m = c.mapping(); - static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; - char buffer[32]; - for (int i = 0; i < 4; ++i) info(menu, names[i], axis_text(m[static_cast(i)], buffer, sizeof buffer)); - const auto switch_text = [&buffer](int axis, int button) -> std::string { - if (button > 0) return std::format("button {}", button); - return axis_text(axis, buffer, sizeof buffer); - }; - info(menu, "Arm switch", switch_text(c.arm_axis(), c.arm_button())); - info(menu, "Reset switch", switch_text(c.reset_axis(), c.reset_button())); - if (c.throttle_centred()) - note("Your throttle springs back to the middle, so the middle now holds the drone up: push up to climb, down to descend."); - if (primary_button(menu, "DONE")) { c.cancel(); saved = false; } - end_card(); - return; - } - begin_card(menu, "drone-setup", title); - note(body); - if (!sample.valid) warn("The controller is not connected."); - else axis_bars(sample, c.waiting_for_return() ? -1 : c.candidate()); - const float third = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2.0f) / 3.0f; - if (step == Step::rest) { - ImGui::BeginDisabled(!sample.valid); - if (ImGui::Button("Ready", ImVec2(third, 0))) c.capture_rest(sample.axes, sample.buttons); - ImGui::EndDisabled(); - ImGui::SameLine(); - } else if ((step == Step::arm || step == Step::reset) && !c.waiting_for_return()) { - if (ImGui::Button("Skip", ImVec2(third, 0))) c.skip(); - ImGui::SameLine(); - } - if (ImGui::Button("Cancel", ImVec2(third, 0))) c.cancel(); - end_card(); -} -// A gamepad (drone_flight.h: gamepad_sticks): a fixed Mode 2 layout, so nothing to set up but feel. -void gamepad_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - const auto pad = overlay::read_gamepad(); - begin_card(menu, "drone-gamepad", "GAMEPAD"); - if (!pad.valid) { - warn("No gamepad found. Plug in an Xbox or PlayStation controller."); - } else { - info(menu, "Connected", pad.playstation ? "PlayStation controller" : "Xbox controller"); - const auto sticks = drone::read_gamepad(pad); - const auto bar = [](const char* label, float v) { - char value[16]; - std::snprintf(value, sizeof value, "%+.2f", static_cast(v)); - ImGui::ProgressBar((v + 1.0f) * 0.5f, ImVec2(ImGui::GetContentRegionAvail().x * 0.75f, 0), value); - ImGui::SameLine(); - ImGui::TextUnformatted(label); - }; - bar("Throttle", sticks.climb); - bar("Yaw", sticks.yaw); - bar("Pitch", sticks.pitch); - bar("Roll", sticks.roll); - note("What the drone gets from each stick, after the dead zone and sensitivity below."); - } - note("Left stick: throttle up and down, turning left and right. Right stick: tilt (pitch, see Invert below) and roll. " - "A (cross) arms and disarms, Menu (Options) puts the drone back where it took off."); - end_card(); - - begin_card(menu, "drone-gamepad-feel", "STICKS"); - slider(menu, callbacks, Knob::kPadRate, "Sensitivity"); - slider(menu, callbacks, Knob::kPadExpo, "Softer middle"); - slider(menu, callbacks, Knob::kDeadzone, "Stick dead zone"); - toggle(menu, callbacks, Knob::kPadInvert, "Invert the right stick (up tips the nose up)"); - note("Sensitivity scales how fast the drone can turn at full stick (your RATES still shape it). A softer middle " - "makes small corrections gentler without slowing full-stick flips. Gamepads use a dead zone of at least 0.08."); - end_card(); -} -void controller_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - if (drone::gamepad_mode()) { - options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"}); - gamepad_tab(menu, callbacks); - return; - } - const auto sample = drone::radio::poll(); - auto& c = drone::radio::calibration(); - if (c.step() != drone::radio::Calibration::Step::idle) { - setup_wizard(menu, callbacks, sample); - return; - } - options(menu, callbacks, Knob::kGamepad, "Controller", {"RC radio", "Gamepad"}); - begin_card(menu, "drone-controller", "CONTROLLER"); - if (!sample.valid) { - warn("No controller found. Plug in a gamepad, or an RC radio with its USB mode set to Joystick (on EdgeTX radios, pick " - "\"USB Joystick (HID)\" when you plug it in)."); - } else { - info(menu, "Connected", sample.name); - if (primary_button(menu, "SET UP CONTROLLER")) c.start(); - note("Goes through each stick and switch and saves which is which. The drone holds still meanwhile."); - } - end_card(); - - begin_card(menu, "drone-manual", "MANUAL SETUP"); - if (sample.valid) { - axis_bars(sample, -1); - std::string pressed; - for (int n = 1; n <= 32; ++n) - if (sample.buttons & (1u << (n - 1))) pressed += (pressed.empty() ? "" : " ") + std::to_string(n); - info(menu, "Buttons pressed", pressed.empty() ? "none" : pressed); - note("Move a stick or flip a switch to see which axis or button it is."); - } - static const char* const names[4] = {"Roll", "Pitch", "Throttle", "Yaw"}; - static const char* const axes[] = {"Axis 1", "Axis 2", "Axis 3", "Axis 4", "Axis 5", "Axis 6"}; - auto map = drone::radio::mapping_snapshot(); - bool changed = false; - for (int i = 0; i < 4; ++i) { - ImGui::PushID(i); - int axis = std::clamp(std::abs(map[static_cast(i)]), 1, 6) - 1; - bool flip = map[static_cast(i)] < 0; - field(menu, names[i]); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.6f); - if (ImGui::Combo("##axis", &axis, axes, 6)) changed = true; - ImGui::SameLine(); - if (ImGui::Checkbox("Reverse", &flip)) changed = true; - map[static_cast(i)] = (axis + 1) * (flip ? -1 : 1); - ImGui::PopID(); - } - if (changed) run(callbacks, std::format("droneaxes {} {} {} {}", map[0], map[1], map[2], map[3])); - toggle(menu, callbacks, Knob::kThrottleCentre, "Throttle springs back to the middle"); - slider(menu, callbacks, Knob::kDeadzone, "Stick dead zone"); - end_card(); - - begin_card(menu, "drone-switches", "SWITCHES", "an axis or button no stick uses; 0 = none"); - integer(menu, callbacks, Knob::kArmAxis, "Arm switch axis"); - integer(menu, callbacks, Knob::kArmButton, "Arm button"); - integer(menu, callbacks, Knob::kResetAxis, "Reset switch axis"); - integer(menu, callbacks, Knob::kResetButton, "Reset button"); - end_card(); -} - -// ---- LOBBY -void lobby_tab(SkateMenu& menu, const CallbacksV3& callbacks) { - begin_card(menu, "drone-lobby", "OTHER PLAYERS"); - if (!drone::beacon::server_allows().load()) { - warn("This server has drones turned off. You can still fly, but nobody here sees your drone and you don't see theirs."); - } - toggle(menu, callbacks, Knob::kBeacon, "Let other players see my drone"); - toggle(menu, callbacks, Knob::kShowBeacons, "Show other players' drones"); - note("Other players only see drones if they are on a version of ReSkate with the drone."); - note("Drone model: FPV Drone by PinaPipe (CC BY 4.0), reduced and rescaled for the game."); - end_card(); -} -} // namespace - -void drone_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - category_tabs(menu, menu.drone_tab, {"FLY", "FLIGHT", "DISPLAY", "CONTROLLER"}, "drone-tabs"); - ImGui::PushID(menu.drone_tab); - ImGui::BeginChild("drone-tab", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); - switch (menu.drone_tab) { - case 0: fly_tab(menu, model, callbacks); break; - case 1: - handling_tab(menu, callbacks); - rates_tab(menu, callbacks); - break; - case 2: - display_tab(menu, callbacks); - lobby_tab(menu, callbacks); - break; - default: controller_tab(menu, callbacks); break; - } - ImGui::EndChild(); - ImGui::PopID(); -} -} // namespace dingosdk::overlay::menu diff --git a/Extension/UI/Overlay/hub_page.cpp b/Extension/UI/Overlay/hub_page.cpp index fba81f21..32f01fa9 100644 --- a/Extension/UI/Overlay/hub_page.cpp +++ b/Extension/UI/Overlay/hub_page.cpp @@ -1212,17 +1212,6 @@ void tools_page(Ui &ui, ImVec2 a, ImVec2 z) { if (list.setting("Noclip", on_off(d.noclip), (d.noclip_available || d.noclip) && debugging)) debug({DebugAction::set_noclip, !d.noclip}); if (list.setting("No bail", on_off(d.no_bail), (d.no_bail_available || d.no_bail) && debugging)) debug({DebugAction::set_no_bail, !d.no_bail}); if (list.setting("Hall of Meat", on_off(m.hall_of_meat.enabled), m.hall_of_meat.available && console)) act("hall-of-meat"); - // (Not listed when its official mod is turned off in the launcher.) - if (!m.road_rash.off) { - if (list.setting("Road Rash", on_off(m.road_rash.enabled), m.road_rash.available && console)) act("road-rash"); - if (list.setting("Road Rash blood", on_off(m.road_rash.blood), m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-blood"); - if (list.setting("Heal Road Rash", {}, m.road_rash.available && m.road_rash.enabled && console)) act("road-rash-heal"); - } - if (!m.board_break.off) { - if (list.setting("Board Break", on_off(m.board_break.enabled), m.board_break.available && console)) act("board-break"); - if (list.setting("Board Break deck", m.board_break.deck, m.board_break.available && m.board_break.has_deck && console)) - act("board-break-deck"); - } const auto &grind = m.grind_exit; if (list.setting("Grind exit triggers", on_off(grind.enabled), grind.available && console)) act("grind-exits"); if (list.setting("Input Mode", grind.toe_heel ? "Toe / Heel" : "Left / Right", grind.available && console)) act("grind-exit-sides"); diff --git a/Extension/UI/Overlay/modding_menu.cpp b/Extension/UI/Overlay/modding_menu.cpp index d5648aba..c9a4fffd 100644 --- a/Extension/UI/Overlay/modding_menu.cpp +++ b/Extension/UI/Overlay/modding_menu.cpp @@ -6,7 +6,7 @@ #include "Engine/Vfs/mod_catalog.h" #include "Engine/Vfs/mod_conflicts.h" #include "Extension/Assets/live_mods.h" -#include "Extension/BoardBreak/board_break.h" +#include "Extension/Skater/breakable_deck.h" #include "Extension/UI/skate_theme.h" #include "Engine/Core/Log/logging.h" @@ -67,28 +67,28 @@ struct DeckScan { }; } namespace menu { -// Board Break's decks (Extension/BoardBreak/board_break.h). The game has one deck model and each -// deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the +// The breakable decks (Extension/Skater/breakable_deck.h), which the board-break plugin snaps. The +// game has one deck model and each deck is a mod's copy of it, so picking one applies the mods with that deck first and loads the // level again. A button for each deck the feature was made with, greyed out while it is not // installed, and for any other deck that is; the Mods folder is read again every 1.5 s while the // card shows. A deck goes by the name its deck.ini gives it (label), else by its folder's. -void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { +void breakable_deck_card(SkateMenu& menu, const CallbacksV3& callbacks) { static auto* scans = new FolderRead; // process lifetime, as the threads that fill it const auto scan = scans->get([] { DeckScan read; read.installed = mods::installed_board_break_decks(); read.chosen = mods::wanted_board_break_deck(); - for (const auto& deck : read.installed) read.titles.push_back(board_break::deck_label(deck)); + for (const auto& deck : read.installed) read.titles.push_back(breakable_deck::label(deck)); return read; }, 1500); if (!scan) return; const auto& installed = scan->installed; const auto& titles = scan->titles; const auto& chosen = scan->chosen; - if (!model.board_break.available || installed.empty()) return; + if (installed.empty()) return; const auto there = [&](const std::string& deck) { return std::find(installed.begin(), installed.end(), deck) != installed.end(); }; // The known decks in their own order, then the others that are installed: the numbers are the - // ones `boardbreak deck` prints. + // ones `boarddeck` prints. std::vector decks(mods::board_break_decks.begin(), mods::board_break_decks.end()); for (const auto& deck : installed) if (std::find(decks.begin(), decks.end(), deck) == decks.end()) decks.push_back(deck); @@ -110,7 +110,7 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV return std::to_string(place) + " " + name; }; const auto in_play = mods::board_break_in_play(); - begin_card(menu, "board-break-decks", "BOARD BREAK DECK", in_play.empty() ? "none in the game" : label(in_play).c_str()); + begin_card(menu, "breakable-decks", "BREAKABLE DECK", in_play.empty() ? "none in the game" : label(in_play).c_str()); const bool busy = live_mods::busy(); if (menu::console_style()) { // Full screen: one row, left / right to a deck and A to put it in the game. @@ -131,7 +131,7 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV if (load && staged >= 0) { const auto& deck = decks[static_cast(staged)]; if (!there(deck)) menu::feedback(menu, "That deck is not installed."); - else if (deck != in_play) send_console(menu, callbacks, "boardbreak deck " + deck); + else if (deck != in_play) send_console(menu, callbacks, "boarddeck " + deck); } } else { const float gap = ImGui::GetStyle().ItemSpacing.x; @@ -142,7 +142,7 @@ void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV ImGui::PushID(static_cast(i)); ImGui::BeginDisabled(!there(deck) || busy || deck == in_play || !callbacks.queue_console_command); if (ImGui::Button((label(deck) + (deck == in_play ? " *" : "")).c_str(), ImVec2(width, 0))) - send_console(menu, callbacks, "boardbreak deck " + deck); + send_console(menu, callbacks, "boarddeck " + deck); ImGui::EndDisabled(); ImGui::PopID(); } @@ -334,7 +334,7 @@ void draw_installed_mods(SkateMenu& menu) { } else if (set_aside(entry)) { status = "SET ASIDE"; hover = "Set aside while a Board Break deck is the one in the game: the game has one deck model, so one " - "mod's deck loads at a time. Pick another deck under BOARD BREAK DECK, or disable Board Break's decks."; + "mod's deck loads at a time. Pick another deck under BREAKABLE DECK, or disable the breakable decks."; } else if (entry.enabled && effect) { status = "LOADED"; colour = skate_theme::good; @@ -440,7 +440,7 @@ void draw_custom_scripts(SkateMenu& menu) { menu::end_card(); } } -void draw_modding_menu(SkateMenu& menu, int tab, const Model&, const CallbacksV3&) { +void draw_modding_menu(SkateMenu& menu, int tab, const Model&, const CallbacksV3& callbacks) { // Loose files are switched in the launcher; custom scripts only need to know // whether they are on. if (!menu.loose_files_settings_loaded) { @@ -449,6 +449,9 @@ void draw_modding_menu(SkateMenu& menu, int tab, const Model&, const CallbacksV3 catch (const std::exception&) {} } if (tab == 1) draw_custom_scripts(menu); - else draw_installed_mods(menu); + else { + menu::breakable_deck_card(menu, callbacks); + draw_installed_mods(menu); + } } } diff --git a/Extension/UI/Overlay/nametag_overlay.cpp b/Extension/UI/Overlay/nametag_overlay.cpp index 565357c5..409fd27f 100644 --- a/Extension/UI/Overlay/nametag_overlay.cpp +++ b/Extension/UI/Overlay/nametag_overlay.cpp @@ -3,7 +3,6 @@ #include "chat_rich.h" #include "nametag_gradient.h" #include "role_badge.h" -#include "Extension/Skater/drone_flight.h" #include #include #include @@ -315,17 +314,7 @@ void draw_nametags() { const auto dim = with_alpha(IM_COL32(230, 230, 230, 255), 0.85f * alpha); draw->AddText(font, detail, ImVec2(distance_at.x + offset, distance_at.y + offset), shadow, distance.c_str()); draw->AddText(font, detail, distance_at, dim, distance.c_str()); - // Flying the FPV drone: said above the name, unless drones are hidden (DRONE page, LOBBY tab). - if (tag.flying_drone && - dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f) { - const char *label = "FLYING DRONE"; - 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; - } - // A plugin's tag for the player (reskate.session.tag): above the name, or the drone label over it. + // A plugin's tag for the player (reskate.session.tag): above the name. if (!tag.extra.empty()) { const auto extra_size = font->CalcTextSizeA(detail, FLT_MAX, 0.0f, tag.extra.c_str()); const float above = std::min(bubble_bottom, name_at.y); @@ -336,7 +325,7 @@ void draw_nametags() { } 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 = std::min(bubble_bottom, name_at.y); // above the name, or the drone label over it + bubble_bottom = std::min(bubble_bottom, name_at.y); // above the name, or the plugin's tag 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 0f36314a..978f7312 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -78,7 +78,6 @@ enum class DebugAction { set_first_person_camera_cycle, reset_first_person_arm, set_free_camera_fov, // 0 = the game's own FOV - set_drone_follow, // the drone flies itself behind the skater, and the view is its camera // Keep the last action in sync with the bound in request_scheduler.h. }; @@ -111,7 +110,6 @@ struct DebugModel { bool ui_available = false; bool free_camera = false; bool first_person = false; - bool drone_follow = false; // the drone flies itself behind the skater (Skater/drone_follow.h) // Vertical degrees applied to the first-person view; 0 keeps the camera's own. float first_person_fov = 0; float free_camera_fov = 0; // 0 = the game's own FOV @@ -223,28 +221,6 @@ struct HallOfMeatModel { bool available = false, enabled = false; }; -// Road Rash's switches (Extension/RoadRash/road_rash.h): started, switched on, and whether its -// worst wounds bleed. `off`: its official mod is turned off in the launcher, and no menu lists it. -struct RoadRashModel { - bool available = false, enabled = false, blood = false; - bool off = false; -}; - -// Board Break's switch (Extension/BoardBreak/board_break.h): started, switched on, and which of -// its decks is the one in the game (none: no deck pack loads, and no board can break). `off`: its -// official mod is turned off in the launcher, and no menu lists it. -struct BoardBreakModel { - bool available = false, enabled = false, has_deck = false; - std::string deck = "None"; - bool off = false; - // Its settings (Extension/BoardBreak/board_break_feel.h) and how worn the board is (0 to 1, and - // its word). - bool always = false, rails = true, wear = true, crack_sounds = true, body_sounds = true; - int sensitivity = 100, joined = 50, wear_rate = 100; - float worn = 0; - std::string condition = "new"; -}; - // Grind exit control's switches (Extension/Skater/grind_exit_control.h): started, switched on, // LT/RT toe/heel (else left/right of travel), and the player's stance (0 not set, 1 regular, // 2 goofy). @@ -282,8 +258,6 @@ struct Model { ProgressionModel progression; PlayerCardModel player_card; HallOfMeatModel hall_of_meat; - RoadRashModel road_rash; - BoardBreakModel board_break; GrindExitModel grind_exit; KeyframeSmoothingModel keyframe_smoothing; ObjectPersistenceModel object_persistence; @@ -454,7 +428,6 @@ 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 std::string extra; // a plugin's short tag above the name ("" for none) std::uint32_t extra_color{0xffffffffU}; // Recent chat lines to show as bubbles above the head, oldest first ("" = none). @@ -566,7 +539,7 @@ extern "C" __declspec(dllexport) bool DingoSDKOverlayStartV3( namespace dingosdk::overlay { // 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. +// Plugins read it (reskate.input.gamepad); safe from any thread. struct Gamepad { bool valid = false; bool playstation = false; @@ -598,8 +571,8 @@ inline std::atomic& menu_open_flag() { return value; } // 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). +// the game from reading it (freecam, or a plugin's input capture). Same thread, paired. +// Plugins read controllers this way (Extension/Plugins/plugin_joystick.h). void begin_input_access() noexcept; void end_input_access() noexcept; } diff --git a/Extension/UI/Overlay/overlay_internal.h b/Extension/UI/Overlay/overlay_internal.h index 8a365590..e854b7e3 100644 --- a/Extension/UI/Overlay/overlay_internal.h +++ b/Extension/UI/Overlay/overlay_internal.h @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -185,6 +186,8 @@ struct State { ComPtr menu_map_texture, menu_map_upload; // The icon of the mod the highlighted item is from (item_browser_overlay.cpp), in the fourth. ComPtr mod_icon_texture, mod_icon_upload; + // Plugins' pictures (plugin_images.cpp), from the fifth slot on. + std::array, 16> plugin_textures, plugin_uploads; ComPtr commands; ComPtr fence; HANDLE fence_event = nullptr; @@ -318,14 +321,25 @@ void menu_map_pixels_uploaded(ImTextureID texture, unsigned serial); void menu_map_pixels_lost(); // The item card's mod icon (item_browser_overlay.cpp), the same way, in the fourth slot. bool take_mod_icon_pixels(CardPixels& pixels); +// Plugins' pictures (plugin_images.cpp): the SRV heap's slots from plugin_image_first_slot on. A +// draw asks for a picture by its file (its id once on the GPU, 0 until then); the render thread +// takes one decoded picture a frame to upload and says where it went. +inline constexpr UINT plugin_image_first_slot = 4, plugin_image_slots = 16; +ImTextureID plugin_image(const std::string& path, ImVec2& size); +struct PluginImageUpload { + std::string path; + int slot{}; + CardPixels pixels; +}; +std::optional take_plugin_image(); +// id 0: it failed; `later`: the slot was busy, so it waits for another frame. +void plugin_image_uploaded(const std::string& path, int slot, ImTextureID id, bool later = false); +void plugin_images_lost(); void mod_icon_uploaded(ImTextureID texture, unsigned serial); void mod_icon_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(); diff --git a/Extension/UI/Overlay/overlay_render.cpp b/Extension/UI/Overlay/overlay_render.cpp index 31682903..d871fb64 100644 --- a/Extension/UI/Overlay/overlay_render.cpp +++ b/Extension/UI/Overlay/overlay_render.cpp @@ -3,7 +3,6 @@ #include "Engine/Core/Profiling/profiler.h" #include "overlay_internal.h" #include "Extension/Trainer/trainer_page.h" -#include "Extension/Skater/drone_flight.h" #include "Extension/HallOfMeat/hall_of_meat_overlay.h" #include "plugin_overlay.h" #include "park_previews.h" @@ -82,6 +81,9 @@ void destroy_graphics() { menu_map_pixels_lost(); s.mod_icon_texture.Reset(); s.mod_icon_upload.Reset(); mod_icon_lost(); + for (auto& texture : s.plugin_textures) texture.Reset(); + for (auto& upload : s.plugin_uploads) upload.Reset(); + plugin_images_lost(); s.commands.Reset(); s.rtvs.Reset(); s.srvs.Reset(); s.fence.Reset(); s.device.Reset(); if (s.fence_event) CloseHandle(s.fence_event); s.fence_event = nullptr; @@ -289,7 +291,9 @@ bool setup_graphics() { return false; } heap.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; - heap.NumDescriptors = 4; // ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon (upload_image) + // ImGui's atlas, the map download card's icon, the menu's map pictures, an item's mod icon, and + // plugins' pictures (upload_image) + heap.NumDescriptors = plugin_image_first_slot + plugin_image_slots; heap.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; graphics_result = s.device->CreateDescriptorHeap(&heap, IID_PPV_ARGS(&s.srvs)); if (FAILED(graphics_result)) { @@ -694,6 +698,22 @@ void upload_mod_icon(State& s) { if (const auto id = upload_image(s, pixels, 3, s.mod_icon_texture, s.mod_icon_upload)) mod_icon_uploaded(id, pixels.serial); } +// A plugin's picture (plugin_images.cpp), one a frame, into the slot the cache chose. +void upload_plugin_image(State& s) { + if (!s.device || !s.srvs) return; + auto next = take_plugin_image(); + if (!next) return; + const auto slot = static_cast(next->slot); + // A texture in the slot may still be sampled by frames on the GPU. + if (s.plugin_textures[slot] && s.next_fence && !completed(s.next_fence, render_fence_timeout_ms)) { + plugin_image_uploaded(next->path, next->slot, {}, true); + return; + } + const auto id = upload_image(s, next->pixels, plugin_image_first_slot + static_cast(slot), s.plugin_textures[slot], + s.plugin_uploads[slot]); + plugin_image_uploaded(next->path, next->slot, id); +} + void render(IDXGISwapChain* presented, UINT flags) { auto& s = state(); if (flags & DXGI_PRESENT_TEST) return; @@ -737,7 +757,6 @@ 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(); @@ -746,7 +765,6 @@ void render(IDXGISwapChain* presented, UINT flags) { const bool perf_frame = perf_hud_pending() || trainer_hud_pending(); const bool hub_frame = hub_page_pending(); if (trainer_open_requested()) s.visible.store(true); - if (dingosdk::drone::menu_flag().load()) s.visible.store(true); // `dronemenu`: the menu opens on its DRONE page if (dingosdk::overlay::menu_open_flag().exchange(false)) s.visible.store(true); // `menu`, or its controller bind const bool menu_frame = interactive_visible(s); if (!menu_frame) { @@ -763,7 +781,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 && !drone_hud_frame && !skate_hud_frame && + if (s.loaded_notice_posted && !notices_pending() && !chat_frame && !game_text_frame && !skate_hud_frame && !nametag_frame && !meat_frame && !plugins_frame && !item_browser_frame && !perf_frame && !hub_frame && !map_download_frame) return; } else if (!s.ui_was_interactive) { s.ui_was_interactive = true; @@ -820,7 +838,6 @@ void render(IDXGISwapChain* presented, UINT flags) { draw_plugins(); draw_nametags(); draw_game_text(); - draw_drone_hud(); draw_skate_hud(); draw_perf_hud(); draw_trainer_hud(); @@ -849,6 +866,7 @@ void render(IDXGISwapChain* presented, UINT flags) { upload_card_image(s); upload_menu_map_image(s); upload_mod_icon(s); + upload_plugin_image(s); D3D12_RESOURCE_BARRIER barrier{}; barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; barrier.Transition.pResource = frame.buffer.Get(); diff --git a/Extension/UI/Overlay/plugin_images.cpp b/Extension/UI/Overlay/plugin_images.cpp new file mode 100644 index 00000000..543e043a --- /dev/null +++ b/Extension/UI/Overlay/plugin_images.cpp @@ -0,0 +1,189 @@ +#include "overlay_internal.h" +#include "Engine/Core/Log/logging.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Pictures plugins draw (reskate.overlay's d:image), from files in their own packages: the plugin +// host checked each path is inside its package. One worker thread decodes them (WIC, at most 512 +// pixels a side and 2 MB a file); the render thread uploads one a frame into the SRV heap's plugin +// slots, and a picture not drawn for 2 s gives its slot up when another needs it. +namespace dingosdk::overlay::detail { +namespace { +constexpr std::size_t most_pictures = 32; // known at once, decoded or waiting +constexpr std::uintmax_t most_file_bytes = 2 * 1024 * 1024; +constexpr double idle_seconds = 2; + +struct Entry { + enum class Stage { waiting, decoded, uploaded, failed } stage{Stage::waiting}; + CardPixels pixels; + int slot{-1}; + ImTextureID id{}; + double drawn{}; +}; +struct Cache { + std::mutex mutex; + std::condition_variable wake; + std::map> entries; + std::deque queue; + std::set> warned; + bool worker{}; +}; +Cache& cache() { + static auto* value = new Cache; + return *value; +} +double seconds_now() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +void warn_once(Cache& c, const std::string& path, const char* why) { + if (!c.warned.insert(path).second) return; + logging::log(logging::Level::warning, logging::Channel::plugins, "A plugin's picture {} is not drawn: {}.", path, why); +} + +void decode_loop() { + auto& c = cache(); + for (;;) { + std::string path; + { + std::unique_lock lock(c.mutex); + c.wake.wait(lock, [&] { return !c.queue.empty(); }); + path = std::move(c.queue.front()); + c.queue.pop_front(); + } + CardPixels pixels; + const char* why = nullptr; + try { + const std::filesystem::path file(std::u8string(path.begin(), path.end())); + std::error_code code; + const auto size = std::filesystem::file_size(file, code); + if (code) why = "the file cannot be read"; + else if (size > most_file_bytes) why = "it is larger than 2 MB"; + else { + std::ifstream in(file, std::ios::binary); + std::string bytes(static_cast(size), '\0'); + if (!in.read(bytes.data(), static_cast(bytes.size()))) why = "the file cannot be read"; + else if (!decode_picture(bytes, pixels, 0)) why = "it is not a PNG or JPG of at most 512 x 512 pixels"; + } + } catch (...) { + why = "the file cannot be read"; + } + std::lock_guard lock(c.mutex); + const auto found = c.entries.find(path); + if (found == c.entries.end()) continue; + if (why) { + found->second.stage = Entry::Stage::failed; + warn_once(c, path, why); + } else { + found->second.pixels = std::move(pixels); + found->second.stage = Entry::Stage::decoded; + } + } +} +} + +ImTextureID plugin_image(const std::string& path, ImVec2& size) { + auto& c = cache(); + std::lock_guard lock(c.mutex); + const double now = seconds_now(); + auto found = c.entries.find(path); + if (found == c.entries.end()) { + if (c.entries.size() >= most_pictures) { + // Make room: a picture that failed, or the one drawn longest ago and idle. + auto oldest = c.entries.end(); + for (auto it = c.entries.begin(); it != c.entries.end(); ++it) { + if (it->second.stage == Entry::Stage::failed) { + oldest = it; + break; + } + if (now - it->second.drawn >= idle_seconds && it->second.slot < 0 && + (oldest == c.entries.end() || it->second.drawn < oldest->second.drawn)) + oldest = it; + } + if (oldest == c.entries.end()) { + warn_once(c, path, "plugins draw more than 32 pictures at once"); + return {}; + } + c.entries.erase(oldest); + } + found = c.entries.emplace(path, Entry{}).first; + c.queue.push_back(path); + if (!std::exchange(c.worker, true)) std::thread(decode_loop).detach(); + c.wake.notify_one(); + } + auto& entry = found->second; + entry.drawn = now; + if (entry.stage != Entry::Stage::uploaded) return {}; + size = ImVec2(static_cast(entry.pixels.width), static_cast(entry.pixels.height)); + return entry.id; +} + +std::optional take_plugin_image() { + auto& c = cache(); + std::lock_guard lock(c.mutex); + const auto waiting = std::find_if(c.entries.begin(), c.entries.end(), [](const auto& e) { return e.second.stage == Entry::Stage::decoded; }); + if (waiting == c.entries.end()) return std::nullopt; + std::array used{}; + for (const auto& [path, entry] : c.entries) + if (entry.slot >= 0) used[static_cast(entry.slot)] = true; + int slot = -1; + for (int i = 0; i < static_cast(plugin_image_slots); ++i) + if (!used[static_cast(i)]) { + slot = i; + break; + } + if (slot < 0) { + // Every slot is in use: the one drawn longest ago gives it up, if it is idle. + const double now = seconds_now(); + auto oldest = c.entries.end(); + for (auto it = c.entries.begin(); it != c.entries.end(); ++it) + if (it->second.slot >= 0 && now - it->second.drawn >= idle_seconds && + (oldest == c.entries.end() || it->second.drawn < oldest->second.drawn)) + oldest = it; + if (oldest == c.entries.end()) return std::nullopt; + slot = oldest->second.slot; + c.entries.erase(oldest); + } + waiting->second.slot = slot; + waiting->second.stage = Entry::Stage::waiting; // until plugin_image_uploaded + return PluginImageUpload{waiting->first, slot, waiting->second.pixels}; +} + +void plugin_image_uploaded(const std::string& path, int slot, ImTextureID id, bool later) { + auto& c = cache(); + std::lock_guard lock(c.mutex); + const auto found = c.entries.find(path); + if (found == c.entries.end() || found->second.slot != slot) return; + auto& entry = found->second; + if (later) { + entry.stage = Entry::Stage::decoded; + entry.slot = -1; + return; + } + if (!id) { + entry.stage = Entry::Stage::failed; + entry.slot = -1; + warn_once(c, path, "the GPU could not take it"); + return; + } + entry.stage = Entry::Stage::uploaded; + entry.id = id; + entry.pixels.rgba.clear(); // the size stays, for drawing + entry.pixels.rgba.shrink_to_fit(); +} + +void plugin_images_lost() { + auto& c = cache(); + std::lock_guard lock(c.mutex); + c.entries.clear(); // drawn again, they decode again +} +} diff --git a/Extension/UI/Overlay/plugin_overlay.cpp b/Extension/UI/Overlay/plugin_overlay.cpp index 7f353417..f7327c30 100644 --- a/Extension/UI/Overlay/plugin_overlay.cpp +++ b/Extension/UI/Overlay/plugin_overlay.cpp @@ -101,6 +101,9 @@ void draw_plugin_screen() { break; case Kind::label: ImGui::TextWrapped("%s", w.text.c_str()); break; case Kind::separator: ImGui::Separator(); break; + case Kind::bar: + ImGui::ProgressBar((w.value - w.minimum) / (w.maximum - w.minimum), ImVec2(480 * k, 0), w.text.c_str()); + break; case Kind::button: acted = ImGui::Button(w.text.c_str(), ImVec2(320 * k, 0)); break; case Kind::toggle: { bool on = w.on; @@ -190,6 +193,25 @@ void draw_plugins() { if (const auto on = camera ? camera->project(p[0]) : std::nullopt) circle(draw, ImVec2((*on)[0], (*on)[1]), std::min(shape.radius * camera->focal / (*on)[2], display.y), style, k); break; + case Kind::image: { + ImVec2 size; + if (const auto id = detail::plugin_image(shape.text, size)) + draw->AddImageRounded(id, at(p[0]), at(p[1]), ImVec2(style.uv[0], style.uv[1]), ImVec2(style.uv[2], style.uv[3]), style.colour, + style.rounding * k); + break; + } + case Kind::world_image: { + ImVec2 size; + const auto id = detail::plugin_image(shape.text, size); + const auto on = camera ? camera->project(p[0]) : std::nullopt; + if (!id || !on || size.y <= 0) break; + const float span_x = std::abs(style.uv[2] - style.uv[0]), span_y = std::abs(style.uv[3] - style.uv[1]); + const float aspect = span_y > 0 ? size.x * span_x / (size.y * span_y) : 1.0f; + const float height = std::min(shape.radius * camera->focal / (*on)[2], display.y), width = height * aspect; + draw->AddImage(id, ImVec2((*on)[0] - width * .5f, (*on)[1] - height * .5f), ImVec2((*on)[0] + width * .5f, (*on)[1] + height * .5f), + ImVec2(style.uv[0], style.uv[1]), ImVec2(style.uv[2], style.uv[3]), style.colour); + break; + } } } } diff --git a/Extension/UI/Overlay/plugins_page.cpp b/Extension/UI/Overlay/plugins_page.cpp index 11d8605e..559157f1 100644 --- a/Extension/UI/Overlay/plugins_page.cpp +++ b/Extension/UI/Overlay/plugins_page.cpp @@ -94,6 +94,9 @@ void plugins_addon(SkateMenu& menu) { } break; } + case Kind::bar: + ImGui::ProgressBar((w.value - w.minimum) / (w.maximum - w.minimum), ImVec2(-FLT_MIN, px(22)), w.text.c_str()); + break; } ImGui::PopID(); } diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 89abbea0..c7732423 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -1,6 +1,5 @@ #include "Engine/Core/Platform/launcher_support.h" #include "skate_menu_internal.h" -#include "Extension/Skater/drone_flight.h" #include "skate_menu.h" #include "modding_menu.h" #include "skate_style.h" @@ -817,10 +816,9 @@ constexpr std::array page_subtitles{ "Everything, a button away.", "Pick your spot. Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it.", "What ReSkate adds to skate.", "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Under the hood."}; -constexpr std::array addon_names{"ADDONS", "DRONE", "BOARD BREAK", "ROAD RASH", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"}; +constexpr std::array addon_names{"ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"}; constexpr std::array addon_subtitles{ - "What ReSkate adds to skate.", "Take it to the sky.", "Slams snap your board.", "Bails leave their mark.", "Every bone counts.", "Pick the side you pop out.", - "Your sound, in the game.", "What your plugins add."}; + "What ReSkate adds to skate.", "Every bone counts.", "Pick the side you pop out.", "Your sound, in the game.", "What your plugins add."}; // The side rail's groups: the heading above the page that starts each. const char* group_of(int page) { switch (page) { @@ -1056,7 +1054,7 @@ void draw_windowed(SkateMenu& menu, const Model& model, const CallbacksV3& callb const auto origin = ImGui::GetWindowPos(); auto* draw = ImGui::GetWindowDrawList(); const float sidebar = px(190.0f); - if (menu.page == page_addons && menu.addon == addon_all) menu.addon = addon_drone; + if (menu.page == page_addons && menu.addon == addon_all) menu.addon = addon_first; // Side rail: brushed wordmark, then the pages in their groups like the HUB's tab icons. draw->AddRectFilled(origin, ImVec2(origin.x + sidebar, origin.y + size.y), IM_COL32(10, 10, 11, 250)); @@ -1074,7 +1072,7 @@ void draw_windowed(SkateMenu& menu, const Model& model, const CallbacksV3& callb if (page_hidden(menu, model, i)) continue; if (i != page_addons) ++rows; else - for (int a = addon_drone; a < addon_count; ++a) rows += !addon_hidden(model, a); + rows += addon_count - addon_first; if (group_of(i)) ++groups; } const float spacing = ImGui::GetStyle().ItemSpacing.y; @@ -1110,8 +1108,7 @@ void draw_windowed(SkateMenu& menu, const Model& model, const CallbacksV3& callb } ImGui::PushID(i); if (i == page_addons) { - for (int a = addon_drone; a < addon_count; ++a) { - if (addon_hidden(model, a)) continue; + for (int a = addon_first; a < addon_count; ++a) { ImGui::PushID(a); if (row("##addon", addon_names[static_cast(a)], menu.page == page_addons && menu.addon == a, row_height, 0, menu.bold, px(16))) @@ -1366,7 +1363,6 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal io.FontGlobalScale = large ? ui_scale / large_font_ratio : ui_scale; menu.rail_indent = 0; if (trainer_page_wanted()) open_page(menu, page_sandbox); - if (dingosdk::drone::menu_flag().exchange(false)) open_page(menu, page_addons, addon_drone); // `dronemenu` menu.page = std::clamp(menu.page, 0, static_cast(page_count) - 1); if (page_hidden(menu, model, menu.page)) menu.page = full ? page_home : page_map; menu_page_owns_pad().store(full && (menu.page == page_online || menu.recording_bind)); diff --git a/Extension/UI/Overlay/skate_menu.h b/Extension/UI/Overlay/skate_menu.h index 787b52c2..67c38c68 100644 --- a/Extension/UI/Overlay/skate_menu.h +++ b/Extension/UI/Overlay/skate_menu.h @@ -88,7 +88,6 @@ struct SkateMenu { int world_layer_category = 0; std::array world_layer_search{}; int progression_tab = 0; - int drone_tab = 0; // the DRONE page (drone_page.cpp) int bus_stop_map = 0; int challenge_type = 0; std::array challenge_search{}; diff --git a/Extension/UI/Overlay/skate_menu_addons.cpp b/Extension/UI/Overlay/skate_menu_addons.cpp index 9477f886..96caa1f3 100644 --- a/Extension/UI/Overlay/skate_menu_addons.cpp +++ b/Extension/UI/Overlay/skate_menu_addons.cpp @@ -2,15 +2,14 @@ #include "skate_style.h" #include "Extension/UI/skate_theme.h" #include "Extension/UI/NativeMenu/native_tools_view.h" -#include "Extension/Skater/drone_flight.h" #include #include #include #include -// The ADDONS page (what ReSkate adds to skate.: the drone, Board Break, Road Rash and Hall of Meat, -// music) and the full screen menu's HOME page. +// The ADDONS page (what ReSkate adds to skate.: Hall of Meat, grind exits, music, the plugins' panels) +// and the full screen menu's HOME page. namespace dingosdk::overlay::menu { using namespace theme; namespace { @@ -56,27 +55,12 @@ void addon_tile(SkateMenu& menu, const char* id, const char* title, const char* void all_addons(SkateMenu& menu, const Model& model) { const auto on_off = [](bool available, bool on) -> const char* { return !available ? "NOT AVAILABLE" : on ? "ON" : "OFF"; }; const auto colour = [](bool available, bool on) { return !available ? skate_theme::tile_light : on ? skate_theme::good : skate_theme::tile_light; }; - addon_tile(menu, "addon-drone", "DRONE", "Fly an FPV drone from the free camera, or let it follow you and film your line.", nullptr, 0, - addon_drone); - // Two a row full screen; the hidden ones (addon_hidden) leave no gap. + // Two a row full screen. int placed = 1; const auto next = [&] { if (console_style() && placed++ % 2) ImGui::SameLine(0, px(12)); }; - const auto& bb = model.board_break; - if (!addon_hidden(model, addon_board_break)) { - next(); - addon_tile(menu, "addon-board-break", "BOARD BREAK", "Slams snap your board, boards wear out, and every crack has its sound.", - on_off(bb.available, bb.enabled), colour(bb.available, bb.enabled), addon_board_break); - } - const auto& rash = model.road_rash; - if (!addon_hidden(model, addon_road_rash)) { - next(); - addon_tile(menu, "addon-road-rash", "ROAD RASH", "Bails leave marks that build up: dust, scrapes, bruises, wounds.", - on_off(rash.available, rash.enabled), colour(rash.available, rash.enabled), addon_road_rash); - } const auto& meat = model.hall_of_meat; - next(); addon_tile(menu, "addon-hall-of-meat", "HALL OF MEAT", "Your bails show the bones they hurt and score a Meat card.", on_off(meat.available, meat.enabled), colour(meat.available, meat.enabled), addon_hall_of_meat); const auto& grind = model.grind_exit; @@ -92,35 +76,23 @@ void all_addons(SkateMenu& menu, const Model& model) { } } -bool addon_hidden(const Model& model, int addon) { - return (addon == addon_board_break && model.board_break.off) || (addon == addon_road_rash && model.road_rash.off); -} - void addons_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { menu.addon = std::clamp(menu.addon, 0, static_cast(addon_count) - 1); - if (addon_hidden(model, menu.addon)) menu.addon = menu.fullscreen_drawn ? addon_all : addon_drone; // (a window has no overview) + if (menu.addon == addon_all && !menu.fullscreen_drawn) menu.addon = addon_first; // (a window has no overview) // A window lists the addons in its side rail; full screen has no rail, so they are tabs here - // (LT / RT on a controller), without the hidden ones. + // (LT / RT on a controller). if (menu.fullscreen_drawn) { - static constexpr std::array names{"ALL ADDONS", "DRONE", "BOARD BREAK", "ROAD RASH", "HALL OF MEAT", - "GRIND EXITS", "MUSIC", "PLUGINS"}; - std::vector labels; - std::vector shown; - for (int a = 0; a < addon_count; ++a) - if (!addon_hidden(model, a)) labels.push_back(names[static_cast(a)]), shown.push_back(a); - int tab = static_cast(std::find(shown.begin(), shown.end(), menu.addon) - shown.begin()); - category_tabs(menu, tab, labels, "addon-tabs"); - menu.addon = shown[static_cast(std::clamp(tab, 0, static_cast(shown.size()) - 1))]; + static constexpr std::array names{"ALL ADDONS", "HALL OF MEAT", "GRIND EXITS", "MUSIC", "PLUGINS"}; + std::vector labels(names.begin(), names.end()); + category_tabs(menu, menu.addon, labels, "addon-tabs"); + menu.addon = std::clamp(menu.addon, 0, static_cast(addon_count) - 1); } ImGui::PushID(menu.addon); switch (menu.addon) { - case addon_drone: drone_page(menu, model, callbacks); break; case addon_music: music_page(menu, model, callbacks); break; default: ImGui::BeginChild("addon", ImVec2(0, page_body_height(menu)), ImGuiChildFlags_NavFlattened); - if (menu.addon == addon_board_break) board_break_addon(menu, model, callbacks); - else if (menu.addon == addon_road_rash) road_rash_addon(menu, model, callbacks); - else if (menu.addon == addon_hall_of_meat) hall_of_meat_addon(menu, model, callbacks); + if (menu.addon == addon_hall_of_meat) hall_of_meat_addon(menu, model, callbacks); else if (menu.addon == addon_grind_exits) grind_exits_addon(menu, model, callbacks); else if (menu.addon == addon_plugins) plugins_addon(menu); else all_addons(menu, model); @@ -252,29 +224,6 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks debug.free_camera, false, "Leave your skater and fly the camera.") == 1) request(DebugAction::set_free_camera, !debug.free_camera); place(width, 1); - const bool flying = drone::is_enabled() && debug.free_camera; - if (home_tile(menu, "##drone", "DRONE", flying ? "FLYING" : "FLY IT", size, debug.camera_available && callbacks.queue_console_command, flying, - false, "Take off with the FPV drone, or land it. Its settings are in Addons > Drone.") == 1) { - if (flying) quietly(callbacks, "dronemode 0"), quietly(callbacks, "freecam 0"); - else quietly(callbacks, "freecam 1"), quietly(callbacks, "dronemode 1"); - } - // (The addons that are hidden, addon_hidden, leave no gap.) - int column = 2; - const auto& bb = model.board_break; - if (!addon_hidden(model, addon_board_break)) { - place(width, column++); - if (home_tile(menu, "##board-break", "BOARD BREAK", on_off(bb.enabled), size, bb.available && callbacks.queue_console_command, bb.enabled, - false, "Slams snap your board. Its deck and settings are in Addons > Board Break.") == 1) - quietly(callbacks, bb.enabled ? "boardbreak 0" : "boardbreak 1"); - } - const auto& rash = model.road_rash; - if (!addon_hidden(model, addon_road_rash)) { - place(width, column++); - if (home_tile(menu, "##road-rash", "ROAD RASH", on_off(rash.enabled), size, rash.available && callbacks.queue_console_command, rash.enabled, - false, "Bails leave marks that build up: dust, scrapes, bruises, wounds.") == 1) - quietly(callbacks, rash.enabled ? "roadrash 0" : "roadrash 1"); - } - place(width, column); const auto& meat = model.hall_of_meat; if (home_tile(menu, "##hall-of-meat", "HALL OF MEAT", on_off(meat.enabled), size, meat.available && callbacks.queue_console_command, meat.enabled, false, "Your bails show the bones they hurt and score a Meat card.") == 1) @@ -300,7 +249,7 @@ void home_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks if (home_tile(menu, "##go-map", "MAP", "Travel, parks, time and weather", size, true, false, false, "Pick a map and load it.", false) == 1) open_page(menu, page_map); place(width, 3); - if (home_tile(menu, "##go-addons", "ADDONS", "Drone, Board Break, grind exits, music", size, true, false, false, + if (home_tile(menu, "##go-addons", "ADDONS", "Hall of Meat, grind exits, music, plugins", size, true, false, false, "What ReSkate adds to skate.", false) == 1) open_page(menu, page_addons, addon_all); place(width, 4); diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index d603f824..f3075c13 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -92,9 +92,9 @@ bool primary_button(SkateMenu& menu, const char* label, bool enabled = true); float page_body_height(const SkateMenu& menu); // Runs a console command and shows its reply as feedback. void send_console(SkateMenu& menu, const CallbacksV3& callbacks, const std::string& command); -// Board Break's decks, a button for each (modding_menu.cpp): on the Mods page and under the -// feature's switch on the Skater page. Draws nothing while no deck is installed. -void board_break_deck_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks); +// The breakable decks, a button for each (modding_menu.cpp), on the Mods page. Draws nothing while +// no deck is installed. +void breakable_deck_card(SkateMenu& menu, const CallbacksV3& callbacks); void debug_request(SkateMenu& menu, const CallbacksV3& callbacks, DebugRequest request); // HOST shows the host form until a session exists, then that session (SESSION). inline constexpr int multiplayer_session_tab = 1, multiplayer_voice_tab = 2, multiplayer_bans_tab = 3, multiplayer_chat_tab = 4; @@ -114,16 +114,12 @@ void graphics_page(SkateMenu&, const Model&, const CallbacksV3&); void developer_page(SkateMenu&, const Model&, const CallbacksV3&); // Extension/Music/music_page.cpp void music_page(SkateMenu&, const Model&, const CallbacksV3&); -// The FPV drone (drone_page.cpp). -void drone_page(SkateMenu&, const Model&, const CallbacksV3&); // Extension/Trainer/trainer_page.cpp: the SANDBOX page (once the Trainer). void trainer_page(SkateMenu&, const Model&, const CallbacksV3&); bool trainer_page_wanted(); // Skater > CHEATS: noclip, No Bail and the boosts (skate_menu_skater.cpp). void cheats_tab(SkateMenu&, const Model&, const CallbacksV3&); // The addons' own pages (skate_menu_skater.cpp), and the ADDONS page itself (skate_menu_addons.cpp). -void board_break_addon(SkateMenu&, const Model&, const CallbacksV3&); -void road_rash_addon(SkateMenu&, const Model&, const CallbacksV3&); void hall_of_meat_addon(SkateMenu&, const Model&, const CallbacksV3&); void grind_exits_addon(SkateMenu&, const Model&, const CallbacksV3&); void addons_page(SkateMenu&, const Model&, const CallbacksV3&); @@ -147,9 +143,6 @@ enum Page { page_home, page_map, page_build, page_skater, page_sandbox, page_addons, page_online, page_progress, page_mods, page_settings, page_developer, page_count }; -enum Addon { addon_all, addon_drone, addon_board_break, addon_road_rash, addon_hall_of_meat, addon_grind_exits, addon_music, addon_plugins, addon_count }; +enum Addon { addon_all, addon_hall_of_meat, addon_grind_exits, addon_music, addon_plugins, addon_count, addon_first = addon_hall_of_meat }; void open_page(SkateMenu&, int page, int addon = -1); -// An addon made from an official mod whose feature did not start (the mod is turned off in the -// launcher, or this game build cannot run it) is listed nowhere in the menu (skate_menu_addons.cpp). -bool addon_hidden(const Model&, int addon); } diff --git a/Extension/UI/Overlay/skate_menu_settings.cpp b/Extension/UI/Overlay/skate_menu_settings.cpp index b24491e0..7a10fec1 100644 --- a/Extension/UI/Overlay/skate_menu_settings.cpp +++ b/Extension/UI/Overlay/skate_menu_settings.cpp @@ -5,6 +5,7 @@ #include "Engine/Core/Profiling/profiler.h" #include "Extension/Music/local_music_playback.h" #include "Extension/Profile/local_profile_runtime.h" +#include "Extension/Plugins/plugin_ui.h" #include #include @@ -136,7 +137,18 @@ std::uint32_t bind_combo(const ControllerBindingsModel& b, int action) { void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const bool available = model.bindings.available && callbacks.queue_console_command; + // Plugins' binds (reskate.input.bind) are rows from plugin_bind_row up, in the order the + // plugins' list gives them; the player's combo replaces the plugin's own. + constexpr int plugin_bind_row = 1000; + const auto plugin_binds = plugin_ui::latest_binds(); const auto save = [&](int action, std::uint32_t combo) { + if (action >= plugin_bind_row) { + const auto index = static_cast(action - plugin_bind_row); + if (index < plugin_binds->size()) + plugin_ui::rebind({(*plugin_binds)[index].plugin, (*plugin_binds)[index].action, combo}); + feedback(menu, combo ? "Saved." : "Cleared. The plugin's action has no bind now."); + return; + } std::array result{}; const auto command = std::string("bind ") + (action >= 10 ? std::string(action_binds[static_cast(action - 10)].name) + " " : action == 1 ? "freecamcontroller " : action == 2 ? "freecam " : action == 3 ? "noclip " : action == 4 ? "forwardvelocity " : action == 5 ? "upvelocity " : action == 6 ? "tptofreecam " : action == 8 ? "voteyes " : action == 9 ? "voteno " : "offboardupvelocity ") + std::to_string(combo); @@ -168,15 +180,37 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback console ? "Hold up to five buttons together, then let go. Nothing for 10 seconds cancels." : "Hold up to five keys together, then release them. A-Z, 0-9, Space, F1-F12, Ctrl, Shift and Alt work; controller combos work too."); } + struct Row { + int action; + std::string name; + std::uint32_t combo; + }; + struct Card { + const char* title; + std::vector rows; + }; + std::vector cards; for (const auto& group : bind_groups) { if (std::string_view(group.title) == "ONLINE" && model.steam_offline) continue; + auto& card = cards.emplace_back(Card{group.title, {}}); + for (const int action : group.actions) card.rows.push_back({action, bind_name(action), bind_combo(model.bindings, action)}); + } + if (!plugin_binds->empty()) { + auto& card = cards.emplace_back(Card{"PLUGINS", {}}); + for (std::size_t i = 0; i < plugin_binds->size(); ++i) { + const auto& bind = (*plugin_binds)[i]; + card.rows.push_back({plugin_bind_row + static_cast(i), bind.plugin + ": " + bind.action, bind.combo}); + } + } + for (const auto& group : cards) { begin_card(menu, group.title, group.title); ImGui::PushID(group.title); if (console) { // Full screen: a row a bind, its combo at the right end. A records, Y clears. auto* draw = ImGui::GetWindowDrawList(); - for (const int action : group.actions) { - const std::uint32_t combo = bind_combo(model.bindings, action); + for (const auto& row : group.rows) { + const int action = row.action; + const std::uint32_t combo = row.combo; const bool recording = menu.recording_bind == action; ImGui::PushID(action); const auto at = ImGui::GetCursorScreenPos(); @@ -195,8 +229,8 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback skate_theme::rough_rect(draw, at, end, recording ? paper : lit ? blue : IM_COL32(26, 26, 26, 235), static_cast(ImGui::GetItemID()) & 0xfff, px(1)); const ImU32 text = recording ? skate_theme::black : available ? paper : muted; - const auto name = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, bind_name(action)); - draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - name.y) * .5f), text, bind_name(action)); + const auto name = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, row.name.c_str()); + draw->AddText(menu.bold, px(17), ImVec2(at.x + px(18), at.y + (height - name.y) * .5f), text, row.name.c_str()); const auto label = recording ? std::string(menu.bind_capture.ready ? "Press the buttons..." : "Let go first...") : controller_combo_label(combo, controller.style); const auto size = menu.bold->CalcTextSizeA(px(17), FLT_MAX, 0, label.c_str()); @@ -210,11 +244,12 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, px(150)); ImGui::TableSetupColumn("Controller combo / key", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##controls", ImGuiTableColumnFlags_WidthFixed, px(170)); - for (const int action : group.actions) { - const std::uint32_t combo = bind_combo(model.bindings, action); + for (const auto& row : group.rows) { + const int action = row.action; + const std::uint32_t combo = row.combo; ImGui::PushID(action); ImGui::TableNextRow(); ImGui::TableNextColumn(); - ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(bind_name(action)); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(row.name.c_str()); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); const auto label = menu.recording_bind == action ? "Recording..." : controller_combo_label(combo, controller.style); ImGui::TextWrapped("%s", label.c_str()); @@ -235,6 +270,15 @@ void binds_page(SkateMenu& menu, const Model& model, const CallbacksV3& callback } ImGui::EndDisabled(); } + if (std::string_view(group.title) == "PLUGINS") { + const bool changed = std::any_of(plugin_binds->begin(), plugin_binds->end(), [](const auto& b) { return b.combo != b.own; }); + ImGui::BeginDisabled(!changed || menu.recording_bind != 0); + if (ImGui::Button("Plugins' own binds", ImVec2(console ? -FLT_MIN : px(200), 0))) { + for (const auto& bind : *plugin_binds) plugin_ui::rebind({bind.plugin, bind.action, std::nullopt}); + feedback(menu, "Every plugin's action is on the plugin's own bind again."); + } + ImGui::EndDisabled(); + } ImGui::PopID(); end_card(); } diff --git a/Extension/UI/Overlay/skate_menu_skater.cpp b/Extension/UI/Overlay/skate_menu_skater.cpp index d9d09cf7..eb772840 100644 --- a/Extension/UI/Overlay/skate_menu_skater.cpp +++ b/Extension/UI/Overlay/skate_menu_skater.cpp @@ -166,61 +166,6 @@ void first_person_controls(SkateMenu& menu, const Model& model, const CallbacksV end_card(); } -// Board Break's settings (Extension/BoardBreak/board_break_feel.h): how a board breaks, wears and -// sounds. Each is a console verb, kept with the profile. -void board_break_settings_card(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - const auto& bb = model.board_break; - if (!bb.available) return; - const bool console = bb.enabled && callbacks.queue_console_command; - std::string subtitle = !bb.has_deck ? "No Board Break deck is in the game" - : "Your board: " + std::to_string(static_cast(bb.worn * 100.0f + 0.5f)) + "% worn (" + bb.condition + ")"; - begin_card(menu, "board-break-settings", "BOARD BREAK SETTINGS", subtitle.c_str()); - if (!bb.enabled) note("Switch Board Break on to change these."); - const auto flag = [&](const char* label, const char* hint, bool on, const char* verb) { - bool value = on; - if (toggle_row(menu, label, hint, value, console)) send_console(menu, callbacks, std::string("boardbreak ") + verb + (value ? " 1" : " 0")); - }; - flag("Always break", "Every slam breaks the board, and every slide that comes down onto a rail: for replay clips.", bb.always, "always"); - flag("Rail snaps", "A boardslide or darkslide that comes down too hard onto a rail or ledge can snap the board, and you go down with it.", - bb.rails, "rails"); - flag("Wear and tear", "Every hit wears your board. A worn board turns lazier, wobbles and breaks more easily; worn out, it can give way. " - "Turn on Board wear (Mod Options) to see the scratches.", bb.wear, "wear"); - flag("Break sounds", "The crack of a break, and metal when it breaks on metal.", bb.crack_sounds, "cracks"); - flag("Body hit sounds", "Each hit of your body in a bail, as hard as it hits.", bb.body_sounds, "bodysounds"); - // Sent once a slider is let go; the dragged value shows until the setting comes back. - const auto percent = [&](const char* id, const char* label, const char* hint, int value, int low, int high, int step, const char* verb) { - static std::array edited{-1, -1, -1}; - static std::array edited_at{}; - const auto slot = std::string_view(id) == "bb-sensitivity" ? 0u : std::string_view(id) == "bb-joined" ? 1u : 2u; - int shown = edited[slot] >= 0 ? edited[slot] : value; - field(menu, label, hint); - ImGui::BeginDisabled(!console); - if (::dingosdk::overlay::menu::slider_int((std::string("##") + id).c_str(), &shown, low, high, "%d%%", ImGuiSliderFlags_AlwaysClamp)) { - edited[slot] = std::clamp((shown + step / 2) / step * step, low, high); - edited_at[slot] = ImGui::GetTime(); - } - if (ImGui::IsItemDeactivatedAfterEdit() && edited[slot] >= 0) { - send_console(menu, callbacks, std::string("boardbreak ") + verb + " " + std::to_string(edited[slot])); - edited_at[slot] = ImGui::GetTime(); - } - if (edited[slot] >= 0 && !ImGui::IsItemActive() && (value == edited[slot] || ImGui::GetTime() - edited_at[slot] > 2.0)) edited[slot] = -1; - ImGui::EndDisabled(); - }; - percent("bb-sensitivity", "Break sensitivity", "How easily a board breaks. 200% takes half the force, 50% twice as much.", bb.sensitivity, 10, 400, 10, - "sensitivity"); - percent("bb-joined", "Grip tape holds", "How many breaks the grip tape holds together instead of a piece coming off.", bb.joined, 0, 100, 5, "joined"); - percent("bb-wear-rate", "Wear speed", "How fast boards wear. 0% keeps a board as it is.", bb.wear_rate, 0, 500, 10, "wearrate"); - if (console_style()) { - if (button_row(menu, "bb-new", {{"NEW BOARD", console && bb.has_deck, false, "Your board as good as new: no wear."}}) == 0) - send_console(menu, callbacks, "boardbreak worn 0"); - } else { - ImGui::BeginDisabled(!console || !bb.has_deck); - if (ImGui::Button("New board", ImVec2(-FLT_MIN, 0))) send_console(menu, callbacks, "boardbreak worn 0"); - ImGui::EndDisabled(); - } - end_card(); -} - // Skater > CHEATS: flying through things, never bailing, and the boosts. void cheats_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& debug = model.debug; @@ -261,21 +206,6 @@ void cheats_tab(SkateMenu& menu, const Model& model, const CallbacksV3& callback end_card(); } -// Addons > ROAD RASH: bails leave marks. -void road_rash_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "road-rash", "ROAD RASH"); - bool road_rash = model.road_rash.enabled; - const bool rash_console = model.road_rash.available && callbacks.queue_console_command; - if (toggle_row(menu, "Road Rash", "Bails leave marks that build up: dust, scrapes, bruises, wounds. Needs the Road Rash item pack.", - road_rash, rash_console)) - send_console(menu, callbacks, road_rash ? "roadrash 1" : "roadrash 0"); - bool rash_blood = model.road_rash.blood; - if (toggle_row(menu, "Road Rash blood", "Fresh blood on the worst wounds. Off, they are dry and scabbed.", rash_blood, - rash_console && model.road_rash.enabled)) - send_console(menu, callbacks, rash_blood ? "roadrashblood 1" : "roadrashblood 0"); - end_card(); -} - // Addons > HALL OF MEAT: bails show the bones they hurt and score a Meat card. void hall_of_meat_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& debug = model.debug; @@ -293,20 +223,6 @@ void hall_of_meat_addon(SkateMenu& menu, const Model& model, const CallbacksV3& end_card(); } -// Addons > BOARD BREAK: the switch, the deck in the game, and how boards break, wear and sound. -void board_break_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { - begin_card(menu, "board-break", "BOARD BREAK"); - bool board_break = model.board_break.enabled; - if (toggle_row(menu, "Board Break", model.board_break.has_deck - ? "A hard slam can snap your board: at the nose, in the middle or at the tail. Whole again once you pick it up." - : "A hard slam can snap your board. Needs one of the Board Break deck packs.", - board_break, model.board_break.available && callbacks.queue_console_command)) - send_console(menu, callbacks, board_break ? "boardbreak 1" : "boardbreak 0"); - end_card(); - board_break_deck_card(menu, model, callbacks); - board_break_settings_card(menu, model, callbacks); -} - // Addons > GRIND EXITS: grind exit control (Extension/Skater/grind_exit_control.h). void grind_exits_addon(SkateMenu& menu, const Model& model, const CallbacksV3& callbacks) { const auto& grind = model.grind_exit; diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 9cc43909..a334d441 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -17,17 +17,13 @@ #include "Engine/Game/World/client_state.h" #include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/Plugins/plugin_runtime.h" -#include "Extension/RoadRash/road_rash.h" -#include "Extension/BoardBreak/board_break.h" -#include "Extension/BoardBreak/board_break_audio.h" +#include "Extension/Plugins/plugin_game_runtime.h" #include "Extension/ReplayKeyframes/replay_keyframes.h" #include "Extension/Skater/grind_exit_control.h" #include "Extension/Skater/camera_observer.h" #include "Extension/UI/NativeMenu/native_menu.h" #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Multiplayer/Session/session.h" -#include "Extension/Multiplayer/Remote/native_prop.h" -#include "Extension/Multiplayer/Remote/drone_sound.h" #include "Extension/Boot/discord_presence.h" #include "Engine/Game/World/world_names.h" #include "Extension/Multiplayer/Hud/custom_nametags.h" @@ -48,8 +44,7 @@ #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/Skater/breakable_deck.h" #include "Extension/Throwdowns/native_throwdowns.h" #include "Extension/Trainer/trainer.h" #include "Extension/World/level_loading.h" @@ -722,7 +717,8 @@ 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 || dingosdk::drone::controller_capture())); + DingoSDKOverlaySetFreecamInputCapture((dingosdk::client_free_camera_active() && freecam_controller) || + dingosdk::plugins::plugin_input_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 || @@ -808,7 +804,8 @@ 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 || dingosdk::drone::controller_capture())); + DingoSDKOverlaySetFreecamInputCapture((dingosdk::client_free_camera_active() && freecam_controller) || + dingosdk::plugins::plugin_input_capture()); record("{\"event\":\"controller_binding_triggered\",\"action\":\"freecam_controller\"}"); } @@ -888,7 +885,7 @@ void update_model(std::uintptr_t client, TickState& frame) { record("{\"event\":\"controller_binding_triggered\",\"action\":\"offboard_up_velocity\"}"); } } - if (debug_request || r.debug_model.free_camera || r.debug_model.first_person || r.debug_model.drone_follow || r.debug_model.noclip || + if (debug_request || r.debug_model.free_camera || r.debug_model.first_person || r.debug_model.noclip || now >= r.next_debug) { ScheduledWorkScope work{r, debug_request.has_value()}; r.next_debug = now + 100; @@ -896,12 +893,15 @@ void update_model(std::uintptr_t client, TickState& frame) { dingosdk::overlay::FlightInput flight_input; DingoSDKOverlayReadFlightInput(&flight_input, (r.debug_model.free_camera || r.debug_model.noclip) && debug_ready, r.debug_model.noclip || (r.debug_model.free_camera && freecam_controller)); + // A plugin holding the skater still (reskate.skater.freeze): noclip with no input hovers. + if (r.debug_model.noclip && dingosdk::plugins::plugin_freeze_active()) flight_input = {true}; const auto phase_issue = camera_phase_issue(); auto debug = dingosdk::on_client_debug_tick(r.base, client, debug_ready, 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 || dingosdk::drone::controller_capture())); + DingoSDKOverlaySetFreecamInputCapture((dingosdk::client_free_camera_active() && freecam_controller) || + dingosdk::plugins::plugin_input_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 || @@ -1093,9 +1093,7 @@ void update_model(std::uintptr_t client, TickState& frame) { dingosdk::update_local_item_browser(); } 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::breakable_deck::tick(); dingosdk::grind_exit_control::on_client_tick(client); { DINGO_PROFILE_ZONE("tick/plugins"); @@ -1240,26 +1238,6 @@ void update_model(std::uintptr_t client, TickState& frame) { model.progression = dingosdk::local_profile_progression(); model.player_card = dingosdk::local_profile_player_card(); model.hall_of_meat = {dingosdk::hall_of_meat::available(), dingosdk::hall_of_meat::enabled()}; - model.road_rash = {dingosdk::road_rash::available(), dingosdk::road_rash::enabled(), dingosdk::road_rash::blood(), - dingosdk::road_rash::turned_off()}; - const auto breakable_deck = dingosdk::board_break::deck(); - model.board_break = {dingosdk::board_break::available(), dingosdk::board_break::enabled(), !breakable_deck.empty(), - dingosdk::board_break::deck_label(breakable_deck)}; - { - const auto feel = dingosdk::board_break::settings(); - auto& bb = model.board_break; - bb.off = dingosdk::board_break::turned_off(); - bb.always = feel.always; - bb.rails = feel.rails; - bb.wear = feel.wear; - bb.crack_sounds = feel.crack_sounds; - bb.body_sounds = feel.body_sounds; - bb.sensitivity = feel.sensitivity; - bb.joined = feel.joined; - bb.wear_rate = feel.wear_rate; - bb.worn = dingosdk::board_break::wear(); - bb.condition = std::string(dingosdk::board_break::condition_name(bb.worn)); - } model.grind_exit = {dingosdk::grind_exit_control::available(), dingosdk::grind_exit_control::enabled(), dingosdk::grind_exit_control::toe_heel(), dingosdk::grind_exit_control::stance(), dingosdk::grind_exit_control::strength()}; @@ -1524,17 +1502,6 @@ void tick(std::uintptr_t client, std::uintptr_t update) { { DINGO_PROFILE_ZONE("tick/multiplayer"); dingosdk::multiplayer::tick(r.base,client,multiplayer_ready,r.multiplayer_map,load_multiplayer_map); - if (multiplayer_ready) { - dingosdk::multiplayer::tick_prop_test(r.base, client); - // Other players' drones as models in the world (DRONE page, LOBBY tab: show other players' drones). - dingosdk::multiplayer::tick_drone_models(r.base, client, - dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f); - } - // And heard where they fly (every frame, so their sounds stop on leaving a level). - dingosdk::multiplayer::tick_drone_sounds(r.base, client, multiplayer_ready, - dingosdk::drone::knobs().value[dingosdk::drone::kShowBeacons].load(std::memory_order_relaxed) >= 0.5f); - // Board Break's cracks and hits, through the game's sound at the skater while in a level. - dingosdk::board_break::audio::tick_game_sound(r.base, client, multiplayer_ready); if (auto notice = dingosdk::multiplayer::take_leave_notice(); !notice.empty()) dingosdk::overlay::notify(dingosdk::overlay::NoticeLevel::warning, "Map not installed", std::move(notice)); // Kicked, banned or otherwise put out by the host or server: said in the middle of @@ -1670,6 +1637,12 @@ void tick(std::uintptr_t client, std::uintptr_t update) { DINGO_PROFILE_ZONE("tick/native party"); dingosdk::multiplayer::tick_native_party_actions(r.base, client, r.spectate_ready && r.spectate_client == client && (state == 13 || state == 21), camera_phase); + // A plugin's camera (reskate.camera) on the same lease, while Spectate leaves it be. + dingosdk::plugins::apply_plugin_camera(r.base, client, + r.spectate_ready && r.spectate_client == client && (state == 13 || state == 21), camera_phase); + // Plugins' clips on the skater's body (reskate.animation). + if (state == 13 || state == 21) dingosdk::plugins::tick_plugin_animation(r.base, client); + dingosdk::plugins::tick_plugin_sounds(r.base, client, state == 13 || state == 21); } if (state == 13 || state == 21) { DINGO_PROFILE_ZONE("tick/camera view"); diff --git a/Runtime/request_scheduler.h b/Runtime/request_scheduler.h index 284791f1..c1161734 100644 --- a/Runtime/request_scheduler.h +++ b/Runtime/request_scheduler.h @@ -74,7 +74,7 @@ public: const bool restore = request.action == overlay::DebugAction::restore_debug; if (!context.tick_ready || (!context.healthy && !restore) || loading() || request.action < overlay::DebugAction::set_free_camera || - request.action > overlay::DebugAction::set_drone_follow || !std::isfinite(request.value) || + request.action > overlay::DebugAction::set_free_camera_fov || !std::isfinite(request.value) || (!restore && count() >= 16)) return false; if (restore) clear(); append(request, thread); return true; diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 5974c6cf..2a8f8389 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -31,8 +31,6 @@ #include "Extension/Boot/offline_boot.h" #include "Extension/HallOfMeat/hall_of_meat.h" #include "Extension/Plugins/plugin_runtime.h" -#include "Extension/RoadRash/road_rash.h" -#include "Extension/BoardBreak/board_break.h" #include "Extension/Skater/grind_exit_control.h" #include "Extension/Skater/camera_observer.h" #include "Extension/Progression/entitlement_request_hook.h" @@ -449,10 +447,6 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { record("{\"event\":\"plugins_initialized\",\"active\":" + std::string(plugins ? "true" : "false") + "}"); const bool meat = dingosdk::hall_of_meat::start(r.base); record("{\"event\":\"hall_of_meat_initialized\",\"active\":" + std::string(meat ? "true" : "false") + "}"); - const bool rash = dingosdk::road_rash::start(r.base); - record("{\"event\":\"road_rash_initialized\",\"active\":" + std::string(rash ? "true" : "false") + "}"); - const bool board_break = dingosdk::board_break::start(r.base); - record("{\"event\":\"board_break_initialized\",\"active\":" + std::string(board_break ? "true" : "false") + "}"); const bool grind_control = dingosdk::grind_exit_control::start(r.base); record("{\"event\":\"grind_exit_control_initialized\",\"active\":" + std::string(grind_control ? "true" : "false") + "}"); const bool noclip_velocity = dingosdk::start_client_noclip_velocity(r.base); diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index f94fa5ef..7e8dc6f0 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -1,4 +1,5 @@ #include "server_host.h" +#include "server_plugins.h" #include "server_words.h" #include "server_text.h" #include "Engine/Core/Text/word_filter.h" @@ -19,7 +20,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 | drones 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 | 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" @@ -612,13 +613,6 @@ 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; @@ -1020,6 +1014,10 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(*tools ? "The players of a throwdown have " + lock_tools_text(*tools) + " locked while it runs." : "Throwdowns lock nothing."); } + // The server's plugins (server_plugins.h): "plugin ...", and their own commands, which never + // hide one of the server's. + if (console && plugins_) + if (auto answer = plugins_->command(line)) return *answer; return "Unknown command \"" + std::string(action) + "\". Type help."; } std::string Host::lock_name(std::uint64_t id, const Guest *guest) const { diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 3c0ed3c6..2c5c7d41 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -130,7 +130,6 @@ 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 locks = Json::array(); for (const auto bit : {lock_noclip, lock_nobail, lock_boosts, lock_tuning}) @@ -384,7 +383,6 @@ ServerConfig load_config(const std::filesystem::path &file, std::vectoris_array()) for (const auto &value : *locks) @@ -800,7 +798,7 @@ bool custom_command_name_free(std::string_view name) noexcept { {"help", "party", "p", "w", "whisper", "tell", "poll", "vote", "yes", "y", "no", "n", "tp", "activity-log", "admin", "admins", "afk-kick", "kick-time", "role", "roles", "announce", "announce-throwdowns", "announce-to", "announcements", "ban", "bans", "board-check", "bone-scale", "boosts", "boosts-allow", "chat-color", "chat-colour", "clear-objects", "crowd", - "distances", "drones", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg", + "distances", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg", "msg-admins", "msg-party", "name", "net", "nobail", "nobail-allow", "noclip", "noclip-allow", "object-limit", "object-placement", "object-scaling", "objects", "park", "park-mod", "parties", "party-size", "password", "placement", "players", "rate", "reserved", "rotation", "say", "score-allow", "score-check", "speed-check", "status", "teleport", diff --git a/Server/server_config.h b/Server/server_config.h index 4de26985..8407d407 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -211,9 +211,6 @@ struct ServerConfig { // and the trails and fire of costumes and skateboards. Off: nothing of them is relayed and // players' games show each other without them. bool sync_effects = true; - // Players see each other's FPV drones (the marker where a player's drone is). Off: none are - // relayed, and players' games neither send theirs nor show anyone's. - bool sync_drones = true; // Whether players may use noclip (and teleport) / No Bail / the boosts (admins always may). bool noclip = true, no_bail = true, boosts = true; // Players skate with the game's own physics tuning, not copies they edited. diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 445308c3..a1584f18 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1,4 +1,6 @@ #include "server_host.h" +#include "server_plugins.h" +#include "Extension/Plugins/plugin_wire.h" #include "server_text.h" #include "Extension/Multiplayer/Net/wire_codec.h" #include "Extension/Multiplayer/Net/block_codec.h" @@ -186,12 +188,23 @@ void Host::broadcast_player_chat(std::string_view text) { } bool Host::teleport_player(std::uint64_t steam_id, float x, float y, float z) { - (void)x; (void)y; (void)z; auto *guest = find(steam_id); - if (!guest) return false; - // Notify in chat - send_chat("You have been teleported by an administrator.", guest); - return true; + if (!guest || !guest->handshaken || !std::isfinite(x) || !std::isfinite(y) || !std::isfinite(z)) return false; + // The player's game moves their skater (as tpall and tphere do) and says the server did. + auto p = packet(PacketKind::teleport, now_); + p.teleport = {x, y, z}; + return send_packet(*guest, p, true, false); +} + +void Host::send_plugin_message(std::uint64_t to, const std::vector &bytes) { + if (bytes.empty() || bytes.size() > max_throwdown_message) return; + auto p = packet(PacketKind::throwdown, now_); + p.throwdown = bytes; + if (!to) { + broadcast(p, true, false); + return; + } + if (auto *guest = find(to); guest && guest->handshaken) send_packet(*guest, p, true, false); } std::optional> Host::player_position(std::uint64_t steam_id) const { @@ -229,6 +242,8 @@ unsigned Host::players() const { } bool Host::start(std::string &error) { + if (!plugins_) + plugins_ = std::make_shared(*this, config_.mods.parent_path() / "Plugins", bans_file(config_).parent_path() / "plugins", log_); transport_.set_send_rate(static_cast(config_.send_rate * 1024)); transport_.set_packing(config_.pack_ms); if (config_.steam_debug) @@ -521,7 +536,7 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 std::shared_ptr> 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::drone; + packet.kind == PacketKind::effects; for (auto &[id, guest] : guests_) { auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; @@ -753,7 +768,6 @@ 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; @@ -932,6 +946,7 @@ void Host::change_map(std::string_view map) { g.chat_rate = old.chat_rate; g.admin_budget = old.admin_budget; g.throwdown_budget = old.throwdown_budget; + g.plugin_budget = old.plugin_budget; g.party_budget = old.party_budget; g.scoring_budget = old.scoring_budget; g.speeding = old.speeding; @@ -1018,8 +1033,6 @@ 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. @@ -1343,12 +1356,27 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: // Linked throwdowns (Extension/Throwdowns/throwdown_relay.cpp): opaque to the // server, relayed like chat to everyone else in the same world. if (p.kind == PacketKind::throwdown) { - if (p.world != world_ || !routed_source(p, link->member, peer, true, id_) || !link->throwdown_budget.accept(now_, 60)) + // Plugin messages have a budget of their own (a few a second for a whole session). + if (p.world != world_ || !routed_source(p, link->member, peer, true, id_) || + !(plugins::wire::is_plugin(p.throwdown) ? link->plugin_budget.accept(now_, 200) : link->throwdown_budget.accept(now_, 60))) return; // A player whose tricks score differently takes part in nothing linked: their scores, // attempts and drops go nowhere, whatever the other players' games would do with them. if (link->scoring_flagged) return; link->last_packet = now_; + // Plugin messages (Extension/Plugins/plugin_wire.h): the server's plugins hear what is for + // them or for everyone; only the server speaks as the authority; one for a single player + // goes to them alone. + if (plugins::wire::is_plugin(p.throwdown)) { + if (plugins::wire::from_authority(p.throwdown)) return; + if (plugins_) plugins_->receive(peer, p.throwdown); + if (!plugins::wire::relayed(p.throwdown)) return; + if (const auto to = plugins::wire::to_of(p.throwdown)) { + const std::set only{to}; + broadcast(p, true, false, p.source, &only); + } else broadcast(p, true, false, p.source); + return; + } if (config_.activity_log || config_.announce_throwdowns || config_.throwdown_locks) { activity_.throwdown(peer, p.throwdown, link->latest_root ? &link->latest_root->position : nullptr, now_); update_throwdown_locks(); @@ -1840,6 +1868,7 @@ void Host::tick(std::uint64_t now) { if (link.connected && !guest->connected_at) guest->connected_at = now_; } for (const auto &message : transport_.receive()) receive(message.peer, message.bytes, message.arrived); + if (plugins_) plugins_->tick(now_); relay_poses(); flush_poses(); receive_cosmetics(); diff --git a/Server/server_host.h b/Server/server_host.h index a62fb1a4..f39a39a0 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -27,6 +27,7 @@ #include namespace dingosdk::server { +class ServerPlugins; using namespace multiplayer; // The host side of a ReSkate session without a game: admission, the roster, @@ -78,6 +79,8 @@ class Host { std::optional> player_position(std::uint64_t steam_id) const; void drop(std::uint64_t id, const std::string &reason, const std::string &detail = {}); void change_map(std::string_view map); // a level name, level path or destination + // A plugin message from the server's plugins (server_plugins.h) to one player, or everyone (0). + void send_plugin_message(std::uint64_t to, const std::vector &bytes); enum class VoteKind { map, kick, time, custom, poll, extend }; @@ -183,7 +186,6 @@ 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; @@ -191,7 +193,7 @@ class Host { ChatRate chat_rate; std::uint64_t typing_notice{}; // last accepted "still typing", so a flood is not relayed bool typing{}; // an "on" was passed along and no "off" since - ChatBudget admin_budget, throwdown_budget, party_budget; + ChatBudget admin_budget, throwdown_budget, party_budget, plugin_budget; SpeedCheck speed; // how fast their game runs, from their pose timestamps bool speeding{}; // flagged: out of linked activities (config speed_check) std::uint64_t speed_normal_since{}; // flagged, but measuring normal again since then @@ -304,6 +306,8 @@ class Host { JoinBackoff join_backoff_; // Steam IDs whose attempts to join keep failing std::optional password_; std::uint64_t id_{}, secret_{}, epoch_{}, map_{}, world_ = 1; + // Plugins' server copies (server_plugins.h), from start on. + std::shared_ptr plugins_; std::uint32_t sequence_{}; VoicePolicy voice_policy_; std::uint32_t object_clears_{}; diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp new file mode 100644 index 00000000..0af1305a --- /dev/null +++ b/Server/server_plugins.cpp @@ -0,0 +1,223 @@ +#include "server_plugins.h" +#include "server_host.h" +#include "Extension/Plugins/plugin_host.h" +#include "Extension/Plugins/plugin_manifest.h" +#include "Extension/Plugins/plugin_wire.h" + +#include +#include +#include +#include + +namespace dingosdk::server { +namespace { +using namespace dingosdk::plugins; + +constexpr std::size_t most_files = 20000; + +std::string text_of(const std::filesystem::path &path) { + const auto utf8 = path.u8string(); + return std::string(utf8.begin(), utf8.end()); +} + +// Changes when any file of the package does: its paths, sizes and times. +std::string fingerprint_of(const std::filesystem::path &directory) { + std::uint64_t hash = 1469598103934665603ull; + const auto add = [&](std::string_view text) { + for (const char c : text) { + hash ^= static_cast(c); + hash *= 1099511628211ull; + } + }; + std::error_code code; + std::size_t files = 0; + for (std::filesystem::recursive_directory_iterator it(directory, std::filesystem::directory_options::skip_permission_denied, code), end; + !code && it != end && ++files <= most_files; it.increment(code)) { + std::error_code entry; + if (!it->is_regular_file(entry)) continue; + const auto relative = std::filesystem::relative(it->path(), directory, entry).generic_u8string(); + add(std::string_view(reinterpret_cast(relative.data()), relative.size())); + add(std::to_string(it->file_size(entry))); + add(std::to_string(it->last_write_time(entry).time_since_epoch().count())); + } + return std::format("{:016x}", hash); +} + +// The server's game, for plugins: its players and what the server does to them. +class ServerGame final : public Game { + public: + explicit ServerGame(Host &host) : host_(host) {} + std::string kick(std::uint64_t player, std::string_view why) override { + if (!present(player)) return "that player is not on the server"; + host_.drop(player, why.empty() ? std::string("Removed from the server.") : "Removed from the server: " + std::string(why), + "a plugin removed them"); + return {}; + } + std::string announce(std::string_view plugin, std::string_view text) override { + host_.broadcast_player_chat("[" + std::string(plugin) + "] " + std::string(text)); + return {}; + } + std::string tell(std::string_view plugin, std::uint64_t player, std::string_view text) override { + if (!present(player)) return "that player is not on the server"; + host_.send_player_chat(player, "[" + std::string(plugin) + "] " + std::string(text)); + return {}; + } + std::string move_player(std::uint64_t player, const Vec3 &at) override { + return host_.teleport_player(player, at[0], at[1], at[2]) ? std::string{} : "that player is not on the server"; + } + + private: + bool present(std::uint64_t player) const { + for (const auto &p : host_.player_list()) + if (p.id == player) return true; + return false; + } + Host &host_; +}; + +class ServerEnvironment final : public Environment { + public: + ServerEnvironment(Host &host, std::filesystem::path storage, ServerPlugins::Log log) + : host_(host), storage_(std::move(storage)), log_(std::move(log)), game_(host) {} + void log(LogLevel level, std::string_view text) override { + log_(std::string(level == LogLevel::error ? "[Plugins] error: " : level == LogLevel::warn ? "[Plugins] warning: " : "[Plugins] ") + + std::string(text)); + } + void console(std::string_view text) override { log_(std::string(text)); } + double now_ms() override { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + } + std::filesystem::path storage_root() override { return storage_; } + // A plugin's command runs only for a line the server does not know, so none can hide one. + bool builtin_command(std::string_view word) override { return word == "plugin"; } + // The players on the server; the server is none of them (it is the authority). + SessionState session() override { + SessionState state{true, true, {}}; + for (const auto &p : host_.player_list()) state.players.push_back({p.id, p.name, false, false}); + return state; + } + bool send_message(std::vector bytes) override { + if (outbox.size() >= 256) return false; + outbox.push_back(std::move(bytes)); + return true; + } + std::vector take_messages() override { return std::exchange(inbox, {}); } + Game &game() override { return game_; } + + std::vector inbox; + std::vector> outbox; + + private: + Host &host_; + std::filesystem::path storage_; + ServerPlugins::Log log_; + ServerGame game_; +}; +} // namespace + +struct ServerPlugins::State { + State(Host &host, std::filesystem::path folder, std::filesystem::path storage, Log log) + : folder(std::move(folder)), environment(host, std::move(storage), log), plugins(environment, Side::server), log(std::move(log)), + server(host) {} + std::filesystem::path folder; + ServerEnvironment environment; + plugins::Host plugins; + Log log; + Host &server; + std::uint64_t next_scan{}, last_tick{}; + std::map> checked; // a fingerprint's file check + std::vector found; + bool announced{}; + + void scan() { + std::vector latest; + std::error_code code; + if (std::filesystem::is_directory(folder, code)) + for (std::filesystem::directory_iterator it(folder, std::filesystem::directory_options::skip_permission_denied, code), end; + !code && it != end; it.increment(code)) { + std::error_code entry; + if (!it->is_directory(entry) || !std::filesystem::is_regular_file(it->path() / manifest_file, entry)) continue; + Candidate candidate{text_of(it->path().filename()), it->path(), fingerprint_of(it->path()), {}}; + auto known = checked.find(candidate.fingerprint); + if (known == checked.end()) known = checked.emplace(candidate.fingerprint, check_package_files(it->path())).first; + candidate.refusal = known->second; + latest.push_back(std::move(candidate)); + } + std::sort(latest.begin(), latest.end(), [](const auto &a, const auto &b) { return a.id < b.id; }); + if (latest == found) return; + found = std::move(latest); + if (!announced && !found.empty()) { + announced = true; + log("[Plugins] " + std::to_string(found.size()) + " server plugin folder(s) in " + text_of(folder) + "."); + } + plugins.apply(found); + } +}; + +ServerPlugins::ServerPlugins(Host &host, std::filesystem::path folder, std::filesystem::path storage, Log log) + : state_(std::make_unique(host, std::move(folder), std::move(storage), std::move(log))) {} +ServerPlugins::~ServerPlugins() { + try { state_->plugins.shutdown(); } catch (...) {} +} + +void ServerPlugins::tick(std::uint64_t now_us) { + auto &s = *state_; + try { + if (now_us >= s.next_scan) { + s.next_scan = now_us + 2'000'000; + s.scan(); + } + const double dt = s.last_tick ? static_cast(now_us - s.last_tick) / 1000.0 : 0; + s.last_tick = now_us; + s.plugins.tick(dt); + for (auto &bytes : std::exchange(s.environment.outbox, {})) s.server.send_plugin_message(wire::to_of(bytes), bytes); + } catch (const std::exception &error) { + s.log(std::string("[Plugins] error: the plugin tick failed: ") + error.what()); + } +} + +void ServerPlugins::receive(std::uint64_t from, const std::vector &bytes) { + auto &inbox = state_->environment.inbox; + const auto to = wire::to_of(bytes); + if ((to == 0 || to == wire::authority) && inbox.size() < 256) inbox.push_back({from, bytes}); +} + +std::optional ServerPlugins::command(std::string_view line) { + auto &s = *state_; + std::string_view rest = line; + const auto word = [&] { + while (!rest.empty() && rest.front() == ' ') rest.remove_prefix(1); + const auto end = rest.find(' '); + const auto taken = rest.substr(0, end); + rest = end == std::string_view::npos ? std::string_view{} : rest.substr(end); + return taken; + }; + if (word() != "plugin") { + if (s.plugins.run_console(line)) return std::string("Ran by a plugin."); + return std::nullopt; + } + const auto verb = word(); + const auto id = word(); + if (verb == "reload") return s.plugins.reload(id); + if (verb == "off") return s.plugins.switch_off(id); + if (verb == "on") return s.plugins.switch_on(id); + if (verb == "commands") { + std::string text; + for (const auto &plugin : s.plugins.status()) + for (const auto &[name, help] : plugin.commands) text += (text.empty() ? "" : "\n") + std::format("{} - {} ({})", name, help, plugin.id); + return text.empty() ? std::string("No plugin has a console command.") : text; + } + if (!verb.empty() && verb != "list") return std::string("plugin list|commands|reload |off |on "); + const auto all = s.plugins.status(); + if (all.empty()) + return "No server plugins. A plugin is a folder in " + text_of(s.folder) + " with a reskate-plugin.json (\"side\" \"server\" or \"both\")."; + std::string text; + for (const auto &plugin : all) { + if (!text.empty()) text += '\n'; + text += std::format("{}: {}", plugin.id, plugin.state); + if (plugin.state == "running") text += std::format(", {:.1f} MB, {:.3f} ms a tick", plugin.memory / 1048576.0, plugin.tick_ms); + else if (!plugin.reason.empty()) text += " (" + plugin.reason + ")"; + } + return text; +} +} // namespace dingosdk::server diff --git a/Server/server_plugins.h b/Server/server_plugins.h new file mode 100644 index 00000000..402dfe56 --- /dev/null +++ b/Server/server_plugins.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +// Plugins' server copies on a dedicated server (Extension/Plugins): the session's authority, where +// a game mode's rules run. The operator installs them in Plugins beside the server, a folder a +// plugin (with its reskate-plugin.json, "side" "server" or "both"); they load, reload and unload as +// the folder changes, checked every 2 s. The server has no game: what a plugin may do here is the +// session's messages, its players, the server API (kick, say, tell, move) and its storage. +// Everything here runs on the server's tick thread. +namespace dingosdk::server { +class Host; + +class ServerPlugins { + public: + using Log = std::function; + ServerPlugins(Host &host, std::filesystem::path folder, std::filesystem::path storage, Log log); + ~ServerPlugins(); + ServerPlugins(const ServerPlugins &) = delete; + ServerPlugins &operator=(const ServerPlugins &) = delete; + + void tick(std::uint64_t now_us); + // A player's plugin message (plugin_wire.h): for the authority, or for everyone. + void receive(std::uint64_t from, const std::vector &bytes); + // A console line for the plugins: "plugin list|reload|off|on" or a plugin's own command. + // Empty when it is neither. + std::optional command(std::string_view line); + + private: + struct State; + std::unique_ptr state_; +}; +} // namespace dingosdk::server diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 0e540d10..f77cf452 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -225,11 +225,12 @@ if(WIN32) Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp + Server/server_plugins.cpp "${CMAKE_CURRENT_BINARY_DIR}/generated/launcher_resources.rc") target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json dingosdk_lz4 dingosdk_zstd dingosdk_logging dingosdk_miniz dingosdk_word_filter dingosdk_https winhttp bcrypt winmm - dingosdk_imgui d3d12 dxgi dwmapi shell32 windowscodecs ole32) + dingosdk_imgui d3d12 dxgi dwmapi shell32 windowscodecs ole32 dingosdk_plugins) set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer") dingosdk_version_info(dingosdk_server "ReSkate dedicated server" "ReSkateServer.exe" VFT_APP) else() @@ -241,9 +242,10 @@ else() Extension/Multiplayer/Steam/steam_transport.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp Extension/Multiplayer/Remote/playback_buffers.cpp Extension/Multiplayer/Session/password.cpp - Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp) + Server/server_activity.cpp Server/server_votes.cpp Server/server_commands.cpp Server/server_parks.cpp Extension/Throwdowns/throwdown_wire.cpp + Server/server_plugins.cpp) target_include_directories(dingosdk_server SYSTEM PRIVATE "${PROJECT_SOURCE_DIR}/External/steam_networking") target_link_libraries(dingosdk_server PRIVATE dingosdk_launcher_support dingosdk_world_layer_scan dingosdk_json - dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dl pthread) + dingosdk_lz4 dingosdk_zstd dingosdk_miniz dingosdk_word_filter OpenSSL::Crypto dingosdk_plugins dl pthread) set_target_properties(dingosdk_server PROPERTIES OUTPUT_NAME "ReSkateServer") endif() diff --git a/cmake/Core.cmake b/cmake/Core.cmake index a9d4300e..a25e9ba4 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -144,7 +144,9 @@ add_library(dingosdk_plugins STATIC Extension/Plugins/plugin_api_ui.cpp Extension/Plugins/plugin_api_more.cpp Extension/Plugins/plugin_api_world.cpp - Extension/Plugins/plugin_host.cpp) + Extension/Plugins/plugin_host.cpp + Extension/AnimationStudio/anim_core.cpp + Extension/Plugins/plugin_wave.cpp) target_link_libraries(dingosdk_plugins PUBLIC dingosdk_json dingosdk_console_core PRIVATE dingosdk_luau) if(WIN32) target_sources(dingosdk_plugins PRIVATE Extension/Plugins/plugin_discovery.cpp) diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index ac793b7b..67bd929a 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -13,7 +13,6 @@ add_library(dingosdk_runtime SHARED Extension/Console/perf_commands.cpp Extension/Console/memory_ledger.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 @@ -32,8 +31,6 @@ add_library(dingosdk_runtime SHARED Extension/UI/NativeMenu/native_menu_data.cpp Extension/Multiplayer/Remote/native_skater.cpp Extension/Multiplayer/Remote/native_skater_spawn.cpp - Extension/Multiplayer/Remote/native_prop.cpp - Extension/Multiplayer/Remote/drone_sound.cpp Extension/Multiplayer/Remote/puppet_cost.cpp Extension/Multiplayer/Remote/native_cosmetics.cpp Extension/Multiplayer/Remote/native_audio.cpp @@ -126,6 +123,11 @@ add_library(dingosdk_runtime SHARED Extension/Plugins/plugin_commands.cpp Extension/Plugins/plugin_http.cpp Extension/Plugins/plugin_game_runtime.cpp + Extension/Skater/skater_animation.cpp + Extension/Plugins/plugin_sound.cpp + Extension/Skater/skater_events.cpp + Extension/Skater/outfit_overlay.cpp + Extension/Skater/board_pose.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp Extension/World/native_entity_pages.cpp @@ -169,11 +171,8 @@ add_library(dingosdk_runtime SHARED Extension/HallOfMeat/hall_of_meat_skater.cpp Extension/HallOfMeat/hall_of_meat_skeleton.cpp Extension/HallOfMeat/hall_of_meat_slow_motion.cpp - Extension/RoadRash/road_rash.cpp - Extension/RoadRash/road_rash_commands.cpp - Extension/BoardBreak/board_break.cpp - Extension/BoardBreak/board_break_commands.cpp - Extension/BoardBreak/board_break_audio.cpp + Extension/Skater/breakable_deck.cpp + Extension/Skater/breakable_deck_commands.cpp Extension/Trainer/trainer.cpp Extension/Trainer/trainer_presets.cpp Extension/Trainer/trainer_jump.cpp @@ -332,8 +331,8 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/emotes.rc") endif() # Game content ReSkate carries itself (Engine/Vfs/builtin_packs.h), one zip each in assets/builtin: -# the FPV drone's model (tools/models/build_drone_pack.py; without it other players' drones use a -# stand-in prop). A missing zip is left out. (Mods that are content for ReSkate's features, such +# the FPV drone's model (tools/models/build_drone_pack.py), a blueprint plugins may place. A missing +# zip is left out. (Mods that are content for ReSkate's features, such # as Board Break's decks, are official mods the launcher downloads: Launcher/official_mods.h.) # Each zip is made into a zstd pack by ReSkateBuiltinPacker as the build goes (again only when the # zip changes), which is what the DLL carries: compressed whole, a pack's repeated files cost diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 212a8b2c..c1efd3e8 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -15,8 +15,6 @@ 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_page.cpp Extension/UI/Overlay/perf_overlay.cpp Extension/UI/Overlay/skate_hud_overlay.cpp Extension/UI/Overlay/nametag_overlay.cpp @@ -28,6 +26,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/skate_menu_addons.cpp Extension/UI/Overlay/plugins_page.cpp Extension/UI/Overlay/plugin_overlay.cpp + Extension/UI/Overlay/plugin_images.cpp Extension/UI/Overlay/menu_map_pictures.cpp Extension/UI/Overlay/skate_menu_skater.cpp Extension/UI/Overlay/skate_menu_settings.cpp diff --git a/contrib/board-break.md b/contrib/board-break.md index cab94e1c..0a41d1f0 100644 --- a/contrib/board-break.md +++ b/contrib/board-break.md @@ -3,8 +3,9 @@ A hard slam can break the local skater's board: at the nose, in the middle or at the tail. Half the time the grip tape holds the pieces together and the board folds or an end hangs; otherwise a piece comes off, flies and lies where it lands, truck and wheels with it. The board is whole again once -the skater has picked it up, rides again or respawns. Built into ReSkate; the broken pieces -themselves come from a content mod with the deck shapes in it. +the skater has picked it up, rides again or respawns. A plugin, [board-break](plugins/board-break) +(it was built into ReSkate until 2026-10); the broken pieces themselves come from a content mod +with the deck shapes in it. Boards also wear: every hard landing, rail hit, grind and slam takes something out of the board of the deck in the game, and a worn board steers lazier, wobbles, breaks from less and in the end can @@ -12,26 +13,25 @@ give way under the rider. A boardslide or darkslide that comes down too hard ont the board and throw the rider. A break cracks (and sounds like metal when it happened on metal), and the body's hits in a bail are heard where and as hard as they land. The player sets all of it. -Code: [board_break.cpp](../Extension/BoardBreak/board_break.cpp) (client tick, the boards' -render pose hook, wear, rail snaps), [board_break_model.h](../Extension/BoardBreak/board_break_model.h) -(the rules and the geometry on their own) and -[board_break_feel.h](../Extension/BoardBreak/board_break_feel.h) (the settings, what each hit wears, -what wear does, rail contacts), both with [tests](../Extension/BoardBreak/Test/board_break_tests.cpp), -[board_break_audio.cpp](../Extension/BoardBreak/board_break_audio.cpp) (the sounds), -[board_break_commands.cpp](../Extension/BoardBreak/board_break_commands.cpp) (console), and the -deck's place in the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp). +Code: the plugin's Lua in [plugins/board-break/plugin](plugins/board-break/plugin) (the break, +wear, rail snaps, sounds, its console command and menu), drawn through reskate.board +([board_pose.cpp](../Extension/Skater/board_pose.cpp)). What every board needs while a deck pack +is in the game stays in core whichever plugins run: +[breakable_deck.cpp](../Extension/Skater/breakable_deck.cpp) (which deck, its name and wheel +spacing, the `boarddeck` command), board_pose.cpp (the deck's stretches kept out of sight, the +baseplates on the deck, the wheels at the deck's spacing, on every board), and the deck's place in +the mod list in [mod_catalog.cpp](../Engine/Vfs/mod_catalog.cpp). ## What the player gets - A slam of force 10 or more always breaks the board, a lighter one a quarter of the time, rising in between (the force is the bail cause's that led to the wipeout). Now and then a board gives way while it is ridden: the nose goes. -- `boardbreak 0|1`, `boardbreak deck [name|number|next]`, `boardbreak snap [nose|mid|tail]` to look - at a break (that one stays until `boardbreak mend` or a respawn). The switch and the deck are in - the menus beside Road Rash (Mod Options > Player, and the overlay's Skater page). The overlay has - a card BOARD BREAK DECK with a button for each deck, on the Mods page and under the switch: a - press asks for that deck, the mods are applied and the level loads again with it. The switch is - saved with the profile. +- `boardbreak 0|1`, `boardbreak snap [nose|mid|tail]` to look at a break (that one stays until + `boardbreak mend` or a respawn), from the plugin; its settings are on the overlay's ADDONS page, + PLUGINS tab. `boarddeck [name|number|next]` (core) and the overlay's BREAKABLE DECK card on the + Mods page pick the deck: a press asks for that deck, the mods are applied and the level loads + again with it. - A break plays back in the replay editor as it happened. - **Wear and tear.** Each deck's board wears from 0% (new) to 100% (worn out): a landing coming down faster than 3.5 m/s, a hit onto a rail or ledge (harder on a slide than on the trucks), every second diff --git a/contrib/plugins/README.md b/contrib/plugins/README.md index 12903e85..d589fb12 100644 --- a/contrib/plugins/README.md +++ b/contrib/plugins/README.md @@ -13,3 +13,12 @@ folder into the game's `Mods` folder and open the console. the sandbox. Both also run without the game, in `dingosdk_plugin_tests` (`DINGOSDK_BUILD_PLUGIN_TESTS`). + +## Ports + +- **road-rash** is Road Rash (`Extension/RoadRash`) as a mod: bails leave marks that build up, + worn over the outfit with `reskate.outfit`. It needs the Road Rash items mod, and the built-in + Road Rash switched off (`roadrash 0`), since ReSkate refuses outfit changes while that one is on. + While the built-in one exists it owns the `roadrash` command, so this one answers to `road rash` + (`road rash heal`, `road rash bails 20`, `road rash blood 0`, `road rash tattoos 1`, + `road rash test` for its own checks). Its switches are on its panel in the PLUGINS tab too. diff --git a/contrib/plugins/board-break/README.md b/contrib/plugins/board-break/README.md new file mode 100644 index 00000000..c4d8bd39 --- /dev/null +++ b/contrib/plugins/board-break/README.md @@ -0,0 +1,57 @@ +# Board Break (plugin) + +ReSkate's Board Break as a sandboxed Luau plugin, to ship inside the +`Fourth_Studio-Board_Break_Pack` deck pack. A hard slam, or a hard boardslide or darkslide onto a +rail, can snap the local skater's board at the nose, in the middle or at the tail. Boards wear out +with every hit and break more easily as they do. The board is whole (and new) again once the skater +has picked it up, rides again or respawns. + +The pieces are the pack's: each deck has four stretches (nose, front, back, tail) on spare bones +12 to 15 of the board's rig. The plugin only moves bones with `reskate.board`, and plays the pack's +`sounds/light|medium|heavy|body/*.wav` (a crack size with no folder of its own takes the nearest +size that has one, or the wave files in `sounds/` itself; at most 6 of each are used). + +- `plugin/model.lua`: where a board breaks, what is left of it, and where each part lies. +- `plugin/feel.lua`: the settings, the chances, wear, the contact type on a rail. +- `plugin/breaker.lua`: the rules at play (breaks, mending, wear, drawing), with the game passed + in, so `plugin/tests.lua` can run them against a fake one. +- `plugin/main.lua`: the wiring to `reskate`, the console command and the menu panel. + +## Install + +Copy `reskate-plugin.json` and `plugin/` into the pack's folder in `Mods`, beside its `sounds/`. + +## Console + +`boardbreak`, or `board break` while ReSkate still has a Board Break of its own: +`0|1`, `status`, `snap [nose|mid|tail]` (to look at a break: it stays until `mend` or a respawn), +`mend`, `new` (a new board), `worn [percent]`, `test` (its own checks), and the settings +`always`, `sensitivity`, `joined`, `rails`, `wear`, `wearrate`, `cracks`, `bodysounds`, each with +a value to set it or none to read it. The same is on the ReSkate menu's PLUGINS tab. + +## What it leaves out + +What the plugin API cannot do yet: + +- Physics tuning by wear (a worn board's softer trucks and lazier turn). +- Choosing the deck (`boardbreak deck`) and each deck's own setup (`deck.ini`: its label, its + wheels' spread). There is one wear for the board, not one per deck. +- Material detection: no metal sound for a board that breaks on metal, no `metal` or `materials` + commands. +- Multiplayer: other players do not see a broken board. +- A new board's scratches: the game's own board wear is not reset with the break. +- The baseplates are held on the deck only while the board is broken (ReSkate held them on every + board, every frame). +- Whether a breakable deck is in the game: the plugin takes it that its pack's deck is. + +How it differs from ReSkate's Board Break, where the API made it so: + +- A piece that comes off flies without its truck (the truck is hidden): `board.toss` writes bones + relative to the board's place, and a truck hangs on the deck's bone. +- While the back half of a board broken in the middle flies, the front half it leaves stays level + on its truck instead of tipping onto its broken end: the tail's bone hangs on the front + stretch's, and tipping the front would drag the flying tail with it. +- The real deck goes 950 m down rather than 2000 m: `board.set` takes a bone at most 1000 m from + its parent, and the trucks, which hang on the deck, come back up by as much. +- A flying piece lands on one ground found as it breaks (where the board rolled a moment before, + else a ray down from the board), instead of following the board's height as it settles. diff --git a/contrib/plugins/board-break/plugin/breaker.lua b/contrib/plugins/board-break/plugin/breaker.lua new file mode 100644 index 00000000..0f015ce4 --- /dev/null +++ b/contrib/plugins/board-break/plugin/breaker.lua @@ -0,0 +1,329 @@ +-- Board Break's rules at play: when the local skater's board breaks, how it is drawn broken, when +-- it is whole again, and how it wears. Everything it needs of the game comes in `game` (main.lua +-- hands it reskate's; tests.lua a fake one): +-- now() ms; random() 0 to 1; bones() the board's 16 bones as last drawn, or nil; velocity() the +-- board's, or nil; skater() the skater's position, or nil; raycast(from, to) a vector or nil; +-- board, reskate.board's set, hide, toss, clear and park; sound(kind) whether it has one, +-- play(kind, volume); wipeout(force); toast(text, level); log(text); load(key), save(key, value). +local feel = require("feel") +local model = require("model") +local v = model.vec + +-- The board's rig (Extension/Skater/board_pose.h): 1 its place in the world, 2 the deck's mount, +-- 3 the deck, 4 and 8 the trucks (each with its baseplate's effector right after it), and the +-- spare bones that carry the four stretches of a breakable deck: nose, front, back, tail (the +-- tail's hangs on the front's). +local place_bone, mount_bone, deck_bone = 1, 2, 3 +local back_truck_bone, front_truck_bone, back_plate_bone, front_plate_bone = 4, 8, 5, 9 +local carrier = {12, 13, 15, 14} +local stretch_bones = {12, 13, 14, 15} +-- The board is back in the skater's hand: close to the skater and not lying flat. +local hand_reach, hand_upright = 1.3, 0.8 +local riding_states = {riding = true, air = true, grinding = true} + +local Breaker = {} +Breaker.__index = Breaker + +local function axes_of(bones) + local facing = model.unit(model.mul(bones[place_bone].rotation, bones[mount_bone].rotation)) + return model.turn(facing, v(0, 0, 1)), model.turn(facing, v(0, 1, 0)) +end + +local function new(game) + local self = setmetatable({game = game}, Breaker) + self.settings = feel.load(game.load("settings")) + self.on = game.load("on") ~= false + local worn = game.load("wear") + self.wear = if type(worn) == "number" and worn == worn then math.clamp(worn, 0, 1) else 0 + self.dirty, self.saved_at = false, game.now() + self.broken = nil -- {since, parts, angle, place, joined, gave_way, asked, tossed} + self.state, self.previous = "none", "none" + self.ride_height, self.ride_when = 0, -math.huge + self.board_seen, self.back_since, self.body_at = -math.huge, nil, -math.huge + self.refused = nil -- why the board was refused, told once + game.board.park(stretch_bones) + return self +end + +-- ---- settings and wear --------------------------------------------------------------------------- +function Breaker:set_on(on) + self.on = on + self.game.save("on", on) +end +function Breaker:setting(name) return self.settings[name] end +function Breaker:set_setting(name, value) + if not feel.set(self.settings, name, value) then return false end + self.game.save("settings", self.settings) + return true +end +function Breaker:save_wear() + self.dirty, self.saved_at = false, self.game.now() + self.game.save("wear", self.wear) +end +function Breaker:set_wear(wear) + self.wear = math.clamp(wear, 0, 1) + self:save_wear() +end +function Breaker:add_wear(amount) + if not (amount > 0) or self.settings.wear == 0 then return end + local before = self.wear + self.wear = feel.add_wear(before, amount, self.settings.wearrate) + if self.wear ~= before then self.dirty = true end + local was, now = feel.condition(before), feel.condition(self.wear) + if was ~= now then + self.game.log(string.format("the board is %s now (%.0f%% worn).", now, self.wear * 100)) + -- Told as it gets close, so a break that follows is no surprise. + if now == "beat" then + self.game.toast("Your board is beat: it breaks from smaller slams now. New board: menu, PLUGINS, Board Break.", "info") + elseif now == "done" then + self.game.toast("Your board is done: the next big drop snaps it. New board: menu, PLUGINS, Board Break.", "warn") + end + end +end + +-- ---- sounds -------------------------------------------------------------------------------------- +-- A crack size with no sounds of its own takes the nearest that has some. +local nearest = {light = {"light", "medium", "heavy"}, medium = {"medium", "heavy", "light"}, heavy = {"heavy", "medium", "light"}} +function Breaker:crack(size) + if self.settings.cracks == 0 then return end + for _, kind in nearest[size] do + if self.game.sound(kind) then return self.game.play(kind, 1) end + end +end +-- The body's hits in a bail, each as hard as it hit; one every 70 ms at most. +function Breaker:body_hit(impact) + if not self.on or self.settings.bodysounds == 0 then return end + local loudness = feel.body_loudness(impact or 0) + local now = self.game.now() + if loudness <= 0 or now - self.body_at < 70 or not self.game.sound("body") then return end + self.body_at = now + self.game.play("body", loudness) +end + +-- ---- drawing ------------------------------------------------------------------------------------- +-- One refusal is told once a break, not every tick's. +function Breaker:asked(ok, why) + if not ok and why ~= self.refused then + self.refused = why + self.game.log("the board cannot be drawn broken: " .. tostring(why)) + end + return ok +end +function Breaker:set(bone, motion) + return self:asked(self.game.board.set(bone, {position = motion.at, rotation = motion.facing, space = "local"})) +end +-- The local board, broken: the real deck goes away, the trucks come back (their baseplates with +-- them), each stretch goes to where its piece lies and one that belongs to no piece goes away too. +-- Every tick from the bones as the animation left them: the trucks steer, and how the pieces hang +-- depends on which way up the board lies. +function Breaker:draw(bones) + local b = self.broken + local root, mount = bones[place_bone], bones[mount_bone] + local up = model.turn(root.rotation, v(0, 1, 0)).y + -- A tossed tail hangs on the front stretch's bone, which board.toss does not know: the front + -- stays level while the tail flies, or it would take the tail with it. + if b.tossed[14] then up = 0 end + local layout = model.lay_out(b.parts.kept, b.parts.hinge, b.angle, up, mount.rotation) + local deck = bones[deck_bone] + self:set(deck_bone, model.rigid(deck.rotation, deck.position + model.deck_away)) + local function truck(kept, bone, plate, motion, place, rest) + if not kept then return self:asked(self.game.board.hide(bone)) end + local now = model.rigid(bones[bone].rotation, bones[bone].position) + if model.length(now.at - rest.at) < 0.05 then self:set(plate, model.baseplate_bone(now, rest)) end + self:set(bone, model.truck_bone(motion, mount.rotation, mount.position, now.facing, place)) + end + truck(layout.back_truck, back_truck_bone, back_plate_bone, layout.back_motion, model.back_truck_place, model.back_truck_rest) + truck(layout.front_truck, front_truck_bone, front_plate_bone, layout.front_motion, model.front_truck_place, model.front_truck_rest) + local placed = model.bones_of(layout.stretch) + for i, bone in carrier do + if b.tossed[bone] then continue end + if layout.stretch[i].shown then self:set(bone, placed[i]) else self:asked(self.game.board.hide(bone)) end + end +end + +-- The ground under the board: where it rolled a moment ago, else what is below it (on a rail, +-- from under the rail), else its own height. +function Breaker:ground(bones, rail) + if self.game.now() - self.ride_when < 400 then return self.ride_height end + local at = bones[place_bone].position + local from = at + (if rail then v(0, -0.3, 0) else v(0, 0.5, 0)) + local hit = self.game.raycast(from, from - v(0, 30, 0)) + return if hit then hit.y else at.y +end +-- The piece that comes off: a kick and a spin from the board's place, onto the ground. +function Breaker:toss(bones, rail) + local b = self.broken + local list = {} + for i, bone in carrier do + if model.has(b.parts.loose, bit32.lshift(1, i - 1)) then table.insert(list, bone) end + end + -- Each leaves from its place in the whole board (a stretch is kept far below the board until + -- then, and the tail's place, on the front's, is that too while the front lies level). + for _, bone in list do self:set(bone, model.rigid()) end + local function roll(low, high) return low + (high - low) * self.game.random() end + local flight = model.flight(b.parts.loose, roll) + local facing = model.mul(bones[place_bone].rotation, bones[mount_bone].rotation) + local ok = self:asked(self.game.board.toss(list, { + velocity = model.turn(facing, flight.kick), + spin = model.turn(facing, flight.spin), + ground = self:ground(bones, rail) + flight.rest, + })) + if ok then + for _, bone in list do b.tossed[bone] = true end + end +end + +-- ---- breaking and mending ------------------------------------------------------------------------ +-- `how`: gave_way (it went under the rider: it stays broken while it is ridden), carried (on foot +-- with the board: the grip tape always holds then), asked (from the console, to be looked at: it +-- stays until mended there), rail (on a rail), why (for the log). False when it did not break. +function Breaker:snap(place, how) + if self.broken then return false end + local bones = self.game.bones() + if not bones then return false end + local game = self.game + place = place or model.places[math.clamp(math.floor(game.random() * 3) + 1, 1, 3)] + local joined = how.carried or game.random() < self.settings.joined / 100 + local parts = model.broken_at(place, joined) + local angle = if parts.hinge == "fold" then 0.35 + 0.65 * game.random() else 0.30 + 0.35 * game.random() + self.broken = {since = game.now(), parts = parts, angle = angle, place = place, joined = joined, + gave_way = how.gave_way, asked = how.asked, tossed = {}} + self.back_since = nil + self:crack(model.break_size(place, joined, angle)) + if parts.loose ~= 0 then self:toss(bones, how.rail) end + self:draw(bones) + game.log(string.format("the board broke at the %s%s (%s).", place, if joined then ", held together by the grip tape" else "", how.why or "asked for")) + return true +end +-- A board that really broke is a new one now; one broken from the console was only looked at. +function Breaker:mend(why) + if not self.broken then return false end + if not self.broken.asked then + self.wear = 0 + self:save_wear() + end + self.game.log(string.format("the board is whole again after %.1f s (%s).", (self.game.now() - self.broken.since) / 1000, why)) + self.broken, self.refused = nil, nil + self.game.board.clear() + self.game.board.park(stretch_bones) + return true +end + +-- ---- the game's news ----------------------------------------------------------------------------- +function Breaker:skater_state(now, previous) + self.state, self.previous = now, previous or self.state + -- A respawn: the skater has no state for a moment, and the next board is whole. + if now == "none" and self.broken and self.game.now() - self.broken.since > 300 then self:mend("a respawn") end + if now == "wipeout" and self.broken then self.broken.gave_way = false end +end + +-- A bail: the slam's force decides whether the board breaks. +function Breaker:bail(force) + if not self.on then return end + force = if type(force) == "number" and force == force then force else 0 + self:add_wear(feel.slam_wear(force)) + local chance = if self.settings.always == 1 then 1 else feel.slam_chance(force, self.settings.sensitivity, self.wear) + if self.broken or not (self.game.random() < chance) then return end + -- Nose first it goes at the nose, tail first at the tail, flat mostly in the middle. + local bones = self.game.bones() + local slope = 0 + if bones then + local along = axes_of(bones) + slope = -along.y + end + local carried = self.state == "off_board" or (self.state == "wipeout" and self.previous == "off_board") + self:snap(feel.place_of_break(slope, false, self.game.random()), {carried = carried, + why = string.format("a slam of force %.1f%s", force, if self.settings.always == 1 then ", set to always break" else "")}) +end + +-- How the board touches a rail now: from its axes and its velocity. +function Breaker:contact(bones) + local velocity = self.game.velocity() + if not bones or not velocity then return "grind" end + local along, up = axes_of(bones) + return feel.contact_of(along, up, velocity) +end + +-- The board came down from the air, onto the ground or a rail. +function Breaker:landed(onto, down_speed) + if not self.on then return end + down_speed = down_speed or 0 + local rail = onto == "rail" + local contact = if rail then self:contact(self.game.bones()) else "grind" + local wear = self.wear -- as it was before this landing + self:add_wear(if rail then feel.rail_wear(down_speed, contact ~= "grind") else feel.landing_wear(down_speed)) + if self.broken then return end + local s = self.settings + if not rail then + -- A done board gives way on a landing hard enough for how worn it is: the nose goes, and they ride on. + if s.wear == 1 and feel.gives_way_on_landing(down_speed, s.sensitivity, wear) then + self:snap("nose", {gave_way = true, why = string.format("a landing at %.1f m/s on a board that was done", down_speed)}) + end + return + end + if s.rails == 0 or contact == "grind" then return end + local chance = if s.always == 1 then (if down_speed > 1.5 then 1 else 0) else feel.rail_chance(down_speed, contact, s.sensitivity, wear) + if self.game.random() < chance and self:snap("mid", {rail = true, why = string.format("a %s onto a rail at %.1f m/s", contact, down_speed)}) then + -- The board snaps under the rider: the rider goes down with it. Gently: a slam's force + -- with the board flat on the ground throws the rider high into the air. + self.game.wipeout(2.5) + end +end + +-- Every tick (`dt` in ms): wear while grinding, the ground under the board, the broken board drawn, +-- and whether it is whole again. +function Breaker:tick(dt) + local game = self.game + local now = game.now() + local bones = game.bones() + if not bones then + -- No board (a respawn, a level change): the next one is whole. One that is only out of + -- sight for a moment stays as it is. + if self.broken and now - self.board_seen < 500 then return end + self:mend("the board is gone") + return + end + self.board_seen = now + local state = self.state + if self.on and state == "grinding" then + self:add_wear(feel.grind_wear_per_second(self:contact(bones) ~= "grind") * (dt or 0) / 1000) + end + -- Rolling on the ground, the right way up: the board's own height is the ground's. + local root = bones[place_bone] + if state == "riding" and model.turn(root.rotation, v(0, 1, 0)).y > 0.9 then + self.ride_height, self.ride_when = root.position.y, now + end + if self.dirty and now - self.saved_at > 5000 then self:save_wear() end + local b = self.broken + if not b then return end + -- Whole again once the skater rides it, or has picked it up after the fall. A board that gave + -- way under the rider stays as it is while it is ridden; after a fall it is broken like any other. + local on_it = riding_states[state] == true + local skater = game.skater() + local in_hand = false + if skater then + local upright = math.abs(model.turn(root.rotation, v(0, 1, 0)).y) + in_hand = model.length(root.position - skater) < hand_reach and upright < hand_upright + end + local back = if b.gave_way then not on_it and in_hand else on_it or (state ~= "wipeout" and in_hand) + if b.asked or not back then + self.back_since = nil + elseif not self.back_since then + self.back_since = now + elseif now - self.back_since > 300 and now - b.since > 1200 then + self:mend(if on_it then "riding again" else "back in hand") + return + end + self:draw(bones) +end + +function Breaker:status() + local s = self.settings + local function word(n) return if n == 1 then "on" else "off" end + return string.format("%s; the board is %s (%.0f%% worn)%s; sensitivity %d%%, always break %s, rail snaps %s, wear %s at %d%%, " .. + "grip holds %d%% of breaks, crack sounds %s, body sounds %s", + if self.on then "on" else "off", feel.condition(self.wear), self.wear * 100, if self.broken then ", broken" else "", + s.sensitivity, word(s.always), word(s.rails), word(s.wear), s.wearrate, s.joined, word(s.cracks), word(s.bodysounds)) +end + +return {new = new} diff --git a/contrib/plugins/board-break/plugin/feel.lua b/contrib/plugins/board-break/plugin/feel.lua new file mode 100644 index 00000000..07d9fdc4 --- /dev/null +++ b/contrib/plugins/board-break/plugin/feel.lua @@ -0,0 +1,150 @@ +-- How a board wears and how hard it has to be hit to break, apart from the game: the player's +-- settings, the damage each kind of hit does, and what kind of contact a board makes with a rail. +-- Plain numbers, so the tests can hold them. (Ported from ReSkate's +-- Extension/BoardBreak/board_break_feel.h.) +local model = require("model") +local feel = {} + +-- ---- the player's settings ----------------------------------------------------------------------- +-- Kept in reskate.storage, each set from the console as `boardbreak ` and from the menu. +-- always every slam breaks the board, for replay clips +-- sensitivity percent: at 200 half the force breaks a board, at 50 twice as much +-- joined percent of breaks the grip tape holds together +-- rails a hard boardslide or darkslide onto a rail or ledge can break it +-- wear boards wear out and break more easily as they do +-- wearrate percent: how fast they wear +-- cracks the crack sound of a break +-- bodysounds the body's hits during a bail +feel.sensitivity_min, feel.sensitivity_max, feel.rate_min, feel.rate_max = 10, 400, 0, 500 +feel.settings = { + {name = "always", on_off = true, low = 0, high = 1, default = 0}, + {name = "sensitivity", on_off = false, low = feel.sensitivity_min, high = feel.sensitivity_max, default = 100}, + {name = "joined", on_off = false, low = 0, high = 100, default = 50}, + {name = "rails", on_off = true, low = 0, high = 1, default = 1}, + {name = "wear", on_off = true, low = 0, high = 1, default = 1}, + {name = "wearrate", on_off = false, low = feel.rate_min, high = feel.rate_max, default = 100}, + {name = "cracks", on_off = true, low = 0, high = 1, default = 1}, + {name = "bodysounds", on_off = true, low = 0, high = 1, default = 1}, +} +function feel.info(name) + for _, info in feel.settings do + if info.name == name then return info end + end + return nil +end +-- Every setting at its default, as numbers (on/off as 1/0). +function feel.defaults() + local out = {} + for _, info in feel.settings do out[info.name] = info.default end + return out +end +-- Sets `name` in `values`, kept within its range; false for no such setting or no number. +function feel.set(values, name, value) + local info = feel.info(name) + if not info or type(value) ~= "number" or value ~= value then return false end + value = math.clamp(math.floor(value + 0.5), info.low, info.high) + values[name] = value + return true +end +-- What was kept, read back over the defaults: anything that is not a setting's number is left out. +function feel.load(kept) + local values = feel.defaults() + if type(kept) == "table" then + for name, value in kept do + if type(name) == "string" then feel.set(values, name, value) end + end + end + return values +end + +-- ---- how hard a slam has to be ------------------------------------------------------------------- +-- The slam forces measured in play run from about 2 (a stumble) to 10 and more (a hard slam). +feel.heavy_slam = 10 +-- A worn board takes less: at the end of its life about half the force it took new. +feel.worn_strength = 0.5 +local function sensitivity_of(percent) return math.clamp(percent, feel.sensitivity_min, feel.sensitivity_max) / 100 end +local function strength_of(wear) return 1 - (1 - feel.worn_strength) * math.clamp(wear, 0, 1) end +-- How likely a slam of `force` breaks the board, with the player's sensitivity (percent) and the +-- board's wear (0 new, 1 worn out). A new board at 100%: never under 6, half the time at 8, always +-- from 10. A bail whose force is not known (0) never breaks it. +function feel.slam_chance(force, sensitivity, wear) + if not (force > 0) then return 0 end + return model.break_chance(force, feel.heavy_slam * strength_of(wear) / sensitivity_of(sensitivity)) +end + +-- ---- wear and tear ------------------------------------------------------------------------------- +-- A board's wear runs from 0 (new) to 1 (worn out). Every hit adds some, at the player's rate. +-- A landing, by how fast the board was coming down as it touched (m/s): nothing up to an ollie off +-- a curb's speed, then more and more. +feel.landing_free = 3.5 +function feel.landing_wear(down_speed) + local over = math.max(0, down_speed - feel.landing_free) + return math.min(0.0008 * over * over, 0.04) +end +-- Onto a rail or ledge: the board's own hit, harder on a slide (the deck takes it) than on the trucks. +function feel.rail_wear(down_speed, slide) + return feel.landing_wear(down_speed + (if slide then 1 else 0)) + (if slide then 0.001 else 0.0003) +end +-- Every second of grinding or sliding. +function feel.grind_wear_per_second(slide) return if slide then 0.0005 else 0.00015 end +-- A slam, by its force. +function feel.slam_wear(force) return math.clamp((force - 4) / 6, 0, 1) * 0.025 end +function feel.add_wear(wear, amount, rate) + return math.clamp(wear + amount * math.clamp(rate, feel.rate_min, feel.rate_max) / 100, 0, 1) +end +-- What a board's state is called. A board is "done" from `done_wear` on. +feel.done_wear = 0.85 +function feel.condition(wear) + return if wear < 0.15 then "new" elseif wear < 0.4 then "used" elseif wear < 0.65 then "worn" elseif wear < feel.done_wear then "beat" else "done" +end +-- A done board gives way under the rider on a landing (the nose goes, and they ride on): from a +-- big drop (6.5 m/s coming down) as it becomes done, down to any real drop (3.5 m/s) when it is +-- worn right out. Never a board less worn. Sensitivity as for a slam. +function feel.gives_way_on_landing(down_speed, sensitivity, wear) + if wear < feel.done_wear then return false end + local t = math.clamp((wear - feel.done_wear) / (1 - feel.done_wear), 0, 1) + return down_speed >= (6.5 - 3 * t) / sensitivity_of(sensitivity) +end + +-- ---- the board on a rail ------------------------------------------------------------------------- +-- From the board's long axis and its up axis in the world, and how it moves: a board lying across +-- its way (a boardslide, a lipslide) slides, one on its grip is in a darkslide, the rest grind on +-- their trucks. A board that hardly moves grinds. +function feel.contact_of(along, up, velocity) + if up.y < -0.35 then return "darkslide" end + local a, s = math.sqrt(along.x * along.x + along.z * along.z), math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z) + if a < 0.2 or s < 0.5 then return "grind" end + local cosine = math.abs((along.x * velocity.x + along.z * velocity.z) / (a * s)) + return if cosine < 0.5 then "slide" else "grind" +end +-- How likely a board snaps as it comes down onto a rail in a slide, by how fast it was coming down +-- (m/s): from 3 m/s up (a hop onto a rail is about 2), certain 3 m/s above that; a darkslide on the +-- grip goes sooner. Grinds on the trucks do not snap a board. Sensitivity and wear as for a slam. +function feel.rail_chance(down_speed, contact, sensitivity, wear) + if contact == "grind" then return 0 end + local s, strength = sensitivity_of(sensitivity), strength_of(wear) + local start = (if contact == "darkslide" then 2.2 else 3) * strength / s + local span = 3 * strength / s + return math.clamp((down_speed - start) / span, 0, 1) +end + +-- ---- where a board breaks ------------------------------------------------------------------------ +-- From how the board came down: nose first it breaks at the nose, tail first at the tail, flat (or +-- across a rail) in the middle. `slope` is how far the nose points down (-1 to 1); `roll` is 0 to 1, +-- chance. Flat landings still break at an end now and then, as real boards do. +function feel.place_of_break(slope, across_rail, roll) + if across_rail then return "mid" end + if slope > 0.35 then return if roll < 0.75 then "nose" else "mid" end + if slope < -0.35 then return if roll < 0.75 then "tail" else "mid" end + return if roll < 0.5 then "mid" elseif roll < 0.75 then "nose" else "tail" +end + +-- ---- a body's hit, for its sound ----------------------------------------------------------------- +-- A graze is 2 to 3 m/s along the contact's normal, a fall on flat ground 3 to 9, a drop from height +-- 13 and more. Below `body_hit` nothing is heard; the loudness rises to full at `body_full`. +feel.body_hit, feel.body_full = 3, 11 +function feel.body_loudness(speed) + if not (speed >= feel.body_hit) then return 0 end + return math.clamp(0.3 + 0.7 * (speed - feel.body_hit) / (feel.body_full - feel.body_hit), 0.3, 1) +end +return feel diff --git a/contrib/plugins/board-break/plugin/main.lua b/contrib/plugins/board-break/plugin/main.lua new file mode 100644 index 00000000..c0243a33 --- /dev/null +++ b/contrib/plugins/board-break/plugin/main.lua @@ -0,0 +1,184 @@ +-- Board Break: a hard slam can break the local skater's board. It snaps at the nose, in the middle +-- or at the tail; half the time the grip tape holds the pieces together and the board folds or an +-- end hangs, otherwise a piece comes off, flies and lies where it lands. A hard boardslide or +-- darkslide onto a rail can snap it too, and boards wear out with every hit, breaking more easily +-- as they do. The board is whole (and new) again once the skater has picked it up, rides again or +-- respawns. +-- +-- The pieces are not drawn here: they are in the deck model of the Board Break deck pack this +-- plugin comes in, four stretches of deck (nose, front, back, tail) on spare bones of the board's +-- rig (contrib/board-break.md says how such a model is made). This only moves bones, with +-- reskate.board: the stretches are kept out of sight on every board, and on a broken board the +-- real deck goes away and each stretch goes to where its piece lies. The sounds are the pack's, +-- sounds/light|medium|heavy|body/*.wav. +-- +-- The rules are in breaker.lua, the geometry in model.lua, the numbers in feel.lua. +local breaker = require("breaker") + +-- ---- sounds -------------------------------------------------------------------------------------- +-- A few of each kind: a plugin plays at most 32 different waves. A crack size with no folder of +-- its own takes the wave files in sounds/ itself. +local per_kind = 6 +local function waves(folder) + local out = {} + local ok, names = pcall(reskate.package.list, folder) + if not ok or type(names) ~= "table" then return out end + for _, name in names do + if #out < per_kind and string.lower(string.sub(name, -4)) == ".wav" then table.insert(out, folder .. "/" .. name) end + end + return out +end +local sounds = {} +local loose = waves("sounds") +for _, kind in {"light", "medium", "heavy", "body"} do + sounds[kind] = waves("sounds/" .. kind) + if #sounds[kind] == 0 and kind ~= "body" then sounds[kind] = loose end +end +local function play(kind, volume) + local list = sounds[kind] + if #list == 0 then return end + local index = math.random(1, #list) + local ok, played, why = pcall(reskate.audio.play, list[index], {volume = math.clamp(volume, 0, 1)}) + if not ok then + -- A file that is no wave the game can play: left out from now on. + reskate.log("the sound " .. list[index] .. " cannot be played: " .. tostring(played)) + table.remove(list, index) + elseif not played and why then + reskate.log("a sound was not played: " .. tostring(why)) + end +end + +-- ---- the game, as breaker.lua takes it ------------------------------------------------------------- +math.randomseed(math.floor(reskate.time.now()) % 1000003) +local game = { + now = reskate.time.now, + random = function() return math.random() end, + bones = reskate.board.bones, + velocity = function() + local motion = reskate.board.motion() + return motion and motion.velocity + end, + skater = reskate.skater.position, + raycast = reskate.world.raycast, + board = reskate.board, + sound = function(kind) return sounds[kind] ~= nil and #sounds[kind] > 0 end, + play = play, + wipeout = function(force) + local ok, why = reskate.skater.wipeout(force) + if not ok then reskate.log("the rider stays up: " .. tostring(why)) end + end, + toast = function(text, level) reskate.ui.toast(text, level) end, + log = reskate.log, + load = reskate.storage.get, + save = reskate.storage.set, +} +local bb = breaker.new(game) +bb.state = reskate.skater.state() or "none" + +reskate.on("tick", function(dt) bb:tick(dt) end) +reskate.on("skater_state", function(now, previous) bb:skater_state(now, previous) end) +reskate.on("bail", function(e) bb:bail(e.force) end) +reskate.on("board_landed", function(e) bb:landed(e.onto, e.down_speed) end) +reskate.on("body_hit", function(e) bb:body_hit(e.impact) end) +reskate.on("level_unloading", function() bb:mend("the level unloads") end) +reskate.on("unload", function() reskate.board.clear() end) + +-- A new board: no wear, and whole if it was broken. +local function new_board() + bb:mend("a new board") + bb:set_wear(0) +end +local function snap(place) + if bb.broken then return "it is broken already: mend it first." end + local carried = bb.state == "off_board" + if not bb:snap(place, {asked = true, carried = carried, why = "asked for"}) then return "there is no board." end + return "snapped" .. (if carried then " (the grip tape holds while you carry it)" else "") .. "; it stays so until mended or you respawn." +end + +-- ---- the console --------------------------------------------------------------------------------- +local function answer(name) + local function say(text) reskate.console.print(name .. ": " .. text) end + local function switch(word) + if word == "1" or word == "on" or word == "yes" then return 1 end + if word == "0" or word == "off" or word == "no" then return 0 end + return tonumber(word) + end + return function(args) + local what = args[1] + if what == nil or what == "status" then return say(bb:status()) end + local value = switch(what) + if value == 0 or value == 1 then + bb:set_on(value == 1) + return say(if bb.on then "on" else "off: boards hold; one that is broken stays so until it is picked up") + end + if what == "snap" then + local place = args[2] + if place ~= nil and place ~= "nose" and place ~= "mid" and place ~= "tail" then return say("snap nose, mid, tail, or nothing for anywhere") end + return say(snap(place)) + elseif what == "mend" then + return say(if bb:mend("asked for") then "mended" else "the board is whole") + elseif what == "new" then + new_board() + return say("a new board") + elseif what == "worn" then + if args[2] then + local percent = tonumber(args[2]) + if not percent then return say("give a percent, 0 to 100") end + bb:set_wear(percent / 100) + end + return say(string.format("the board is %s, %d%% worn", require("feel").condition(bb.wear), math.floor(bb.wear * 100 + 0.5))) + elseif what == "test" then + local passed, failures = require("tests").run() + for _, failure in failures do say("FAIL " .. failure) end + return say(string.format("%d passed, %d failed", passed, #failures)) + elseif bb:setting(what) ~= nil then + if args[2] then + local number = switch(args[2]) + if not number or not bb:set_setting(what, number) then return say("give a number (or on/off)") end + end + return say(what .. " is " .. bb:setting(what)) + end + say("say " .. name .. " 0|1, status, snap [nose|mid|tail], mend, new, worn [percent], or a setting: " .. + "always, sensitivity, joined, rails, wear, wearrate, cracks, bodysounds") + end +end +local help = "A hard slam can snap your board (needs the Board Break deck pack): 0|1, status, snap, mend, new, worn, settings" +-- While ReSkate has a Board Break of its own, boardbreak is its command: this one answers to board break. +local name = "boardbreak" +if not pcall(reskate.console.command, name, help, answer(name)) then + name = "board break" + reskate.console.command(name, help, answer(name)) +end + +-- ---- the menu (the ADDONS page's PLUGINS tab) ---------------------------------------------------- +local function toggle(ui, id, text) + local on = ui:toggle(id, text, bb:setting(id) == 1) + if on ~= (bb:setting(id) == 1) then bb:set_setting(id, if on then 1 else 0) end +end +local function slider(ui, id, text, low, high) + local value = math.floor(ui:slider(id, text, bb:setting(id), low, high) + 0.5) + if value ~= bb:setting(id) then bb:set_setting(id, value) end +end +reskate.menu.panel("Board Break", function(ui) + local on = ui:toggle("on", "Board Break", bb.on) + if on ~= bb.on then bb:set_on(on) end + local feel = require("feel") + ui:label(string.format("The board is %s, %d%% worn%s.", feel.condition(bb.wear), math.floor(bb.wear * 100 + 0.5), + if bb.broken then ", and broken" else "")) + ui:bar("Wear", bb.wear * 100, 0, 100) + if ui:button("new", "NEW BOARD") then new_board() end + ui:separator() + toggle(ui, "always", "Every slam breaks it (for clips)") + toggle(ui, "rails", "Rail snaps") + toggle(ui, "wear", "Wear and tear") + slider(ui, "sensitivity", "Sensitivity (%)", feel.sensitivity_min, feel.sensitivity_max) + slider(ui, "joined", "Grip tape holds (% of breaks)", 0, 100) + slider(ui, "wearrate", "Wear rate (%)", feel.rate_min, feel.rate_max) + toggle(ui, "cracks", "Crack sounds") + toggle(ui, "bodysounds", "Body hit sounds") + ui:separator() + if ui:button("snap", "SNAP IT, TO LOOK AT") then snap(nil) end + if ui:button("mend", "MEND") then bb:mend("asked for") end +end) + +reskate.log(string.format("Board Break loaded (%s): %s", name, bb:status())) diff --git a/contrib/plugins/board-break/plugin/model.lua b/contrib/plugins/board-break/plugin/model.lua new file mode 100644 index 00000000..ef56af69 --- /dev/null +++ b/contrib/plugins/board-break/plugin/model.lua @@ -0,0 +1,213 @@ +-- Board Break's geometry, apart from the game: where a board breaks, what is left of it, and where +-- each part of a broken board lies. Everything is measured on the deck, in the deck model's own +-- axes: metres, y up, z along the board with the nose at +z. (Ported from ReSkate's +-- Extension/BoardBreak/board_break_model.h.) +local model = {} + +-- ---- rigid motions ------------------------------------------------------------------------------- +-- Positions are vectors; quaternions are {x, y, z, w} (Hamilton), as reskate.board has them. +local function v(x, y, z) return vector.create(x, y, z) end +model.vec = v +local function cross(a, b) return v(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x) end +local function length(a) return math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z) end +model.length = length +local function identity() return {0, 0, 0, 1} end +model.identity = identity +-- b first, then a. +local function mul(a, b) + return {a[4] * b[1] + a[1] * b[4] + a[2] * b[3] - a[3] * b[2], a[4] * b[2] - a[1] * b[3] + a[2] * b[4] + a[3] * b[1], + a[4] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[4], a[4] * b[4] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3]} +end +local function conj(q) return {-q[1], -q[2], -q[3], q[4]} end +local function unit(q) + local n = math.sqrt(q[1] * q[1] + q[2] * q[2] + q[3] * q[3] + q[4] * q[4]) + if n > 1e-6 then return {q[1] / n, q[2] / n, q[3] / n, q[4] / n} end + return identity() +end +local function about(axis, radians) + local s = math.sin(radians / 2) + return {axis.x * s, axis.y * s, axis.z * s, math.cos(radians / 2)} +end +local function turn(q, p) + local u = v(q[1], q[2], q[3]) + local c = cross(u, p) + return p + c * (2 * q[4]) + cross(u, c) * 2 +end +model.mul, model.conj, model.unit, model.about, model.turn = mul, conj, unit, about, turn +-- Turned, then shifted. +local function rigid(facing, at) return {facing = facing or identity(), at = at or v(0, 0, 0)} end +model.rigid = rigid + +-- ---- breaks -------------------------------------------------------------------------------------- +-- A deck breaks across one of three rows: just ahead of the front bolts, in the middle, just behind +-- the back bolts. That cuts it into four stretches; each truck rides on the stretch its bolts are in. +model.nose, model.front, model.back, model.tail, model.front_truck, model.back_truck = 1, 2, 4, 8, 16, 32 +model.whole_board = 63 +model.stretches = 15 +model.places = {"nose", "mid", "tail"} +model.nose_row, model.mid_row, model.tail_row = 0.260, 0.009, -0.260 +local function has(set, piece) return bit32.band(set, piece) ~= 0 end +model.has = has + +-- What a break leaves: the piece the board keeps, the piece that comes off and flies (0 when the +-- grip tape holds), and how the two hang together: "none", or with the nose hanging ("nose"), the +-- board folded in the middle ("fold"), the tail hanging ("tail"). +function model.broken_at(place, joined) + if joined then + return {kept = model.whole_board, loose = 0, hinge = if place == "nose" then "nose" elseif place == "mid" then "fold" else "tail"} + end + -- A clean break: the board keeps the larger piece, and of two halves the front one. + if place == "nose" then return {kept = model.whole_board - model.nose, loose = model.nose, hinge = "none"} end + if place == "mid" then + return {kept = model.nose + model.front + model.front_truck, loose = model.back + model.tail + model.back_truck, hinge = "none"} + end + return {kept = model.whole_board - model.tail, loose = model.tail, hinge = "none"} +end +-- How big a break is, for its sound. An end that hangs on by the grip tape is a small one; an end +-- that comes off, or a board that folds a little, is in between; a board that folds right down or +-- comes apart in the middle is the biggest. `fold_depth` is 0 to 1, how far a fold sags. +function model.break_size(place, joined, fold_depth) + if place == "mid" then return if not joined or fold_depth >= 0.7 then "heavy" else "medium" end + return if joined then "light" else "medium" +end +-- How likely a slam of `force` breaks the board: never up to 60% of `heavy`, always from `heavy` up, +-- rising smoothly in between. +function model.break_chance(force, heavy) + if heavy <= 0 or force >= heavy then return 1 end + local low = 0.6 * heavy + if force <= low then return 0 end + local t = (force - low) / (heavy - low) + return t * t * (3 - 2 * t) +end + +-- ---- where the parts of a broken board lie ------------------------------------------------------- +model.axle_height, model.front_axle, model.back_axle = 0.028, 0.2226, -0.2225 +-- How far each half of a board tips: a touch short of where its first splinter meets the ground. +model.front_tip, model.back_tip = 0.340, 0.315 +-- Half a board on one truck tips about its axle until the broken end lies on the ground. `towards` +-- is +1 when the broken end is on the nose side of that axle. +function model.tipped_on_axle(axle_z, towards, angle) + local turned = about(v(1, 0, 0), towards * angle) + local axle = v(0, model.axle_height, axle_z) + return rigid(turned, axle - turn(turned, axle)) +end +-- A piece that hangs on by the grip tape: turned about the break line just under the deck's top, +-- and drawn a hair away from the board, so the tape opens into a ragged crack. +model.crack, model.grip_height = 0.0025, 0.0935 +function model.hung_on_grip(break_z, towards, angle) + local turned = about(v(1, 0, 0), towards * angle) + local hinge = v(0, model.grip_height - 0.004, break_z) + return rigid(turned, hinge - turn(turned, hinge) + v(0, 0, towards * model.crack * 0.5)) +end +-- A board folded in the middle: each half turns about its own axle, the front by `depth` of the +-- way to the ground and the back as far as brings its grip to the same height, and the two move +-- along the board until the grip of both all but meets again at the break. The front half, then the back. +function model.folded(depth) + local grip_y = 0.095 + local rise, ahead, behind = grip_y - model.axle_height, model.front_axle - model.mid_row, model.mid_row - model.back_axle + local a = model.front_tip * math.clamp(depth, 0, 1) + local height = rise * math.cos(a) - ahead * math.sin(a) + local b = math.acos(math.clamp(height / math.sqrt(rise * rise + behind * behind), -1, 1)) - math.atan2(behind, rise) + local front_half = model.tipped_on_axle(model.front_axle, -1, a) + local back_half = model.tipped_on_axle(model.back_axle, 1, b) + local front_end = model.front_axle - (rise * math.sin(a) + ahead * math.cos(a)) + local back_end = model.back_axle + (rise * math.sin(b) + behind * math.cos(b)) + local shift = (back_end - front_end + model.crack) * 0.5 -- and a crack's width more: the tape gapes + front_half.at += v(0, 0, shift) + back_half.at -= v(0, 0, shift) + return front_half, back_half +end +-- The deck sits on the board's rig a quarter turn about the upright axis (the rig's mount carries +-- it), and the stretches hang on the rig's root: a motion worked out on the deck, carried over. +function model.on_root(mount, motion) + return rigid(unit(mul(mul(mount, motion.facing), conj(mount))), turn(mount, motion.at)) +end + +-- Where something goes that is not to be seen: far below the board. +model.away = v(0, -2000, 0) +-- A broken board as it lies, in the rig root's axes: its four stretches (nose, front, back, tail, +-- in that order everywhere), each {shown, motion}, and its trucks. +-- `kept` is the piece the board keeps, hanging together as `hinge` says. `angle` is how far an end +-- droops (radians) or how deep a fold sags (0 to 1); `up` is how far the board is the right way up +-- (1 on its wheels, -1 on its grip); `mount` is the rig's mount turn. +function model.lay_out(kept, hinge, angle, up, mount) + local ahead, rear, hung = rigid(), rigid(), rigid() + if hinge == "fold" then + -- The break sags towards the ground under the board. On its grip the fold would push nose + -- and tail into the ground instead, so there it stays shallow. + local level = math.clamp((up + 0.2) / 0.8, 0, 1) + ahead, rear = model.folded(angle * (0.3 + 0.7 * level)) + elseif hinge == "nose" then + hung = model.hung_on_grip(model.nose_row, 1, angle) + elseif hinge == "tail" then + hung = model.hung_on_grip(model.tail_row, -1, angle) + elseif has(kept, model.front_truck) ~= has(kept, model.back_truck) then + -- Half a deck on one truck: while the board is the right way up it tips onto its broken end. + local level = math.clamp((up - 0.3) / 0.5, 0, 1) + local amount = level * level * (3 - 2 * level) + ahead = if has(kept, model.front_truck) then model.tipped_on_axle(model.front_axle, -1, model.front_tip * amount) + else model.tipped_on_axle(model.back_axle, 1, model.back_tip * amount) + end + ahead = model.on_root(mount, ahead) + hung = model.on_root(mount, hung) + rear = if hinge == "fold" then model.on_root(mount, rear) else ahead + return { + stretch = { + {shown = has(kept, model.nose), motion = if hinge == "nose" then hung else ahead}, + {shown = has(kept, model.front), motion = ahead}, + {shown = has(kept, model.back), motion = rear}, + {shown = has(kept, model.tail), motion = if hinge == "fold" then rear elseif hinge == "tail" then hung else ahead}, + }, + front_truck = has(kept, model.front_truck), + back_truck = has(kept, model.back_truck), + front_motion = ahead, + back_motion = rear, + } +end +-- What goes into the stretches' bones. The first three hang on the rig's root; the tail's hangs on +-- the front stretch's, so its place is given from there. A stretch that does not show goes away. +function model.bones_of(stretch) + local motion = {} + for i = 1, 4 do motion[i] = if stretch[i].shown then stretch[i].motion else rigid(identity(), model.away) end + local from, to = motion[2], motion[4] + local back = conj(from.facing) + return {motion[1], motion[2], motion[3], rigid(unit(mul(back, to.facing)), turn(back, to.at - from.at))} +end +-- A truck hangs on the deck's bone, which is moved `deck_away` to hide the real deck, and that bone +-- sits on the mount. To move the truck with a piece: the piece's motion seen from the deck, the +-- deck's move taken back. `steer` is the truck's own turn, which it keeps; `place` where it sits on the deck. +model.back_truck_place, model.front_truck_place = v(0.0005, -0.0183, -0.2345), v(0.0005, -0.0183, 0.2347) +-- The real deck goes this far down: reskate.board.set takes a bone at most 1000 m from its parent, +-- and the trucks, which hang on the deck, come back up by as much. +model.deck_away = v(0, -950, 0) +function model.truck_bone(motion, mount, mount_at, steer, place) + local facing = unit(mul(mul(conj(mount), motion.facing), mount)) + local at = turn(conj(mount), turn(motion.facing, mount_at) + motion.at - mount_at) + return rigid(unit(mul(facing, steer)), turn(facing, place) + at - model.deck_away) +end +-- The trucks' baseplates. A deck pack's truck model has each baseplate on its truck's effector, a +-- bone under the truck's: this is where that effector goes so that the baseplate sits on the deck +-- however the truck steers. `truck` is the truck's bone as the animation left it. +model.back_truck_rest = {facing = {-0.3641458, -0.3641458, -0.6061335, 0.6061335}, at = v(0.0005361, -0.0183003, -0.2344986), effector = v(0.0395021, 0, 0)} +model.front_truck_rest = {facing = {-0.6061674, 0.6061674, 0.3640894, 0.3640894}, at = v(0.0005361, -0.0182917, 0.2346680), effector = v(0.0395677, 0, 0)} +function model.baseplate_bone(truck, rest) + local back = conj(truck.facing) + return rigid(unit(mul(back, rest.facing)), turn(back, rest.at + turn(rest.facing, rest.effector) - truck.at)) +end + +-- ---- the piece that comes off -------------------------------------------------------------------- +-- Its kick (m/s) and spin (axis times rad/s) in the deck's own axes, and how far above the ground +-- the rig's origin lies once the piece rests: a bare piece lies on its underside. `roll(low, high)` +-- draws a number. (The plugin cannot carry a loose truck: board.toss writes bones relative to the +-- board's place, and a truck hangs on the deck, so a piece that comes off goes without its truck.) +function model.flight(loose, roll) + local loose_end = not has(loose, model.front_truck + model.back_truck) -- a nose or tail, no truck on it + local along = if has(loose, model.nose) then 1 else -1 -- the way it leaves, along the board + local push = if loose_end then 1 else 0.55 + return { + kick = v(roll(-0.9, 0.9) * push, (1.4 + roll(0, 1.2)) * push, along * (1.2 + roll(0, 1.6)) * push), + spin = v(along * (5 + roll(0, 6)) * push, roll(-3, 3) * push, roll(-2, 2) * push), + rest = -0.083 + 0.012, -- on its underside, a little above: real ground is rough + } +end +return model diff --git a/contrib/plugins/board-break/plugin/tests.lua b/contrib/plugins/board-break/plugin/tests.lua new file mode 100644 index 00000000..5de78020 --- /dev/null +++ b/contrib/plugins/board-break/plugin/tests.lua @@ -0,0 +1,457 @@ +-- Checks of Board Break, run by `boardbreak test` (and by dingosdk_plugin_tests, without the game): +-- the geometry and the numbers as ReSkate's own Board Break has them (its board_break_tests.cpp), +-- then the rules at play, with a game faked here. +local model = require("model") +local feel = require("feel") +local breaker = require("breaker") +local v = model.vec + +local checks = {} +local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end +local function near(a, b, within) + within = within or 1e-4 + if type(a) == "number" then return math.abs(a - b) < within end + return model.length(a - b) < within +end +-- Where a point of a part ends up after the part's motion. +local function moved(motion, point) return model.turn(motion.facing, point) + motion.at end +-- The rig's mount: a quarter turn about the upright axis. +local mount = model.about(v(0, 1, 0), 1.5707963) + +-- ---- the geometry -------------------------------------------------------------------------------- +check("a break leaves two pieces, or one that hangs together", function() + for _, place in model.places do + local clean = model.broken_at(place, false) + assert(clean.hinge == "none" and clean.loose ~= 0, "a clean break has a piece that comes off") + assert(bit32.bor(clean.kept, clean.loose) == model.whole_board and bit32.band(clean.kept, clean.loose) == 0, "each part once") + local joined = model.broken_at(place, true) + assert(joined.kept == model.whole_board and joined.loose == 0 and joined.hinge ~= "none", "held by the grip tape, nothing comes off") + end + assert(model.broken_at("nose", false).loose == model.nose and model.broken_at("tail", false).loose == model.tail) + local halves = model.broken_at("mid", false) + assert(halves.kept == model.nose + model.front + model.front_truck and halves.loose == model.back + model.tail + model.back_truck) + assert(model.broken_at("nose", true).hinge == "nose" and model.broken_at("mid", true).hinge == "fold" and model.broken_at("tail", true).hinge == "tail") +end) +check("a harder slam breaks a board more often", function() + assert(model.break_chance(10, 10) == 1 and model.break_chance(30, 10) == 1) + assert(model.break_chance(0, 10) == 0 and model.break_chance(6, 10) == 0) + assert(near(model.break_chance(8, 10), 0.5)) + local before = 0 + for force = 0, 12, 0.5 do + local chance = model.break_chance(force, 10) + assert(chance >= before and chance <= 1, "it never falls with more force") + before = chance + end +end) +check("the size of a break picks its sound", function() + assert(model.break_size("mid", false, 0) == "heavy") + assert(model.break_size("mid", true, 0.9) == "heavy" and model.break_size("mid", true, 0.4) == "medium") + assert(model.break_size("nose", false, 0) == "medium" and model.break_size("tail", false, 0) == "medium") + assert(model.break_size("nose", true, 0) == "light" and model.break_size("tail", true, 0) == "light") +end) +local function hidden_stretches() + local s = {} + for i = 1, 4 do s[i] = {shown = false, motion = model.rigid()} end + return s +end +check("a whole board keeps its stretches away", function() + local bones = model.bones_of(hidden_stretches()) + for i = 1, 3 do assert(near(bones[i].at, model.away), "a stretch that does not show goes far away") end + assert(near(bones[4].at, v(0, 0, 0)) and near(bones[4].facing[4], 1), "and the tail's goes with the front's") +end) +check("the tail is placed from the front stretch", function() + local front_motion = model.rigid(model.about(v(1, 0, 0), 0.3), v(0.01, 0.02, -0.03)) + local tail_motion = model.rigid(model.unit(model.mul(model.about(v(0, 1, 0), 1.1), model.about(v(1, 0, 0), -0.4))), v(0.5, -0.2, 0.7)) + for _, front_shown in {true, false} do + local stretch = hidden_stretches() + stretch[2] = {shown = front_shown, motion = front_motion} + stretch[4] = {shown = true, motion = tail_motion} + local bones = model.bones_of(stretch) + local parent = if front_shown then front_motion else model.rigid(model.identity(), model.away) + for _, point in {v(0, 0.09, -0.3), v(0.1, 0.1, -0.4)} do + assert(near(moved(parent, moved(bones[4], point)), moved(tail_motion, point), 2e-3), "the tail lies where its piece does") + end + end +end) +check("a piece shows only its own stretches, and a half tips onto its broken end", function() + local clean = model.broken_at("mid", false) + local layout = model.lay_out(clean.kept, clean.hinge, 0, 1, mount) + local s = layout.stretch + assert(s[1].shown and s[2].shown and not s[3].shown and not s[4].shown, "the front half is the nose and the front stretch") + assert(layout.front_truck and not layout.back_truck, "on its front truck") + assert(near(model.bones_of(s)[3].at, model.away), "and the back stretch is nowhere to be seen") + local broken_end = moved(s[2].motion, model.turn(mount, v(0, 0.09, model.mid_row))) + assert(broken_end.y < 0.09 - 0.03, "the half tips onto its broken end") + local upside_down = model.lay_out(clean.kept, clean.hinge, 0, -1, mount) + assert(near(upside_down.stretch[2].motion.at, v(0, 0, 0)), "lying on its grip it stays as it is") +end) +check("a folded board meets at the break", function() + for _, depth in {0.2, 0.6, 1.0} do + local front_half, back_half = model.folded(depth) + local grip = v(0, 0.095, model.mid_row) + local ahead, behind = moved(front_half, grip), moved(back_half, grip) + assert(near(ahead.y, behind.y, 1e-3), "both halves' grip is at one height at the break") + assert(near(ahead.z - behind.z, model.crack, 1e-3), "with the crack between them") + assert(ahead.y < grip.y, "and the break has sagged") + assert(near(moved(front_half, v(0, model.axle_height, model.front_axle)).y, model.axle_height) and + near(moved(back_half, v(0, model.axle_height, model.back_axle)).y, model.axle_height), "the axles keep their height") + end + local whole = model.lay_out(model.whole_board, "fold", 0.8, 1, mount) + for _, one in whole.stretch do assert(one.shown, "a folded board shows all four stretches") end + assert(not near(whole.stretch[2].motion.at, whole.stretch[3].motion.at), "its two halves moved apart") + assert(near(whole.stretch[1].motion.at, whole.stretch[2].motion.at) and near(whole.stretch[3].motion.at, whole.stretch[4].motion.at)) +end) +check("a hanging end turns about the break", function() + local layout = model.lay_out(model.whole_board, "nose", 0.5, 1, mount) + for i = 2, 4 do assert(near(layout.stretch[i].motion.at, v(0, 0, 0)), "the rest of the board stays where it is") end + local tip = moved(layout.stretch[1].motion, model.turn(mount, v(0, 0.14, 0.41))) + assert(tip.y < 0.14 - 0.05, "and the nose droops") + local hinge = moved(model.hung_on_grip(model.nose_row, 1, 0.5), v(0, model.grip_height - 0.004, model.nose_row)) + assert(near(hinge, v(0, model.grip_height - 0.004, model.nose_row + model.crack * 0.5)), "about the break line, a hair away") +end) +check("a truck goes with its piece, from under the hidden deck", function() + local mount_at = v(0, 0.0856, 0) + local still = model.truck_bone(model.rigid(), mount, mount_at, model.identity(), model.front_truck_place) + assert(near(still.at + model.deck_away, model.front_truck_place) and near(still.facing[4], 1), "a piece that has not moved leaves its truck") + assert(model.length(still.at) < 1000, "and reskate.board.set takes it") + local up = model.truck_bone(model.rigid(nil, v(0, 0.25, 0)), mount, mount_at, model.identity(), model.front_truck_place) + assert(near(up.at + model.deck_away, model.front_truck_place + v(0, 0.25, 0)), "a piece that is lifted takes its truck up") +end) +check("a baseplate stays on the deck while its truck steers", function() + for _, rest in {model.back_truck_rest, model.front_truck_rest} do + local still = model.baseplate_bone(model.rigid(rest.facing, rest.at), rest) + assert(near(still.at, rest.effector) and near(math.abs(still.facing[4]), 1), "at rest the effector is where the rig has it") + local bolt = v(0.03, 0.08, rest.at.z + 0.02) + local on_deck = moved(model.rigid(rest.facing, rest.at), moved(model.rigid(nil, rest.effector), bolt)) + for _, lean in {-0.4, 0.15, 0.6} do + local steered = model.rigid(model.unit(model.mul(rest.facing, model.about(v(0, 0, 1), lean))), rest.at) + assert(near(moved(steered, moved(model.baseplate_bone(steered, rest), bolt)), on_deck), "steered, the baseplate has not moved") + end + end +end) + +-- ---- the numbers --------------------------------------------------------------------------------- +check("the settings keep to their ranges", function() + local s = feel.defaults() + assert(feel.set(s, "sensitivity", 9999) and s.sensitivity == feel.sensitivity_max) + assert(feel.set(s, "always", 5) and s.always == 1) + assert(feel.set(s, "joined", -3) and s.joined == 0) + assert(not feel.set(s, "nonsense", 1) and not feel.set(s, "joined", 0 / 0)) + local kept = feel.load({sensitivity = 150, joined = "lots", nonsense = 3}) + assert(kept.sensitivity == 150 and kept.joined == 50 and kept.nonsense == nil, "what was kept is read back, the rest is the default") +end) +check("a worn or touchy board breaks sooner", function() + assert(feel.slam_chance(feel.heavy_slam, 100, 0) == 1) + assert(feel.slam_chance(6, 100, 0) < feel.slam_chance(6, 100, 1)) + assert(feel.slam_chance(feel.heavy_slam * 0.5, 200, 0) == 1) + assert(feel.slam_chance(8, 50, 0) < feel.slam_chance(8, 100, 0)) + assert(feel.slam_chance(2, 100, 0) == 0 and feel.slam_chance(2, 100, 0.6) == 0) + assert(feel.slam_chance(0, 400, 1) == 0, "a bail whose force is not known never breaks it") +end) +check("every hit wears a board", function() + assert(feel.landing_wear(3) == 0 and feel.landing_wear(5) > 0 and feel.landing_wear(9) > feel.landing_wear(5)) + assert(feel.rail_wear(3, true) > feel.rail_wear(3, false)) + assert(feel.grind_wear_per_second(true) > feel.grind_wear_per_second(false)) + assert(feel.slam_wear(2) == 0 and feel.slam_wear(12) > feel.slam_wear(7)) + assert(feel.add_wear(0.5, 0.1, 0) == 0.5 and near(feel.add_wear(0.5, 0.1, 200), 0.7) and feel.add_wear(0.95, 1, 100) == 1) +end) +check("a done board gives way on a landing", function() + assert(not feel.gives_way_on_landing(20, 400, feel.done_wear - 0.01)) + assert(not feel.gives_way_on_landing(6, 100, feel.done_wear) and feel.gives_way_on_landing(6.5, 100, feel.done_wear)) + assert(not feel.gives_way_on_landing(3, 100, 1) and feel.gives_way_on_landing(3.5, 100, 1)) + assert(feel.gives_way_on_landing(4, 200, feel.done_wear) and not feel.gives_way_on_landing(4, 100, feel.done_wear)) + assert(feel.condition(0) == "new" and feel.condition(0.5) == "worn" and feel.condition(0.9) == "done") +end) +check("a board across its way slides", function() + local level_up, ahead, across, upside_down, going = v(0, 1, 0), v(0, 0, 1), v(1, 0, 0), v(0, -1, 0), v(0, -1, 5) + assert(feel.contact_of(ahead, level_up, going) == "grind") + assert(feel.contact_of(across, level_up, going) == "slide") + assert(feel.contact_of(across, upside_down, going) == "darkslide") + assert(feel.contact_of(across, level_up, v(0, -2, 0.1)) == "grind") + assert(feel.rail_chance(9, "grind", 100, 0) == 0) + assert(feel.rail_chance(2, "slide", 100, 0) == 0 and feel.rail_chance(6, "slide", 100, 0) == 1) + assert(feel.rail_chance(3.5, "darkslide", 100, 0) > feel.rail_chance(3.5, "slide", 100, 0)) +end) +check("a board breaks where it hit, and hits sound by how hard", function() + assert(feel.place_of_break(0.8, false, 0.1) == "nose" and feel.place_of_break(-0.8, false, 0.1) == "tail") + assert(feel.place_of_break(0.8, true, 0.1) == "mid") + assert(feel.place_of_break(0, false, 0.2) == "mid" and feel.place_of_break(0, false, 0.9) == "tail") + assert(feel.body_loudness(2) == 0 and feel.body_loudness(feel.body_full) == 1 and feel.body_loudness(5) < feel.body_loudness(9)) +end) + +-- ---- the rules at play, with a faked game ------------------------------------------------------- +-- A board at rest on flat ground at (10, 0, 5), the right way up, its nose towards +x; the skater +-- 3 m away. `rolls` are the random numbers it draws, in order (0.5 once they run out). +local function fake(stored) + local g = {t = 1000, rolls = {}, stored = stored or {}, set = {}, hidden = {}, tossed = nil, cleared = 0, + played = {}, wipeouts = {}, toasts = {}, logs = {}, sounds = {light = true, medium = true, heavy = true, body = true}, + velocity_now = v(0, 0, 0), skater_at = v(13, 0, 5), floor = nil, refuse = nil} + local bones = {} + for i = 0, 15 do bones[i] = {position = v(0, 0, 0), rotation = model.identity(), scale = v(1, 1, 1)} end + bones[1].position = v(10, 0, 5) + bones[2] = {position = v(0, 0.0856, 0), rotation = mount, scale = v(1, 1, 1)} + bones[4] = {position = model.back_truck_rest.at, rotation = model.back_truck_rest.facing, scale = v(1, 1, 1)} + bones[8] = {position = model.front_truck_rest.at, rotation = model.front_truck_rest.facing, scale = v(1, 1, 1)} + g.bones_now = bones + g.now = function() return g.t end + g.random = function() return table.remove(g.rolls, 1) or 0.5 end + g.bones = function() return g.bones_now end + g.velocity = function() return g.velocity_now end + g.skater = function() return g.skater_at end + g.raycast = function(from) return if g.floor then v(from.x, g.floor, from.z) else nil end + g.board = { + set = function(bone, value) + if g.refuse then return false, g.refuse end + assert(model.length(value.position) <= 1000, "board.set refuses a bone over 1000 m out") + g.set[bone] = value + return true + end, + hide = function(bone) g.hidden[bone] = true; return true end, + toss = function(list, options) g.tossed = {list = list, options = options}; return true end, + clear = function() g.cleared += 1; g.set, g.hidden, g.tossed = {}, {}, nil end, + park = function(list) g.parked = list end, + } + g.sound = function(kind) return g.sounds[kind] == true end + g.play = function(kind, volume) table.insert(g.played, string.format("%s %.2f", kind, volume)) end + g.wipeout = function(force) table.insert(g.wipeouts, force) end + g.toast = function(text) table.insert(g.toasts, text) end + g.log = function(text) table.insert(g.logs, text) end + g.load = function(key) return g.stored[key] end + g.save = function(key, value) g.stored[key] = value end + g.run = function(bb, ms) -- ticks of 50 ms + for _ = 1, math.ceil(ms / 50) do + g.t += 50 + bb:tick(50) + end + end + return g +end +local function rider(g) + local bb = breaker.new(g) + bb:skater_state("riding", "none") + g.run(bb, 100) -- rolling: the ground under the board is known + return bb +end +local function wiped(bb, g) + bb:skater_state("wipeout", "riding") +end + +check("the stretches are kept out of sight on every board from the start", function() + local g = fake() + breaker.new(g) + assert(g.parked and #g.parked == 4 and table.find(g.parked, 12) and table.find(g.parked, 15)) +end) +check("a heavy slam breaks the board in the middle, and the back half flies off", function() + local g = fake() + local bb = rider(g) + wiped(bb, g) + -- breaks; the middle (flat); the grip tape does not hold; then the angle and the flight + g.rolls = {0, 0.1, 0.9} + bb:bail(12) + assert(bb.broken and bb.broken.place == "mid" and not bb.broken.joined, "it broke in the middle") + assert(g.tossed and table.concat(g.tossed.list, ",") == "15,14", "the back and tail stretches fly") + assert(g.tossed.options.velocity.y > 1 and near(g.tossed.options.ground, -0.071), "up, and down onto the ground it rolled on") + assert(g.hidden[4] and not g.hidden[8], "the back truck goes, the front one stays") + assert(near(g.set[3].position, v(0, -950, 0)), "the real deck goes away") + assert(near(g.set[8].position + model.deck_away, model.front_truck_place, 0.01), "the front truck stays where it was") + assert(g.set[12] and g.set[13], "the front half is drawn") + assert(near(g.set[15].position, v(0, 0, 0)) and near(g.set[14].position, v(0, 0, 0)), "the flying stretches leave from their place in the board") + assert(near(g.set[13].position, v(0, 0, 0)), "and lies level while its tail flies") + assert(g.played[1] == "heavy 1.00", tostring(g.played[1])) + assert(near(bb.wear, feel.slam_wear(12)), "a slam wears the board") +end) +check("held by the grip tape, it folds and nothing flies", function() + local g = fake() + local bb = rider(g) + wiped(bb, g) + g.rolls = {0, 0.1, 0.2, 1} + bb:bail(12) + assert(bb.broken.joined and bb.broken.parts.hinge == "fold" and g.tossed == nil) + for _, bone in {12, 13, 14, 15} do assert(g.set[bone], "stretch " .. bone .. " is drawn") end + assert(g.set[4] and g.set[8] and g.set[5] and g.set[9], "both trucks and their baseplates come back") + assert(g.played[1] == "heavy 1.00", "a deep fold is a big break") +end) +check("light slams and bails of no known force do not break a board", function() + local g = fake() + local bb = rider(g) + g.rolls = {0} + bb:bail(3) + assert(not bb.broken and g.tossed == nil and #g.played == 0) + g.rolls = {0} + bb:bail(nil) + assert(not bb.broken) + bb:set_setting("always", 1) + g.rolls = {0, 0.1, 0.1} + bb:bail(nil) + assert(bb.broken, "set to always break, any bail does") +end) +check("on foot with the board the grip tape always holds", function() + local g = fake() + local bb = rider(g) + bb:skater_state("off_board", "riding") + bb:skater_state("wipeout", "off_board") + g.rolls = {0, 0.9} + bb:bail(12) + assert(bb.broken and bb.broken.joined and g.tossed == nil) +end) +check("a slide that comes down hard onto a rail snaps the board and throws the rider", function() + local g = fake() + local bb = rider(g) + g.t += 1000 -- in the air since: the ground it rolled on is old news + g.floor = -1.2 + bb:skater_state("grinding", "air") + g.velocity_now = v(0, -6, 4) -- the board's nose is towards +x: across its way + g.rolls = {0, 0.9} + bb:landed("rail", 6) + assert(bb.broken and bb.broken.place == "mid", "it snapped in the middle") + assert(#g.wipeouts == 1 and g.wipeouts[1] == 2.5, "and the rider went down") + assert(near(g.tossed.options.ground, -1.2 - 0.071), "the piece falls to the ground under the rail") +end) +check("a grind on the trucks wears the board and never snaps it", function() + local g = fake() + local bb = rider(g) + g.velocity_now = v(6, -6, 0) -- along the board + g.rolls = {0} + bb:landed("rail", 9) + assert(not bb.broken and #g.wipeouts == 0) + assert(near(bb.wear, feel.rail_wear(9, false))) + bb:skater_state("grinding", "air") + local before = bb.wear + g.run(bb, 1000) + assert(near(bb.wear - before, feel.grind_wear_per_second(false), 1e-5), "a second of grinding wears it a little") + bb:set_setting("rails", 0) + g.velocity_now = v(0, -6, 4) + bb:landed("rail", 9) + assert(not bb.broken, "rail snaps off: a slide does not snap it either") +end) +check("a done board gives way on a big drop and is ridden on until the next fall", function() + local g = fake() + local bb = rider(g) + bb:set_wear(0.9) + bb:landed("ground", 4) + assert(not bb.broken, "a small drop holds") + g.rolls = {0.9} + bb:landed("ground", 6.5) + assert(bb.broken and bb.broken.place == "nose" and bb.broken.gave_way) + g.run(bb, 3000) + assert(bb.broken, "ridden, it stays broken") + bb:skater_state("wipeout", "riding") + g.run(bb, 1000) + bb:skater_state("riding", "off_board") + g.run(bb, 1000) + assert(not bb.broken and bb.wear == 0, "after a fall, riding again it is a new board") +end) +check("the board is whole again once it is back in hand", function() + local g = fake() + local bb = rider(g) + wiped(bb, g) + g.rolls = {0, 0.1, 0.1} + bb:bail(12) + g.run(bb, 2000) + assert(bb.broken, "lying there, it stays broken") + bb:skater_state("off_board", "wipeout") + g.run(bb, 1000) + assert(bb.broken, "the skater has not picked it up yet") + -- Picked up: close to the skater, on its edge. + g.bones_now[1] = {position = v(12.5, 0.8, 5), rotation = model.about(v(1, 0, 0), 1.5707963), scale = v(1, 1, 1)} + g.run(bb, 200) + assert(bb.broken, "not at once") + g.run(bb, 300) + assert(not bb.broken and g.cleared >= 1 and bb.wear == 0, "a moment later it is a new board") + assert(g.parked and #g.parked == 4, "and the stretches are kept away again") +end) +check("a respawn mends it, and so does the board gone for long", function() + local g = fake() + local bb = rider(g) + wiped(bb, g) + g.rolls = {0, 0.1, 0.1} + bb:bail(12) + g.run(bb, 400) + bb:skater_state("none", "wipeout") + assert(not bb.broken, "a respawn") + bb:skater_state("riding", "none") + g.rolls = {0, 0.1, 0.1} + bb:bail(12) + local board = g.bones_now + g.bones_now = nil + g.run(bb, 300) + assert(bb.broken, "out of sight for a moment it stays broken") + g.run(bb, 400) + assert(not bb.broken, "gone for longer, the next board is whole") + g.bones_now = board +end) +check("one snapped to look at stays until it is mended, and keeps its wear", function() + local g = fake() + local bb = rider(g) + bb:set_wear(0.3) + g.rolls = {0.9} + assert(bb:snap("tail", {asked = true})) + assert(g.tossed and g.tossed.list[1] == 14 and #g.tossed.list == 1, "the tail comes off") + assert(near(g.set[13].position, v(0, 0, 0)), "and the front stretch stays where it is") + g.run(bb, 3000) + assert(bb.broken, "riding, it stays broken") + assert(not bb:snap("nose", {asked = true}), "one break at a time") + assert(bb:mend("asked for") and near(bb.wear, 0.3), "mended, the wear is as it was") +end) +check("a refusal of the board is told once", function() + local g = fake() + local bb = rider(g) + g.refuse = "other holds the board" + g.rolls = {0.9} + bb:snap("nose", {asked = true}) + g.run(bb, 500) + local told = 0 + for _, line in g.logs do if line:find("other holds the board", 1, true) then told += 1 end end + assert(told == 1, "told " .. told .. " times") +end) +check("cracks take the nearest size there are sounds of, body hits sound by how hard", function() + local g = fake() + g.sounds = {heavy = true, body = true} + local bb = rider(g) + g.rolls = {0.1} -- held by the grip tape: a light break + bb:snap("nose", {asked = true, carried = true}) + assert(g.played[1] == "heavy 1.00", tostring(g.played[1])) + bb:body_hit(2) + bb:body_hit(feel.body_full) + bb:body_hit(feel.body_full) + assert(#g.played == 2 and g.played[2] == "body 1.00", "a graze is not heard, and two hits at once are one") + bb:set_setting("cracks", 0) + bb:mend("asked for") + bb:snap("nose", {asked = true}) + assert(#g.played == 2, "cracks off") +end) +check("off, nothing breaks or wears", function() + local g = fake() + local bb = rider(g) + bb:set_on(false) + g.rolls = {0, 0, 0} + bb:bail(20) + bb:landed("ground", 9) + assert(not bb.broken and bb.wear == 0 and g.stored.on == false) +end) +check("settings and wear are kept, and the board's state is told as it wears", function() + local g = fake() + local bb = rider(g) + assert(bb:set_setting("sensitivity", 9999) and bb:setting("sensitivity") == 400) + assert(not bb:set_setting("nonsense", 1)) + bb:set_wear(0.6) + bb:add_wear(0.1) + assert(#g.toasts == 1 and g.toasts[1]:find("beat", 1, true)) + bb:add_wear(0.2) + assert(#g.toasts == 2 and g.toasts[2]:find("done", 1, true)) + g.run(bb, 6000) + local again = breaker.new(fake(g.stored)) + assert(again:setting("sensitivity") == 400 and near(again.wear, 0.9), "read back: " .. tostring(again.wear)) +end) + +local tests = {} +-- The number that passed, and a line for each that failed. +function tests.run() + local passed, failures = 0, {} + for _, c in checks do + local ok, why = pcall(c.fn) + if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end + end + return passed, failures +end +return tests diff --git a/contrib/plugins/board-break/reskate-plugin.json b/contrib/plugins/board-break/reskate-plugin.json new file mode 100644 index 00000000..30b0028a --- /dev/null +++ b/contrib/plugins/board-break/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["events.skater", "skater.read", "skater.control", "board", "audio", "world.raycast", "storage", "menu", "overlay", "console"] +} diff --git a/contrib/plugins/fpv-drone/images/font.json b/contrib/plugins/fpv-drone/images/font.json new file mode 100644 index 00000000..8c3df8c6 --- /dev/null +++ b/contrib/plugins/fpv-drone/images/font.json @@ -0,0 +1,14 @@ +{ + "order": "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.:%/-+()", + "columns": 13, + "cell": [ + 36, + 48 + ], + "pad": 1, + "size": [ + 494, + 350 + ], + "outline_offset": 44 +} diff --git a/contrib/plugins/fpv-drone/images/font.png b/contrib/plugins/fpv-drone/images/font.png new file mode 100644 index 00000000..49a4fe5b Binary files /dev/null and b/contrib/plugins/fpv-drone/images/font.png differ diff --git a/contrib/plugins/fpv-drone/images/scanlines.png b/contrib/plugins/fpv-drone/images/scanlines.png new file mode 100644 index 00000000..95d64314 Binary files /dev/null and b/contrib/plugins/fpv-drone/images/scanlines.png differ diff --git a/contrib/plugins/fpv-drone/images/static.png b/contrib/plugins/fpv-drone/images/static.png new file mode 100644 index 00000000..2e0aa9ea Binary files /dev/null and b/contrib/plugins/fpv-drone/images/static.png differ diff --git a/contrib/plugins/fpv-drone/images/vignette.png b/contrib/plugins/fpv-drone/images/vignette.png new file mode 100644 index 00000000..0215fb61 Binary files /dev/null and b/contrib/plugins/fpv-drone/images/vignette.png differ diff --git a/contrib/plugins/fpv-drone/manifest.json b/contrib/plugins/fpv-drone/manifest.json new file mode 100644 index 00000000..b84185d3 --- /dev/null +++ b/contrib/plugins/fpv-drone/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "FPV_Drone", + "version_number": "0.1.0", + "website_url": "https://github.com/Dingo-Shenanigans/ReSkate", + "description": "Fly a first-person drone over the map with a keyboard, gamepad or RC radio, or let it follow you and film while you skate.", + "dependencies": [] +} diff --git a/contrib/plugins/fpv-drone/plugin/beacon.lua b/contrib/plugins/fpv-drone/plugin/beacon.lua new file mode 100644 index 00000000..7243e14a --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/beacon.lua @@ -0,0 +1,269 @@ +-- Other players' drones, as Extension/Skater/drone_beacon.h and the drone HUD had them, over the +-- plugin's own messages instead of the core's drone packet: a flying drone sends where it is, its +-- throttle and speed about 12 times a second; each receiver glides a marker from one position to +-- the next, tags the pilot's nametag "FLYING DRONE" and plays their motor where the drone is. +-- (Their drone models are left out: spawning props from a plugin is not possible yet.) +local flight = require("flight") +local osd = require("osd") +local beacon = {} + +local V = vector.create +local dot, cross, len, norm = vector.dot, vector.cross, vector.magnitude, flight.norm +local UP = V(0, 1, 0) + +beacon.send_every_ms = 80 -- about 12 a second +beacon.stale_ms = 1500 -- a drone not heard from this long is gone +beacon.snap_distance = 50 -- a jump further than this (a reset) is shown at once +beacon.most = 32 +beacon.tag = "FLYING DRONE" +beacon.tag_colour = 0x5AE8FFFF +local FORMAT = " 0 then + local s = math.sqrt(trace + 1) * 2 + x, y, z, w = (m12 - m21) / s, (m20 - m02) / s, (m01 - m10) / s, 0.25 * s + elseif m00 > m11 and m00 > m22 then + local s = math.sqrt(1 + m00 - m11 - m22) * 2 + x, y, z, w = 0.25 * s, (m01 + m10) / s, (m20 + m02) / s, (m12 - m21) / s + elseif m11 > m22 then + local s = math.sqrt(1 + m11 - m00 - m22) * 2 + x, y, z, w = (m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m20 - m02) / s + else + local s = math.sqrt(1 + m22 - m00 - m11) * 2 + x, y, z, w = (m20 + m02) / s, (m12 + m21) / s, 0.25 * s, (m01 - m10) / s + end + local l = math.sqrt(x * x + y * y + z * z + w * w) + if not (l > 1e-6) or not flight.finite(l) then return {0, 0, 0, 1} end + return {x / l, y / l, z / l, w / l} +end + +-- A message: flying (or stopped), where, which way it faces, throttle (0-1) and speed (m/s). +function beacon.encode(flying, position, rotation, throttle, speed) + local function q(v) return math.clamp(math.round(v * 32767), -32767, 32767) end + return string.pack(FORMAT, MAGIC, flying and 1 or 0, position.x, position.y, position.z, + q(rotation[1]), q(rotation[2]), q(rotation[3]), q(rotation[4]), + math.clamp(math.round(throttle * 255), 0, 255), math.clamp(math.round(speed * 100), 0, 65535)) +end +-- The message's fields, or nil for one that is not a drone's. +function beacon.decode(payload) + if type(payload) ~= "string" or #payload ~= string.packsize(FORMAT) then return nil end + local ok, magic, kind, x, y, z, qx, qy, qz, qw, throttle, speed = pcall(string.unpack, FORMAT, payload) + if not ok or magic ~= MAGIC then return nil end + local position = V(x, y, z) + if not flight.finite_vector(position) then return nil end + return {flying = kind == 1, position = position, rotation = {qx / 32767, qy / 32767, qz / 32767, qw / 32767}, + throttle = throttle / 255, speed = speed / 100} +end + +-- ---- sending ---------------------------------------------------------------------------------- +local sending = {last = -1e9, announced = false} +-- Each tick: the local drone (a flight state, or nil when not flying) and whether to share it. +-- Returns the message to send now, or nil. +function beacon.outgoing(now, drone, share) + if drone and share then + if sending.announced and now - sending.last < beacon.send_every_ms then return nil end + sending.announced = true + sending.last = now + return beacon.encode(true, drone.position, beacon.rotation(drone.body), drone.throttle, drone.speed) + end + if sending.announced then + sending.announced = false + sending.last = now + local at = drone and drone.position or V(0, 0, 0) + return beacon.encode(false, at, {0, 0, 0, 1}, 0, 0) + end + return nil +end + +-- ---- receiving -------------------------------------------------------------------------------- +-- By player id: {id, name, from, to, seen, glide (ms), throttle, speed, sound, tagged}. +beacon.remotes = {} +local count = 0 + +local function glide_fraction(r, now) + if r.glide <= 0 then return 1 end + return math.clamp((now - r.seen) / r.glide, 0, 1) +end +function beacon.position(r, now) + return r.from + (r.to - r.from) * glide_fraction(r, now) +end + +local function forget(id) + local r = beacon.remotes[id] + if not r then return end + if r.sound then pcall(reskate.audio.stop, r.sound) end + if r.tagged then pcall(reskate.session.tag, id, nil) end + beacon.remotes[id] = nil + count -= 1 +end +beacon.forget = forget + +function beacon.receive(player, payload, now) + local message = beacon.decode(payload) + if not message or not player or not player.id then return end + local id = player.id + local r = beacon.remotes[id] + if not message.flying then + forget(id) + return + end + if not r then + if count >= beacon.most then return end + r = {id = id, name = player.name or "", from = message.position, to = message.position, seen = now, glide = 0, + throttle = message.throttle, speed = message.speed} + beacon.remotes[id] = r + count += 1 + return + end + if player.name and player.name ~= "" then r.name = player.name end + local shown = beacon.position(r, now) + -- The glide lasts as long as this update took to arrive, within sensible bounds. + local gap = math.clamp(now - r.seen, 20, 250) + local snap = len(message.position - shown) > beacon.snap_distance + r.from = snap and message.position or shown + r.to = message.position + r.glide = gap + r.seen = now + r.throttle += (message.throttle - r.throttle) * 0.5 + r.speed += (message.speed - r.speed) * 0.5 +end + +-- Each tick: drops stale drones, keeps tags and motors going. `volume` is the motor volume +-- setting, `show` whether other drones are shown at all. +function beacon.tick(now, show, volume) + for id, r in beacon.remotes do + if now - r.seen > beacon.stale_ms then forget(id) end + end + local tags_ok = true + for id, r in beacon.remotes do + if show and not r.tagged and tags_ok then + tags_ok = pcall(reskate.session.tag, id, beacon.tag, beacon.tag_colour) + r.tagged = tags_ok + elseif not show and r.tagged then + pcall(reskate.session.tag, id, nil) + r.tagged = false + end + local at = beacon.position(r, now) + if show and volume > 0.001 then + -- Other players' drones a little under the pilot's own motor at the same volume. + local options = {position = at, throttle = math.clamp(r.throttle, 0, 1), speed = math.clamp(r.speed, 0, 200), + volume = math.clamp(volume * 0.75, 0, 1), range = 150} + if r.sound then + pcall(reskate.audio.set, r.sound, options) + elseif now >= (r.sound_retry or 0) then + -- (A plugin plays 8 sounds at most: the rest wait and try again now and then.) + options.loop = true + local ok, id_or_false = pcall(reskate.audio.play, "motor", options) + if ok and id_or_false then r.sound = id_or_false else r.sound_retry = now + 2000 end + end + elseif r.sound then + pcall(reskate.audio.stop, r.sound) + r.sound = nil + end + end +end + +-- Everything let go (the plugin unloads, or the level changes). +function beacon.clear() + for id in beacon.remotes do forget(id) end + sending.announced = false +end + +function beacon.count() + return count +end + +-- ---- markers ----------------------------------------------------------------------------------- +local function metres_text(m) + if m < 1000 then return string.format("%.0f m", math.floor(m)) end + return string.format("%.1f km", m / 1000) +end + +-- A little quadcopter: an X with a propeller circle at each end, around (x, y). +local function drone_icon(d, k, x, y, size, colour, shadow) + local arm, prop, t = size * 0.5, size * 0.2, math.max(1.5, size * 0.09) + for pass = 1, 2 do + local c = pass == 1 and shadow or colour + local style = {colour = c, thickness = (pass == 1 and t * 2.4 or t) / k} + d:line(V(x - arm, y - arm, 0), V(x + arm, y + arm, 0), style) + d:line(V(x + arm, y - arm, 0), V(x - arm, y + arm, 0), style) + d:circle(V(x - arm, y - arm, 0), prop, style) + d:circle(V(x + arm, y - arm, 0), prop, style) + d:circle(V(x + arm, y + arm, 0), prop, style) + d:circle(V(x - arm, y + arm, 0), prop, style) + end + d:circle(V(x, y, 0), size * 0.1, {colour = colour, fill = true}) +end + +-- Markers for the drones, from the camera `view` ({position, forward, fov}): over each one on +-- screen its pilot's name and how far; an arrow at the screen's edge for one off it. +function beacon.draw(d, w, h, view, now) + if count == 0 or not view then return end + local k = h / 1080 + local centre_x, centre_y = w * 0.5, h * 0.5 + local margin = 40 * k + local pulse = 0.78 + 0.22 * math.sin(now / 1000 * 5) + local colour = osd.rgba(90, 232, 255, 255 * pulse) + local shadow = osd.rgba(0, 0, 0, 200) + local forward = norm(view.forward) + local right = norm(cross(forward, UP)) + if len(right) < 0.5 then right = V(1, 0, 0) end + local up = cross(right, forward) + local fov = (view.fov and view.fov > 1 and view.fov < 175) and view.fov or 60 + local focal = h / (2 * math.tan(fov * math.pi / 360)) + local size = math.clamp(14 * k, 10, 30) + for _, r in beacon.remotes do + -- Anchored a little above the drone, then lifted by half the icon and a gap on screen. + local at = beacon.position(r, now) + V(0, 0.25, 0) + local delta = at - view.position + local distance = len(delta) + local label = (r.name ~= "" and r.name or "Player") .. "'s drone" + local metres = metres_text(distance) + local screen = d:world_to_screen(at) + local on_screen = screen ~= nil and screen.x >= margin and screen.x <= w - margin and screen.y >= margin and screen.y <= h - margin + if on_screen then + -- Stacked upwards: the icon, the distance, then the name. + local icon = math.clamp(18 * k, 12, 32) + local icon_y = screen.y - icon * 0.5 - 6 * k + drone_icon(d, k, screen.x, icon_y, icon, colour, shadow) + local fine = size * 0.78 + local metres_y = icon_y - icon * 0.5 - 4 * k - fine + d:text(V(screen.x, metres_y, 0), metres, {colour = osd.rgba(235, 235, 235, 230), size = fine / k, align = "centre"}) + d:text(V(screen.x, metres_y - size, 0), label, {colour = colour, size = size / k, align = "centre"}) + else + -- Off screen: an arrow at the edge pointing to it, with the distance. + local depth, side, height = dot(delta, forward), dot(delta, right), dot(delta, up) + local ex, ey + if depth > 0.1 then ex, ey = side * focal / depth, -height * focal / depth + else ex, ey = side, -height end + if math.abs(ex) < 1e-3 and math.abs(ey) < 1e-3 then ey = 1 end -- straight behind + local hx, hy = centre_x - margin, centre_y - margin + local scale = math.min(math.abs(ex) > 1e-6 and hx / math.abs(ex) or 1e30, math.abs(ey) > 1e-6 and hy / math.abs(ey) or 1e30) + local edge_x, edge_y = centre_x + ex * scale, centre_y + ey * scale + local l = math.sqrt(ex * ex + ey * ey) + local dx, dy = ex / l, ey / l + local px, py = -dy, dx + local a = 13 * k + local tip = V(edge_x + dx * a, edge_y + dy * a, 0) + local b1 = V(edge_x - dx * a * 0.5 + px * a * 0.8, edge_y - dy * a * 0.5 + py * a * 0.8, 0) + local b2 = V(edge_x - dx * a * 0.5 - px * a * 0.8, edge_y - dy * a * 0.5 - py * a * 0.8, 0) + local o = V(2 * k, 2 * k, 0) + d:poly({tip + o, b1 + o, b2 + o}, {colour = shadow, fill = true}) + d:poly({tip, b1, b2}, {colour = colour, fill = true}) + local fine = size * 0.78 + local tx = math.clamp(edge_x - dx * a * 2.2, 40 * k, w - 40 * k) + local ty = math.clamp(edge_y - dy * a * 2.2 - fine * 0.5, 4 * k, h - fine - 4 * k) + d:text(V(tx, ty, 0), metres, {colour = colour, size = fine / k, align = "centre"}) + end + end +end + +return beacon diff --git a/contrib/plugins/fpv-drone/plugin/flight.lua b/contrib/plugins/fpv-drone/plugin/flight.lua new file mode 100644 index 00000000..4f816027 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/flight.lua @@ -0,0 +1,536 @@ +-- The FPV drone's flight model, as Extension/Skater/drone_flight.h had it. Plain maths: the world +-- comes in as a cast(from, to) function (the hit point, or nil), so the tests fly it over a fake. +-- +-- A pose is a basis {r, u, b} of world vectors (right, up, backward; the camera looks along -b) and +-- a position. World up is +Y. +-- +-- The throttle sets motor output, which spools up and down over some tens of milliseconds, and +-- thrust grows with output to the power 1.5, so the hover throttle fixes the thrust-to-weight +-- ratio (a 22% hover is about 10:1, a 5-inch's). Thrust falls off as the drone climbs into its own +-- inflow and comes back near the ground. The flight controller turns the drone at the rate the +-- sticks ask, catching up in about 30 ms, or in angle mode leans it to the angle they ask. Drag is +-- quadratic and larger across the props than edge on; the top speed setting sets how much, so full +-- throttle flat out settles near it. Hits bounce off the surface they meet, knock the drone about, +-- and a hard one cuts the motors until it is armed again. +local flight = {} + +local V = vector.create +local dot, cross, len = vector.dot, vector.cross, vector.magnitude +local ZERO, UP = V(0, 0, 0), V(0, 1, 0) +local DEG = math.pi / 180 + +flight.gimbal_roll_share = 0.3 -- how much of the body's roll a tilt gimbal's picture keeps +flight.rate_lag = 0.03 -- s: the flight controller's catch-up to the asked turn rate +flight.spool_up, flight.spool_down = 0.04, 0.07 -- s: motor output following the throttle +flight.thrust_power = 1.5 -- thrust grows with motor output to this power +flight.boost_thrust = 1.5 -- boost: this much more thrust +flight.hard_hit = 2.0 -- m/s into a surface: a bump rather than a touch +flight.bounce = 0.35 -- how much of a hard hit's speed it bounces back with +flight.hold_gain = 4.0 -- 1/s: height hold's pull toward the asked climb +-- Range: the picture is clean to half the range, lost at it; the motors cut past 1.5 times it, or +-- after 5 s without a picture. +flight.range_clean, flight.range_failsafe, flight.failsafe_after = 0.5, 1.5, 5.0 + +local function finite(x) + return x == x and x > -1e30 and x < 1e30 +end +local function finite_vector(v) + return finite(v.x) and finite(v.y) and finite(v.z) and math.abs(v.x) < 1e6 and math.abs(v.y) < 1e6 and math.abs(v.z) < 1e6 +end +flight.finite = finite +flight.finite_vector = finite_vector + +local function norm(a) + local l = len(a) + if l > 1e-8 then return a / l end + return ZERO +end +flight.norm = norm +local function clamp1(v) + if not finite(v) then return 0 end + return math.clamp(v, -1, 1) +end +flight.clamp1 = clamp1 + +-- Rodrigues: v about the unit axis by the angle (right-handed). +local function rotate(v, axis, angle) + local c, s = math.cos(angle), math.sin(angle) + return v * c + cross(axis, v) * s + axis * (dot(axis, v) * (1 - c)) +end +flight.rotate = rotate + +-- The whole basis about a world axis, then square again. +local function rotate_basis(m, axis, angle) + if angle == 0 then return end + local r = norm(rotate(m.r, axis, angle)) + local u = rotate(m.u, axis, angle) + local b = norm(cross(r, u)) + m.r, m.u, m.b = r, cross(b, r), b +end +flight.rotate_basis = rotate_basis + +local function copy_basis(m) + return {r = m.r, u = m.u, b = m.b} +end +flight.copy_basis = copy_basis + +-- A level basis facing `forward` (its height is dropped). +function flight.level_basis(forward) + local f = V(forward.x, 0, forward.z) + if len(f) < 0.05 then f = V(0, 0, -1) end + f = norm(f) + return {r = V(-f.z, 0, f.x), u = UP, b = -f} +end + +-- Betaflight's "Actual" rates: deg/s for a stick of -1 to 1. +function flight.actual_rate(stick, rate) + local a = math.min(math.abs(stick), 1) + local expo = math.clamp(rate.expo, 0, 1) + local curve = a * (a * a * a * a * a * expo + a * (1 - expo)) + local movement = math.max(0, rate.max - rate.center) + local v = rate.center * a + movement * curve + return stick < 0 and -v or v +end + +-- A throttle stick that springs back to the middle: the middle holds the drone up, up climbs to +-- full, down drops to nothing. +function flight.spring_throttle(stick, hover) + local h = math.clamp(finite(hover) and hover or 0.35, 0.05, 0.95) + local v = math.clamp(finite(stick) and stick or 0, -1, 1) + if v >= 0 then return h + v * (1 - h) end + return h * (1 + v) +end + +local function hover_of(c) + return math.clamp(finite(c.hover) and c.hover or 0.35, 0.05, 0.95) +end +flight.hover_of = hover_of +-- Thrust (m/s² along the drone's up) at full output, and at `output`. +local function max_thrust(c) + return math.max(0, c.gravity) / hover_of(c) ^ flight.thrust_power +end +flight.max_thrust = max_thrust +local function thrust_at(output, c) + return max_thrust(c) * math.clamp(output, 0, 1) ^ flight.thrust_power +end +flight.thrust_at = thrust_at +-- The output that gives `accel` m/s² of thrust. +local function output_for(accel, c) + local most = max_thrust(c) + if not (most > 0) or not finite(accel) then return hover_of(c) end + return math.clamp(accel / most, 0, 1) ^ (1 / flight.thrust_power) +end +flight.output_for = output_for +-- The top speed (m/s) the speed setting asks for, and the quadratic drag that gives it: flat out, +-- the thrust left over from holding the drone up is spent on drag at that speed. +local function top_speed(speed) + return math.clamp(finite(speed) and speed or 10, 0.6, 1500) * 4 +end +flight.top_speed = top_speed +local function drag_for(c) + local most, g = max_thrust(c), math.max(0, c.gravity) + local spare = math.sqrt(math.max(most * most - g * g, 0.01)) + local top = top_speed(c.top_speed) + return 0.36 * spare / (top * top) +end +flight.drag_for = drag_for + +function flight.signal_strength(distance, range) + if not (range > 0) or not finite(distance) then return 1 end + local q = distance / range + if q <= flight.range_clean then return 1 end + if q >= 1 then return 0 end + local t = (q - flight.range_clean) / (1 - flight.range_clean) + return math.clamp(1 - t * t * (3 - 2 * t), 0, 1) +end +function flight.failsafe_due(distance, range, seconds_lost) + if not (range > 0) then return false end + return (finite(distance) and distance > range * flight.range_failsafe) or seconds_lost >= flight.failsafe_after +end + +-- A pretend 4S pack: 16.8 V full, sagging a little with throttle and draining over the flight. +function flight.battery_volts(seconds, throttle) + return math.max(13.2, 16.8 - 0.0045 * seconds - 0.7 * throttle) +end + +-- xorshift32: -1 to 1. +local function noise(state) + local s = state.noise + s = bit32.bxor(s, bit32.lshift(s, 13)) + s = bit32.bxor(s, bit32.rshift(s, 17)) + s = bit32.bxor(s, bit32.lshift(s, 5)) + state.noise = s + return bit32.band(s, 0xffffff) / 8388608 - 1 +end +flight.noise = noise + +-- The surface at a hit: two more rays beside the first find two more points of it. Without them +-- (an edge, or a thin thing) it is taken to face straight back along the move. +function flight.surface_normal(cast, from, dir, reach, hit, spread) + local side = norm(cross(dir, math.abs(dir.y) < 0.9 and UP or V(1, 0, 0))) + local other = cross(dir, side) + local points, found = {}, 0 + for _, offset in {side, other, -side, -other} do + if found == 2 then break end + local start = from + offset * spread + local at = cast(start, start + dir * reach) + if at then + found += 1 + points[found] = at + end + end + if found == 2 then + local n = norm(cross(points[1] - hit, points[2] - hit)) + if dot(n, dir) > 0 then n = -n end + if len(n) > 0.5 and dot(n, dir) < -0.05 then return n end + end + return -dir +end + +-- Body turn rates (about right, up, forward) from a world spin vector. +local function body_rates(body, spin) + return dot(spin, body.r), dot(spin, body.u), -dot(spin, body.b) +end +flight.body_rates = body_rates + +-- The camera on the body, tilted up by `uptilt` radians. +function flight.camera_from_body(body, uptilt) + local cam = copy_basis(body) + rotate_basis(cam, body.r, uptilt) + return cam +end +-- A camera on a tilt gimbal (as on a DJI Avata): it faces the way the body faces and holds its +-- own pitch (+ looks up), keeping only a share of the body's roll. +function flight.camera_on_gimbal(body, tilt, roll_share) + local f = -body.b + f = V(f.x, 0, f.z) + if dot(f, f) < 0.0025 then f = cross(body.r, UP) end + f = norm(f) + local cam = {r = V(-f.z, 0, f.x), u = UP, b = -f} + rotate_basis(cam, cam.r, tilt) + local roll = -math.asin(math.clamp(body.r.y, -1, 1)) + rotate_basis(cam, -cam.b, roll * roll_share) + return cam +end + +-- A new flight: level, facing `forward`, at rest and hovering, at `position`. +function flight.new_state(position, forward, armed, cfg) + local s = {} + flight.start(s, position, forward, armed, cfg) + return s +end +function flight.start(s, position, forward, armed, cfg) + s.body = flight.level_basis(forward) + s.position = position + s.velocity = ZERO + s.start_y = position.y + s.start_position = position + s.start_body = copy_basis(s.body) + s.flight_time = 0 + s.failsafe = false + s.signal_lost = 0 + s.rate = {0, 0, 0} -- rad/s about right (+ nose up), up (+ left), forward (+ roll right) + s.motor = armed and hover_of(cfg) or 0 + s.crashed = false + s.armed_last = armed + s.grounded = false + s.glitch = 0 + s.wash = {0, 0} + s.noise = s.noise or 0x2545f491 + s.signal = 1 + s.home = 0 + s.speed = 0 + s.altitude = 0 + s.throttle = s.motor + s.crashed_now = false + s.camera = copy_basis(s.body) +end +-- Back to where it took off, with a fresh battery and timer. +function flight.reset(s, armed, cfg) + s.body = copy_basis(s.start_body) + s.position = s.start_position + s.velocity = ZERO + s.flight_time = 0 + s.failsafe = false + s.signal_lost = 0 + s.rate = {0, 0, 0} + s.motor = armed and hover_of(cfg) or 0 + s.crashed = false + s.glitch = 0 +end + +-- A pilot's command: pitch (+ nose up), roll (+ right), yaw_rad (radians this step, + left), +-- look_pitch (radians from the mouse, + looks down), throttle (0-1), hold and climb (height hold), +-- boost, park (motors off, hold still), gimbal (set: a tilt gimbal at this pitch). +function flight.command() + return {pitch = 0, roll = 0, yaw_rad = 0, look_pitch = 0, throttle = 0.35, hold = false, climb = 0, boost = false, park = false, gimbal = nil} +end + +-- One step of `seconds`. `cfg` is settings.flight(); `cast` the world (nil: no collisions). +-- Sets s.crashed_now when a hit cut the motors (the pilot's switch goes off). +function flight.step(s, cmd_in, seconds, cfg, cast) + s.crashed_now = false + if not finite(seconds) then return end + local dt = math.clamp(seconds, 0, 0.05) + local uptilt = cfg.uptilt * DEG + local position = s.position + + -- After a crash the motors stay off until the drone is armed again: off, then on. + if cfg.armed and not s.armed_last then s.crashed = false end + s.armed_last = cfg.armed + local cmd = cmd_in + -- Range: the link weakens with distance from the takeoff point. + local home = len(position - s.start_position) + local quality = flight.signal_strength(home, cfg.range) + s.signal_lost = quality <= 0 and s.signal_lost + dt or 0 + if flight.failsafe_due(home, cfg.range, s.signal_lost) then s.failsafe = true end + s.signal, s.home = quality, home + -- Disarmed, crashed or out of range: the pilot's inputs and the motors are off. A crashed drone + -- falls even when a disarmed one would hang. + local motors = cfg.armed and not s.failsafe and not s.crashed + if not motors then + local gimbal = cmd.gimbal + cmd = flight.command() + cmd.gimbal = gimbal + cmd.throttle = 0 + cmd.park = cfg.disarm_freeze and not s.crashed + end + + local pitch_in, roll_in = clamp1(cmd.pitch), clamp1(cmd.roll) + local yaw_rate = 0 + if dt > 0 and finite(cmd.yaw_rad) then yaw_rate = math.clamp(cmd.yaw_rad / dt, -40, 40) end + local look_pitch = finite(cmd.look_pitch) and cmd.look_pitch or 0 + local body = s.body + local rate = s.rate + + -- Attitude: the flight controller asks for a turn rate on each axis and the drone catches up; + -- with the motors off nothing drives it and only the air slows a spin. + if motors then + local w1, w2, w3 + if not cfg.angle_mode then + w1 = flight.actual_rate(pitch_in, cfg.pitch) * DEG + w2 = yaw_rate + w3 = flight.actual_rate(roll_in, cfg.roll) * DEG + else + -- Lean toward the angle the sticks ask, about the heading the drone faces. + local fwd = -body.b + local level = V(fwd.x, 0, fwd.z) + if len(level) < 0.05 then level = cross(body.r, UP) end + level = norm(level) + local level_right = V(-level.z, 0, level.x) + local most = math.clamp(cfg.max_angle, 1, 89) * DEG + local desired = rotate(UP, level_right, pitch_in * most) + desired = rotate(desired, rotate(level, level_right, pitch_in * most), roll_in * most) + local up = body.u + local axis = cross(up, desired) + local angle = math.atan2(len(axis), dot(up, desired)) + local spin = norm(axis) * (angle * math.max(cfg.angle_response, 0.5)) + local r1, _, r3 = body_rates(body, spin) + local lp, lr = math.max(cfg.pitch.max, 30) * DEG, math.max(cfg.roll.max, 30) * DEG + w1, w2, w3 = math.clamp(r1, -lp, lp), yaw_rate, math.clamp(r3, -lr, lr) + end + local k = 1 - math.exp(-dt / flight.rate_lag) + rate[1] += (w1 - rate[1]) * k + rate[2] += (w2 - rate[2]) * k + rate[3] += (w3 - rate[3]) * k + else + local damp = math.exp(-dt * (s.grounded and 8 or 0.6)) + rate[1] *= damp + rate[2] *= damp + rate[3] *= damp + end + -- Prop wash: dropping straight down through its own wash shakes it. + do + local up = body.u + local axial = dot(s.velocity, up) + local across = len(s.velocity - up * axial) + local sink = math.clamp((-axial - 1.5) / 6, 0, 1) * math.clamp(1 - across / 8, 0, 1) + local amount = motors and math.clamp(cfg.propwash, 0, 1) * sink * 3 or 0 + local k = 1 - math.exp(-dt * 20) + s.wash[1] += (noise(s) * amount - s.wash[1]) * k + s.wash[2] += (noise(s) * amount - s.wash[2]) * k + end + for i = 1, 3 do + if not finite(rate[i]) then rate[i] = 0 end + end + rotate_basis(body, body.u, rate[2] * dt) -- yaw about body up + rotate_basis(body, body.r, (rate[1] + s.wash[1]) * dt - look_pitch) -- pitch about body right + rotate_basis(body, -body.b, (rate[3] + s.wash[2]) * dt) -- roll about body forward + + -- Translation. + local next_position = position + local hardest = 0 + if cmd.park then + s.velocity = ZERO + s.motor = 0 + else + local right, up, back = body.r, body.u, body.b + -- The throttle, or with height hold the output that keeps the asked climb, leaning or not. + -- Height hold never holds the stick back: full stick is always full or no throttle. + local want = math.clamp(cmd.throttle, 0, 1) + if cmd.hold and motors then + local stick = clamp1(cmd.climb) + local direct = flight.spring_throttle(stick, hover_of(cfg)) + local lift = math.max(0, cfg.gravity + flight.hold_gain * (stick * math.max(cfg.climb_rate, 0) - s.velocity.y)) + local held = up.y > 0.25 and output_for(lift / up.y, cfg) or direct + want = stick > 0 and math.max(held, direct) or stick < 0 and math.min(held, direct) or held + end + if not motors then want = 0 end + s.motor += (want - s.motor) * (1 - math.exp(-dt / (want > s.motor and flight.spool_up or flight.spool_down))) + local thrust = thrust_at(s.motor, cfg) * ((cmd.boost and motors) and flight.boost_thrust or 1) + -- Inflow: climbing into its own air it pushes less, sinking a little more. + local axial = dot(s.velocity, up) + thrust *= math.clamp(1 - axial / math.max(top_speed(cfg.top_speed) * 2.5, 5), 0.3, 1.25) + -- Ground effect: within a couple of prop sizes of the ground it pushes more. + local r = math.max(cfg.radius, 0.01) + if thrust > 0 and cast and cfg.collide then + local reach = 4 * r + local below = cast(position, position + V(0, -reach, 0)) + if below then thrust *= 1 + 0.25 * math.clamp(1 - (position.y - below.y) / reach, 0, 1) end + end + -- Drag: quadratic, larger across the props than edge on, plus the plain drag setting. + local v = s.velocity + local k = drag_for(cfg) * len(v) + local drag = right * (-dot(v, right) * k) + up * (-dot(v, up) * 1.5 * k) + back * (-dot(v, back) * k) + local accel = up * thrust + drag + V(-v.x * cfg.lin_drag, -cfg.gravity - v.y * cfg.lin_drag, -v.z * cfg.lin_drag) + v += accel * dt + -- (Only a guard against runaway numbers: drag sets the real top speed.) + local cap = math.max(top_speed(cfg.top_speed) * 3, 30) + local now = len(v) + if now > cap then v *= cap / now end + s.velocity = v + next_position = position + v * dt + s.grounded = false + if cast and cfg.collide then + -- Swept move: up to the first surface on the way, then what is left of the move slides + -- along it. A hit takes away the speed into the surface; a hard one bounces back some of + -- it and knocks the drone about. + local p = position + local move = next_position - position + local spread = math.max(r, 0.05) + for _ = 1, 3 do + local length = len(move) + if not (length > 1e-6) then break end + local dir = move / length + local hit = cast(p, p + dir * (length + r)) + if not hit then + p += move + break + end + local gap = len(hit - p) - r + local allowed = math.clamp(gap, 0, length) + p += dir * allowed + local n = flight.surface_normal(cast, p, dir, length + r + 2 * spread, hit, spread) + if gap < 0 then p += n * math.min(-gap, r) end -- started too close: step out + if n.y > 0.6 then s.grounded = true end + local into = dot(s.velocity, n) + if into < 0 then + local impact = -into + local along = s.velocity - n * into + if cfg.crash and impact > flight.hard_hit then + s.velocity = along * 0.75 + n * (impact * flight.bounce) + -- Knocked about: spun away from the surface by the speed along it, plus some + -- of the hit at random. + local slide = math.min(len(along), 15) + local spin = norm(cross(n, along)) * (slide * 0.6) + spin += V(noise(s), noise(s), noise(s)) * (impact * 0.4) + local k1, k2, k3 = body_rates(body, spin) + rate[1] = math.clamp(rate[1] + k1, -30, 30) + rate[2] = math.clamp(rate[2] + k2, -30, 30) + rate[3] = math.clamp(rate[3] + k3, -30, 30) + else + s.velocity = along + end + hardest = math.max(hardest, impact) + end + if n.y > 0.6 then -- on the ground: skid to a stop + local grip = math.exp(-6 * dt) + local normal = dot(s.velocity, n) + s.velocity = (s.velocity - n * normal) * grip + n * normal + end + local rest = dir * (length - allowed) + local push = dot(rest, n) + if push < 0 then rest -= n * push end + move = rest + end + next_position = p + -- Safety net while the collision is not loaded: do not fall forever. + local deep = s.start_y - 300 + if next_position.y < deep then + next_position = V(next_position.x, deep, next_position.z) + if s.velocity.y < 0 then s.velocity = V(s.velocity.x, 0, s.velocity.z) end + end + elseif cfg.floor > 0 then + -- No collisions: an invisible floor so it lands instead of falling through the map. + local floor_y = s.start_y - cfg.floor + if next_position.y < floor_y then + next_position = V(next_position.x, floor_y, next_position.z) + local grip = math.exp(-6 * dt) + s.velocity = V(s.velocity.x * grip, math.max(s.velocity.y, 0), s.velocity.z * grip) + s.grounded = true + end + end + end + -- A hit hard enough cuts the motors (the drone falls and tumbles); any real one breaks up the picture. + if cfg.crash and hardest > flight.hard_hit then + s.glitch = math.max(s.glitch, math.clamp(hardest / math.max(cfg.crash_speed * 1.5, 4), 0.2, 1)) + if motors and cfg.crash_speed > 0 and hardest >= cfg.crash_speed then + s.crashed = true + s.crashed_now = true + end + end + s.glitch *= math.exp(-dt * 2.5) + + local camera + if cmd.gimbal then camera = flight.camera_on_gimbal(body, cmd.gimbal, flight.gimbal_roll_share) + else camera = flight.camera_from_body(body, uptilt) end + if not (finite_vector(next_position) and finite_vector(s.velocity) and finite_vector(camera.r) and finite_vector(camera.u)) then + -- Bad numbers: start again where it was. + flight.start(s, position, -s.start_body.b, cfg.armed, cfg) + return + end + s.position = next_position + s.camera = camera + if cfg.armed then s.flight_time += dt end + s.throttle = cmd.park and 0 or math.clamp(s.motor, 0, 1) + s.speed = len(s.velocity) + s.altitude = next_position.y - s.start_y + s.armed_now = cfg.armed and not s.crashed +end + +-- Keyboard and mouse (reskate.input.flight()): the keyboard has no throttle that stays put, so +-- with height hold the middle holds the height and E / Q climb and descend; without it the middle +-- is hover power. Also returns the sticks to show: roll, pitch, yaw, throttle (-1 to 1). +function flight.keyboard_command(input, cfg) + local cmd = flight.command() + cmd.pitch = clamp1(-input.forward) -- forward = nose down + cmd.roll = clamp1(input.right) + local look_x = math.clamp(finite(input.look_x) and input.look_x or 0, -250, 250) + local look_y = math.clamp(finite(input.look_y) and input.look_y or 0, -250, 250) + cmd.yaw_rad = -look_x * cfg.yaw_look_scale -- right = negative about up + cmd.look_pitch = look_y * cfg.yaw_look_scale + local up_in = clamp1(input.up) + cmd.throttle = flight.spring_throttle(up_in, cfg.hover) + cmd.hold = cfg.alt_hold + cmd.climb = up_in + cmd.boost = input.boost and true or false + return cmd, {roll = clamp1(input.right), pitch = clamp1(input.forward), yaw = clamp1(look_x / 40), throttle = up_in} +end + +-- A controller's sticks (radio.lua: an RC radio, or a gamepad read as one). A throttle stick that +-- stays put is absolute thrust; one that springs back holds the height (height hold) or hovers in +-- its middle. +function flight.radio_command(sticks, seconds, cfg) + local dt = math.clamp(finite(seconds) and seconds or 0, 0, 0.05) + local cmd = flight.command() + cmd.pitch = clamp1(-sticks.pitch) -- stick forward = nose down + cmd.roll = clamp1(sticks.roll) + cmd.yaw_rad = -flight.actual_rate(clamp1(sticks.yaw), cfg.yaw) * DEG * dt + cmd.throttle = math.clamp(sticks.throttle, 0, 1) + cmd.hold = sticks.centred and cfg.alt_hold + cmd.climb = clamp1(sticks.climb) + return cmd, {roll = clamp1(sticks.roll), pitch = clamp1(sticks.pitch), yaw = clamp1(sticks.yaw), + throttle = sticks.centred and clamp1(sticks.climb) or nil} +end + +return flight diff --git a/contrib/plugins/fpv-drone/plugin/follow.lua b/contrib/plugins/fpv-drone/plugin/follow.lua new file mode 100644 index 00000000..ee94cd61 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/follow.lua @@ -0,0 +1,437 @@ +-- Follow mode, as Extension/Skater/drone_follow.h had it: the drone flies itself behind the skater +-- while the player skates, and the view is its camera. An autopilot works the same sticks a pilot +-- would (angle mode with height hold) through the flight model, so it has to lean to speed up, +-- overshoots a turn, swings round to keep the skater in shot and climbs over what is in the way. +-- +-- It sees the skater a reaction time late and moves its sticks with a hand's smoothness and a +-- little wander. When the skater rides back and forth in one place (a halfpipe, a bowl) it stops +-- chasing and holds a spot off to the side to film the session. It keeps off walls and ceilings. +local flight = require("flight") +local follow = {} + +local V = vector.create +local dot, cross, len, norm, clamp1 = vector.dot, vector.cross, vector.magnitude, flight.norm, flight.clamp1 +local ZERO, UP = V(0, 0, 0), V(0, 1, 0) +local DEG = math.pi / 180 + +-- The tuning for a follow distance (m) and how aggressively the pilot flies (0 calm, 0.5 middling, +-- 1 hard): how quickly it sees and answers the skater, how hard it closes a gap, leans and turns. +-- Further back it also flies a little higher, and films a session from further away. +function follow.tuning(distance, aggression) + local a = math.clamp(flight.finite(aggression) and aggression or 0.5, 0, 1) + local function mix(calm, mid, hard) + if a < 0.5 then return calm + (mid - calm) * (a / 0.5) end + return mid + (hard - mid) * ((a - 0.5) / 0.5) + end + local t = {lead = 0.15, wander = 0.035, uptilt = 5, margin = 1.2, clearance = 1.4} + t.distance = math.clamp(flight.finite(distance) and distance or 4.5, 1, 30) + t.height = 1 + 0.09 * t.distance + t.session_distance = math.max(t.distance + 2.5, 5) + t.reaction = mix(0.20, 0.12, 0.06) + t.hands = mix(0.16, 0.10, 0.05) + t.gap_gain = mix(0.5, 0.8, 1.2) + t.speed_gain = mix(1.0, 1.5, 2.0) + t.catch_up = mix(4, 6, 10) + t.brake = mix(1.2, 2.0, 3.5) + t.max_lean = mix(25, 40, 55) + t.max_yaw_rate = mix(2.5, 4.5, 6.0) + t.tilt_ease = mix(0.6, 0.4, 0.25) + return t +end + +-- The flight settings it flies with: the player's own drone made easy to fly well (self-levelling, +-- height hold), and bumps never cut the motors (nobody is there to arm it again). +function follow.flight_settings(cfg, t) + local out = table.clone(cfg) + out.angle_mode = true + out.alt_hold = true + out.max_angle = math.clamp(t.max_lean + 10, 20, 80) + out.uptilt = t.uptilt + out.crash_speed = 0 + out.range = 0 + out.armed = true + out.top_speed = math.max(cfg.top_speed, 8) + return out +end + +function follow.pilot() + return {valid = false, last_target = ZERO, target_velocity = ZERO, heading = V(0, 0, -1), + seen = {}, seen_head = 0, seen_count = 0, ride_y = 0, climb_extra = 0, blocked_for = 0, floor_lift = 0, + pitch = 0, roll = 0, yaw = 0, climb = 0, tilt = 0, + history = {}, history_head = 0, history_count = 0, history_clock = 0, + session = false, session_for = 0, session_spot = ZERO, session_middle = ZERO, session_side = ZERO, session_reach = 0, + nearby = {0, 0, 0, 0, 0}, near_next = 1, noise = 0x9e3779b9, wander_now = ZERO, wander_to = ZERO, wander_time = 0} +end + +local SEEN, HISTORY = 48, 60 + +-- Where the drone wants to be: behind the skater along its heading, up and a little ahead in time. +function follow.spot(target, velocity, heading, t, extra) + local ahead = target + velocity * t.lead + return ahead - heading * t.distance + V(0, t.height + extra, 0) +end + +-- A level basis at `at` facing `look`. +function follow.facing(at, look) + return flight.level_basis(look - at) +end + +local function history_at(p, back) + return p.history[(p.history_head - back - 1) % HISTORY + 1] +end + +-- Reads the skater's last few seconds: riding back and forth in one place starts a session (the +-- drone holds a spot off to the side), riding away or stopping for a while ends it. +function follow.read_session(p, drone_at, t, cast) + local n = math.min(p.history_count, 50) -- the last five seconds + if n < 30 then + p.session = false + return + end + -- The way it rides most (across a halfpipe), from how its velocity spreads. + local xx, xz, zz, low, high, path = 0, 0, 0, 1e9, -1e9, 0 + local middle = ZERO + for back = 0, n - 1 do + local sample = history_at(p, back) + local v = sample.velocity + xx += v.x * v.x + xz += v.x * v.z + zz += v.z * v.z + middle += sample.position + low = math.min(low, sample.position.y) + high = math.max(high, sample.position.y) + if back > 0 then + local before = history_at(p, back - 1).position + local dx, dz = sample.position.x - before.x, sample.position.z - before.z + path += math.sqrt(dx * dx + dz * dz) + end + end + middle /= n + local angle = 0.5 * math.atan2(2 * xz, xx - zz) + local axis = V(math.cos(angle), 0, math.sin(angle)) + local half = 0.5 * (xx + zz) + local spread = math.sqrt(math.max(half * half - (xx * zz - xz * xz), 0)) + local line = half + spread > 3 * math.max(half - spread, 1e-3) -- one way and back, not round + local reversals, since_reversal = 0, n + local sign, lo, hi = 0, 1e9, -1e9 + for back = n - 1, 0, -1 do + local sample = history_at(p, back) + local along = dot(sample.velocity, axis) + local x = dot(sample.position - middle, axis) + lo = math.min(lo, x) + hi = math.max(hi, x) + if math.abs(along) >= 1.5 then + local now = along > 0 and 1 or -1 + if sign ~= 0 and now ~= sign then + reversals += 1 + since_reversal = back + end + sign = now + end + end + local first, last = history_at(p, n - 1).position, history_at(p, 0).position + local net = math.sqrt((last.x - first.x) ^ 2 + (last.z - first.z) ^ 2) + local back_and_forth = reversals >= 2 and path > 8 and net < 0.4 * path + local across = V(-axis.z, 0, axis.x) + if not p.session then + if not back_and_forth then return end + p.session = true + p.session_for = 0 + -- Watched from off to the side of the way it rides, on the side the drone is already on; + -- round riding (a bowl) from where the drone already is. + local from = V(drone_at.x - middle.x, 0, drone_at.z - middle.z) + if line then p.session_side = dot(from, across) >= 0 and across or -across + else p.session_side = len(from) > 0.5 and norm(from) or across end + else + -- Ends: no turn back for a while, or the skater has left the middle. + local out = math.sqrt((last.x - p.session_middle.x) ^ 2 + (last.z - p.session_middle.z) ^ 2) + if p.session_for > 3 and (since_reversal > 35 or out > p.session_reach + 6) then + p.session = false + return + end + if line then p.session_side = dot(across, p.session_side) >= 0 and across or -across end + end + p.session_middle = middle + p.session_reach = math.max(-lo, hi) + -- Far enough to see both ends of the riding, high enough to see the airs. + local distance = math.clamp(0.6 * (hi - lo), t.session_distance, 14) + local height = low + math.clamp(0.5 * (high - low) + 1.5, 1.8, 6) + local look_from = V(middle.x, height, middle.z) + local function place(side) + local spot = look_from + side * distance + local room = distance + local hit = cast and cast(look_from, spot + side * t.margin) + if hit then + room = math.max(len(hit - look_from) - t.margin, 0.5) + spot = look_from + side * room + end + return spot, room + end + local spot, room = place(p.session_side) + p.session_spot = spot + if room < 3 then -- boxed in on this side: the other side, if it is any better + local other, other_room = place(-p.session_side) + if other_room > room + 1 then + p.session_side = -p.session_side + p.session_spot = other + end + end +end + +local WAYS = {V(1, 0, 0), V(-1, 0, 0), V(0, 0, 1), V(0, 0, -1), V(0, 1, 0)} +-- Keeps off walls and ceilings: looks along the way it flies and the way it wants to, and slows +-- toward anything there in time to stop short of it; and pushes away from anything already close. +-- Returns the changed level velocity it wants, and whether the way up is blocked. +function follow.avoid(p, drone, position, want, t, cast) + for _, going in {drone.velocity, want} do + local dir = V(going.x, 0, going.z) + local speed = len(dir) + if speed >= 0.3 then + dir /= speed + local reach = t.margin + 0.5 + speed * 0.7 + local hit = cast(position, position + dir * reach) + if hit then + local n = flight.surface_normal(cast, position, dir, reach, hit, 0.3) + n = V(n.x, 0, n.z) + if len(n) >= 0.2 then -- a wall, not the ground + n = norm(n) + local room = len(hit - position) - t.margin + local allowed = room > 0 and math.sqrt(2 * 4 * room) or room * 2 -- m/s toward it; < 0 backs off + local toward = -dot(want, n) + if toward > allowed then want += n * (toward - allowed) end + end + end + end + end + -- Two of the five ways around it each tick. + local reach = t.margin + 0.3 + for _ = 1, 2 do + local w = p.near_next + p.near_next = w % 5 + 1 + local hit = cast(position, position + WAYS[w] * reach) + p.nearby[w] = hit and math.max(len(hit - position), 0.01) or 0 + end + for w = 1, 4 do + if p.nearby[w] > 0 then want += WAYS[w] * (-(reach - p.nearby[w]) * 3) end + end + return want, p.nearby[5] > 0 and p.nearby[5] < t.margin +end + +-- One tick: what the pilot does with the sticks. `drone` is the flight state, `target` the +-- skater's position and `skater_forward` which way it faces. `cfg` is follow.flight_settings(). +function follow.fly(p, drone, target, skater_forward, seconds, cfg, t, cast) + local position = drone.position + local dt = math.clamp(flight.finite(seconds) and seconds or 0, 0, 0.05) + local cmd = flight.command() + cmd.hold = true + cmd.throttle = flight.hover_of(cfg) + cmd.gimbal = p.tilt + if not (dt > 0) then return cmd end + + -- What the skater is doing: velocity from its moves (a jump of more than a few metres in one + -- tick is a respawn, not a move), and the way it is going. + if not p.valid or len(target - p.last_target) > 8 then + p.valid = true + p.target_velocity = ZERO + local f = V(skater_forward.x, 0, skater_forward.z) + p.heading = len(f) > 0.1 and norm(f) or V(0, 0, -1) + p.seen_count = 0 + p.ride_y = target.y + local flat = math.max(math.sqrt((target.x - position.x) ^ 2 + (target.z - position.z) ^ 2), 0.5) + p.tilt = math.clamp(math.atan2(target.y + 0.9 - position.y, flat), -1.2, 0.35) + p.climb_extra, p.blocked_for, p.floor_lift = 0, 0, 0 + p.history_count, p.history_clock = 0, 0 + p.session = false + p.nearby = {0, 0, 0, 0, 0} + else + local now = (target - p.last_target) / dt + local k = 1 - math.exp(-dt / 0.15) + p.target_velocity += (now - p.target_velocity) * k + -- A pilot follows the lie of the ground, not every ollie: height is eased toward slowly, + -- slower still going up (most rises are jumps that come straight back down). + local tau = target.y > p.ride_y and 1.4 or 0.6 + p.ride_y += (target.y - p.ride_y) * (1 - math.exp(-dt / tau)) + end + p.last_target = target + -- Remembered, so the pilot can act on what it saw a reaction time ago. + for i = 1, p.seen_count do + local s = p.seen[(p.seen_head - i) % SEEN + 1] + s.age += dt + end + p.seen_head = p.seen_head % SEEN + 1 + local level_velocity = V(p.target_velocity.x, 0, p.target_velocity.z) + p.seen[p.seen_head] = {age = 0, position = V(target.x, p.ride_y, target.z), velocity = level_velocity} + p.seen_count = math.min(p.seen_count + 1, SEEN) + local seen = p.seen[p.seen_head] + for back = 0, p.seen_count - 1 do + local s = p.seen[(p.seen_head - back - 1) % SEEN + 1] + seen = s + if s.age >= t.reaction then break end + end + -- Every 0.1 s: is it riding a session? + p.history_clock += dt + if p.history_clock >= 0.1 then + p.history_clock = math.min(p.history_clock - 0.1, 0.1) + p.history_head = p.history_head % HISTORY + 1 + p.history[p.history_head] = {position = target, velocity = level_velocity} + p.history_count = math.min(p.history_count + 1, HISTORY) + follow.read_session(p, position, t, cast) + end + if p.session then p.session_for += dt end + -- Heading: the way the skater moves once it moves at all, else the way it faces; turned toward + -- gently, as a pilot swings round behind. + do + local want = V(seen.velocity.x, 0, seen.velocity.z) + if len(want) < 1.5 then want = V(skater_forward.x, 0, skater_forward.z) end + if len(want) > 0.1 then + local k = 1 - math.exp(-dt * (len(seen.velocity) > 4 and 2.2 or 0.8)) + local blended = p.heading + (norm(want) - p.heading) * k + if len(blended) > 0.1 then p.heading = norm(blended) end + end + end + + -- Where to be: behind the skater, or in a session the spot it films from. A wall between the + -- skater and the spot behind brings the spot in to this side of it; one that leaves it too + -- close, or stands between the drone and its spot, is climbed over (a little, if it lasts). + -- The ground under the spot keeps it up. + local goal = p.session and p.session_spot or follow.spot(seen.position, seen.velocity, p.heading, t, p.climb_extra) + local chest = seen.position + V(0, 1, 0) + if cast then + local blocked = false + if not p.session then + local out = goal - chest + local out_length = len(out) + if out_length > 1 then + local dir = out / out_length + local hit = cast(chest + dir * 0.6, goal + dir * t.margin) + if hit then + local room = len(hit - chest) - t.margin + goal = chest + dir * math.max(room, 0) + blocked = room < t.distance * 0.5 + end + end + end + blocked = blocked or cast(position, goal) ~= nil + p.blocked_for = blocked and p.blocked_for + dt or 0 + if p.blocked_for > 0.3 then p.climb_extra = math.min(p.climb_extra + 1.5 * dt, 3) + else p.climb_extra = math.max(p.climb_extra - 0.8 * dt, 0) end + -- Ground under the spot (a bank, a ledge) lifts it, quickly up and slowly back down. + local lift = 0 + local ground = cast(goal + V(0, 2, 0), goal + V(0, -(t.height + 6), 0)) + if ground then lift = math.max(ground.y + t.clearance - goal.y, 0) end + p.floor_lift += (lift - p.floor_lift) * (1 - math.exp(-dt / (lift > p.floor_lift and 0.2 or 1))) + goal += V(0, p.floor_lift, 0) + end + + -- Speed: match the skater, closing the gap no faster than it can still slow down from gently by + -- the time it gets there; then lean to get it, no further than a pilot would. + local error_v = goal - position + local want_velocity = p.session and ZERO or seen.velocity + do + local level = V(error_v.x, 0, error_v.z) + local d = len(level) + if d > 0.01 then + local close = math.min(t.gap_gain * d, math.sqrt(2 * t.brake * d), t.catch_up) + want_velocity += level * (close / d) + end + end + -- Too close (the skater stopped short, or came back at it): back off as a pilot pulls away. + do + local now_at = seen.position + seen.velocity * t.reaction + local away = V(position.x - now_at.x, 0, position.z - now_at.z) + local gap, keep = len(away), t.distance * 0.7 + if gap < keep then + away = gap > 0.05 and away / gap or -p.heading + want_velocity += away * ((keep - gap) * 2) + end + end + local ceiling = false + if cast then want_velocity, ceiling = follow.avoid(p, drone, position, want_velocity, t, cast) end + local accel = (want_velocity - drone.velocity) * t.speed_gain + local g = math.max(cfg.gravity, 1) + local most = math.tan(math.clamp(t.max_lean, 5, 70) * DEG) * g + do + local a = math.sqrt(accel.x * accel.x + accel.z * accel.z) + if a > most then accel = V(accel.x * most / a, accel.y, accel.z * most / a) end + end + local forward = V(-drone.body.b.x, 0, -drone.body.b.z) + forward = len(forward) > 0.05 and norm(forward) or V(0, 0, -1) + local right = V(-forward.z, 0, forward.x) + local lean = math.clamp(cfg.max_angle, 1, 89) * DEG + -- Angle mode: pitch stick back (+) leans the nose up, so speeding up forward is stick forward. + local want_pitch = clamp1(-math.atan(dot(accel, forward) / g) / lean) + local want_roll = clamp1(math.atan(dot(accel, right) / g) / lean) + -- Height: eased toward, a few metres a second at most. The ground under the drone and just + -- ahead of where it is going comes first: it climbs off it (as fast as it must to clear a rise + -- ahead in time) and only sinks slowly near it, whatever its spot asks. + local climb_rate = math.max(cfg.climb_rate, 1) + local want_up = math.clamp(1.5 * error_v.y, -2.5, 3) + local ground_close = false + if cast then + local reach = t.clearance + 4 + local room, rise = 1e9, -1e9 + local hit = cast(position + V(0, 0.5, 0), position + V(0, -reach, 0)) + if hit then room = position.y - hit.y end + local going = V(drone.velocity.x, 0, drone.velocity.z) + local speed = len(going) + if speed > 1 then + local soon = 0.8 -- s ahead it looks + local at = position + going * math.min(soon, 10 / speed) + local ahead = cast(at + V(0, 3, 0), at + V(0, -reach, 0)) + if ahead then rise = (ahead.y + t.clearance - position.y) / soon end + end + if room < t.clearance then want_up = math.max(want_up, (t.clearance - room) * 4 + 0.5) end + if rise > 0 then want_up = math.max(want_up, rise) end + if want_up < 0 and room < t.clearance + 1.5 then want_up *= math.clamp((room - t.clearance) / 1.5, 0, 1) end + ground_close = room < t.clearance + end + local want_climb = clamp1(math.min(want_up, 8) / climb_rate) + if ceiling then want_climb = math.min(want_climb, ground_close and 0 or -1 / climb_rate) end -- something just above + -- Yaw: keep the skater in the middle of the shot, at a pilot's turn rate. + local want_yaw = 0 + do + local to = V(chest.x - position.x, 0, chest.z - position.z) + if len(to) > 0.5 then + to = norm(to) + local across = dot(cross(forward, to), UP) -- + = the skater is to the left + local angle = math.atan2(across, dot(forward, to)) + -- Far off the middle of the shot the pilot snaps round faster. + local gain = 3 + 3 * math.clamp(math.abs(angle) / 0.6, 0, 1) + want_yaw = math.clamp(angle * gain, -t.max_yaw_rate, t.max_yaw_rate) + end + end + + -- Hands: the sticks follow what the pilot wants smoothly, with a little wander. + p.wander_time -= dt + if p.wander_time <= 0 then + p.wander_time = 0.4 + 0.5 * (flight.noise(p) + 1) + p.wander_to = V(flight.noise(p), flight.noise(p), flight.noise(p)) + end + local w = 1 - math.exp(-dt / 0.35) + p.wander_now += (p.wander_to - p.wander_now) * w + local h = 1 - math.exp(-dt / math.max(t.hands, 0.01)) + p.pitch += (want_pitch + t.wander * p.wander_now.x - p.pitch) * h + p.roll += (want_roll + t.wander * p.wander_now.y - p.roll) * h + p.yaw += (want_yaw * (1 + 0.15 * p.wander_now.z) - p.yaw) * h + p.climb += (want_climb - p.climb) * h + if ground_close then p.climb = math.max(p.climb, want_climb) end -- (no easing into the ground) + + cmd.pitch = clamp1(p.pitch) + cmd.roll = clamp1(p.roll) + cmd.yaw_rad = p.yaw * dt -- + = turn left + cmd.climb = clamp1(p.climb) + -- The camera sits on a tilt gimbal, eased toward the skater, so leaning to speed up or slow + -- down does not swing the shot to the ground or the sky. It follows half of a jump; in a + -- session all of it (the airs are the shot). + do + local aim = p.ride_y + (p.session and 1 or 0.5) * (target.y - p.ride_y) + local to = V(target.x, aim + 0.9, target.z) - position + local level = math.sqrt(to.x * to.x + to.z * to.z) + local want_tilt = math.clamp(math.atan2(to.y, math.max(level, 0.5)), -1.2, 0.6) + p.tilt += (want_tilt - p.tilt) * (1 - math.exp(-dt / math.max(t.tilt_ease, 0.05))) + cmd.gimbal = p.tilt + end + return cmd +end + +return follow diff --git a/contrib/plugins/fpv-drone/plugin/main.lua b/contrib/plugins/fpv-drone/plugin/main.lua new file mode 100644 index 00000000..d247d857 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/main.lua @@ -0,0 +1,421 @@ +-- FPV Drone: fly a first-person drone over the map, or let it follow you and film while you skate. +-- The drone ReSkate had in its core (Extension/Skater/drone_*.h), as a plugin: flight.lua is the +-- flight model, follow.lua the autopilot, radio.lua the controllers, osd.lua the goggle display, +-- beacon.lua other players' drones, page.lua the menu panels and settings.lua the settings. +-- Console: fpv (fpv help lists what it does). +local settings = require("settings") +local flight = require("flight") +local radio = require("radio") +local follow = require("follow") +local osd = require("osd") +local beacon = require("beacon") +local page = require("page") + +local V = vector.create +local len = vector.magnitude + +-- What the console, the panels and the tick share. +local drone = { + mode = "off", -- "fly", "follow" or "off" + state = nil, -- the flight state (flight.lua) + pilot = nil, -- follow mode's autopilot + armed = true, -- the pilot's arm switch + reset = false, -- asked to go back to takeoff + problem = nil, -- why it could not start, for the panel + sticks = {}, -- what the sticks did last, for the OSD + calibration = radio.calibration(), + calibration_seen = -1e9, + switches = {}, -- the radio's switch memory + pad = {}, -- the gamepad's button memory + motor = nil, -- the motor sound's id + motor_retry = 0, + hid_hud = false, + camera_problem = nil, +} + +-- ---- the world ---------------------------------------------------------------------------------- +-- reskate.world.raycast with this plugin's own cap a tick (the host stops at 64). +local rays = 0 +local function cast(from, to) + rays += 1 + if rays > 56 then return nil end + local ok, hit = pcall(reskate.world.raycast, from, to) + if ok then return hit end + return nil +end + +-- ---- goggles: eyewear on the pilot's skater while the drone flies -------------------------------- +local eyewear = { + "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00001", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00002", + "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00003", "Own_EyewearGlasses_Gen_SunglassesVisorLarge_00004", + "Own_EyewearGlasses_Gen_ShieldAviator_00001", "Own_EyewearGlasses_Gen_WrapAround_00001", + "Own_EyewearGlasses_Gen_SunglassesWraparoundSlim_00001", "Own_EyewearGlasses_Dime_SpeedShades_00001", +} +local goggles = {worn = nil, last_flying = -1e9, retry = 0, said = nil} +local function goggles_tick(now) + if drone.mode ~= "off" then goggles.last_flying = now end + if now < goggles.retry then return end + -- A moment's grace, so a reset or a pause does not take them off and on again. + local choice = math.round(settings.get("goggles")) + local want = now - goggles.last_flying < 1500 and choice >= 1 and eyewear[choice] or nil + if want == goggles.worn then return end + if want then + local ok, done, why = pcall(reskate.outfit.set, "eyewear", want) + if ok and done then + goggles.worn = want + else + goggles.retry = now + 500 + local said = tostring(ok and why or done) + if said ~= goggles.said then + goggles.said = said + reskate.log("goggles: " .. said, "warn") + end + end + else + pcall(reskate.outfit.clear, "eyewear") + goggles.worn = nil + end +end + +-- ---- the motor ----------------------------------------------------------------------------------- +local function motor_tick(now) + local s = drone.state + local volume = settings.get("volume") + if drone.mode == "off" or not s or volume <= 0.001 then + if drone.motor then + pcall(reskate.audio.stop, drone.motor) + drone.motor = nil + end + return + end + -- Heard while armed, as the C++ drone was. + local options = {position = s.position, throttle = math.clamp(s.throttle, 0, 1), speed = math.clamp(s.speed, 0, 200), + volume = s.armed_now ~= false and volume or 0, pitch = math.clamp(settings.get("motorpitch"), 0.25, 4)} + if drone.motor then + local ok, still = pcall(reskate.audio.set, drone.motor, options) + if not (ok and still) then drone.motor = nil end + elseif now >= drone.motor_retry then + options.loop = true + local ok, id = pcall(reskate.audio.play, "motor", options) + if ok and id then drone.motor = id else drone.motor_retry = now + 1000 end + end +end + +-- ---- flying -------------------------------------------------------------------------------------- +local function hide_hud(on) + if on == drone.hid_hud then return end + pcall(reskate.hud.hide, on) + drone.hid_hud = on +end + +function drone.stop() + if drone.mode == "off" then return end + drone.mode = "off" + pcall(reskate.input.capture, false) + pcall(reskate.camera.release) + hide_hud(false) + drone.problem = nil +end + +-- Takes off from where the game's camera is: "fly" (the pilot flies it) or "follow" (it follows +-- the skater). False and why when it cannot. +function drone.start(mode) + local view = reskate.camera.get() + if not view or not reskate.level.current() then + drone.problem = "Load into a level to fly." + return false, drone.problem + end + if mode == "follow" and not reskate.skater.get() then + drone.problem = "Follow me needs your skater." + return false, drone.problem + end + if drone.mode ~= "off" then drone.stop() end + drone.problem = nil + drone.armed = true + drone.reset = false + local cfg = settings.flight(true) + drone.state = flight.new_state(view.position, view.forward, true, cfg) + drone.pilot = mode == "follow" and follow.pilot() or nil + drone.sticks = {} + drone.mode = mode + hide_hud(settings.on("hideui")) + local ok, why = reskate.camera.set(view.position, view.forward, V(0, 1, 0), 0) + if not ok then + drone.stop() + drone.problem = "The camera is not free: " .. tostring(why) + return false, drone.problem + end + return true +end + +local function calibrating(now) + return drone.calibration:active() and now - drone.calibration_seen < 500 +end + +-- The pilot's command this tick: from the controller when it has one, else the keyboard and +-- mouse. Nil while there is nothing to fly with (the menu is open with the keyboard), and the +-- drone holds still meanwhile. +local function pilot_command(seconds, now, cfg) + local input = reskate.input.flight() -- nil while the menu has the input, or the camera is not ours + local menu_closed = input ~= nil + if settings.controller.enabled then + if settings.on("gamepad") then + local pad = reskate.input.gamepad() + if pad then + -- While the menu is open the pad is the menu's: the drone hovers and the buttons do nothing. + local toggle_arm, reset = radio.pad_buttons(drone.pad, pad.buttons, menu_closed) + if toggle_arm then drone.armed = not drone.armed end + if reset then drone.reset = true end + local used = menu_closed and pad or {left_x = 0, left_y = 0, right_x = 0, right_y = 0} + local v = settings.values + return radio.gamepad_sticks(used, v.deadzone, v.padrate, v.padexpo, v.hover, v.padinvert >= 0.5, v.mode >= 0.5) + end + else + local stick = reskate.input.joystick() + if stick then + if calibrating(now) then return "hold" end -- the setup is moving the sticks and switches + local v = settings.values + local bits = radio.button_bits(stick.buttons) + local armed, reset = radio.switches(drone.switches, stick.axes, bits, v.armaxis, v.armbutton, v.resetaxis, v.resetbutton) + if armed ~= nil then drone.armed = armed end + if reset then drone.reset = true end + return radio.map_axes(stick.axes, settings.controller.map, v.deadzone, v.throttlecentre >= 0.5, v.hover) + end + end + end + if not input then return nil end + local cmd, shown = flight.keyboard_command(input, cfg) + return cmd, shown +end + +local function fly_tick(seconds, now) + local s = drone.state + local cfg + local cmd + if drone.mode == "fly" then + pcall(reskate.input.capture, true) + cfg = settings.flight(drone.armed) + local got, shown = pilot_command(seconds, now, cfg) + if got == "hold" then got = nil + elseif got and got.valid ~= nil then + -- Sticks from a controller. + cmd, shown = flight.radio_command(got, seconds, cfg) + else + cmd = got + end + cfg.armed = drone.armed + if shown then drone.sticks = shown end + else + local skater = reskate.skater.get() + local p = drone.pilot + local target = skater and skater.position or p.last_target + local facing = skater and V(math.sin(skater.heading), 0, math.cos(skater.heading)) or p.heading + local t = follow.tuning(settings.get("followdistance"), settings.get("followaggression")) + cfg = follow.flight_settings(settings.flight(true), t) + if skater and len(target - s.position) > 60 then + -- Left far behind (a respawn, a teleport): it is back where it should be. + local spot = follow.spot(target, V(0, 0, 0), p.heading, t, 0) + flight.start(s, spot, target - spot, true, cfg) + drone.pilot = follow.pilot() + p = drone.pilot + end + cmd = follow.fly(p, s, target, facing, seconds, cfg, t, cast) + drone.sticks = {roll = cmd.roll, pitch = -cmd.pitch, yaw = math.clamp(-cmd.yaw_rad / math.max(seconds, 1e-3) / t.max_yaw_rate, -1, 1), + throttle = cmd.climb} + end + if drone.reset then + flight.reset(s, cfg.armed, cfg) + drone.reset = false + end + if cmd then + flight.step(s, cmd, seconds, cfg, cast) + if s.crashed_now then drone.armed = false end + end + local camera = s.camera + local ok, done, why = pcall(reskate.camera.set, s.position, -camera.b, camera.u, 0) + if ok and done then drone.camera_problem = nil else drone.camera_problem = tostring(ok and why or done) end +end + +-- ---- the tick ------------------------------------------------------------------------------------ +local perf = {total = 0, ticks = 0, worst = 0, rays = 0} +local clock = os and os.clock or function() return reskate.time.now() / 1000 end + +reskate.on("tick", function(dt_ms) + local started = clock() + rays = 0 + local now = reskate.time.now() + local seconds = math.clamp((dt_ms or 16) / 1000, 0, 0.05) + if drone.mode ~= "off" then + if not reskate.level.current() then drone.stop() + else fly_tick(seconds, now) end + end + goggles_tick(now) + motor_tick(now) + -- Other players: where this drone is, about 12 times a second, and theirs. + local share = drone.mode ~= "off" and settings.on("beacon") + local message = beacon.outgoing(now, share and drone.state or nil, share) + if message then pcall(reskate.net.send, "all", message) end + beacon.tick(now, settings.on("showbeacons"), settings.get("volume")) + local spent = (clock() - started) * 1000 + perf.total += spent + perf.ticks += 1 + perf.rays += math.min(rays, 56) + perf.worst = math.max(perf.worst, spent) +end) + +reskate.on("message", function(from, payload) + if from and not from.me then beacon.receive(from, payload, reskate.time.now()) end +end) +reskate.on("player_left", function(player) + beacon.forget(player.id) +end) +reskate.on("level_unloading", function() + drone.stop() + beacon.clear() +end) + +reskate.on("draw", function(d) + local started = clock() + local size = d:size() + local w, h = size.x, size.y + if w <= 0 or h <= 0 then return end + local s = drone.state + local now = reskate.time.now() + local flying = drone.mode ~= "off" and s ~= nil + -- The analogue look under everything, so the markers and the display stay crisp. + if flying then osd.analog(d, w, h, s, settings.values, now / 1000) end + if settings.on("showbeacons") and beacon.count() > 0 then + local view = reskate.camera.get() + if flying then view = {position = s.position, forward = -s.camera.b, fov = view and view.fov or 60} end + beacon.draw(d, w, h, view, now) + end + if flying then + s.sticks = drone.sticks + osd.draw(d, osd.layout(s, settings.values), w, h) + end + perf.total += (clock() - started) * 1000 +end) + +-- ---- the console --------------------------------------------------------------------------------- +local function say(text) + reskate.console.print(text) +end +local function show_setting(name) + local info = settings.info[name] + say(string.format("%-16s %-8g %s (default %g, range %g-%g)", name, settings.get(name), info.help, info.def, info.lo, info.hi)) +end + +local commands = {} +commands.help = function() + say("fpv fly | stop | follow: start flying, land, or let the drone follow you while you skate") + say("fpv arm | disarm | reset: motors on, motors off, back to where it took off") + say("fpv tune [setting [value]] | tune reset: list, show or change the settings; reset puts them all back") + say("fpv preset <1-3>: Tinywhoop, Toothpick or 5 inch") + say("fpv controller on|off | axes | check: the controller") + say("fpv status | perf | test") + say("The panels on ADDONS > PLUGINS have every setting.") +end +commands.fly = function() + local ok, why = drone.start("fly") + say(ok and "fpv: flying. W/S tilt forward/back, A/D roll, E/Q throttle, mouse turns, Shift boosts. fpv stop lands." or ("fpv: " .. why)) +end +commands.follow = function() + local ok, why = drone.start("follow") + say(ok and "fpv: the drone follows you. Skate as normal; fpv stop lands it." or ("fpv: " .. why)) +end +commands.stop = function() + drone.stop() + say("fpv: landed.") +end +commands.arm = function() + drone.armed = true + say("fpv: armed.") +end +commands.disarm = function() + drone.armed = false + say("fpv: disarmed. It falls (or hangs, with fpv tune disarmfreeze 1).") +end +commands.reset = function() + drone.reset = true + say("fpv: back to takeoff.") +end +commands.tune = function(args) + local name, value = args[2], args[3] + if not name then + for _, entry in settings.list do show_setting(entry[1]) end + return + end + if name == "reset" then + settings.reset() + return say("fpv: every setting is back to its default.") + end + if not settings.info[name] then return say("fpv: there is no setting called '" .. name .. "'. fpv tune lists them.") end + if not value then return show_setting(name) end + local wanted = tonumber(value) + if not wanted then return say("fpv: " .. value .. " is not a number.") end + local got = settings.set(name, wanted) + say(got == wanted and string.format("%s = %g", name, got) or string.format("%s = %g (the closest allowed value)", name, got)) +end +commands.preset = function(args) + local n = tonumber(args[2] or "") + if not n or not settings.apply_preset(n) then return say("fpv preset 1 (Tinywhoop), 2 (Toothpick) or 3 (5 inch)") end + say("fpv: " .. settings.presets[n].name .. ".") +end +commands.controller = function(args) + if args[2] == "on" or args[2] == "off" then settings.set_controller(args[2] == "on") end + say("fpv: controller " .. (settings.controller.enabled and "on" or "off") .. ", " .. (settings.on("gamepad") and "gamepad" or "RC radio")) +end +commands.axes = function(args) + if #args >= 5 then settings.set_controller(nil, {tonumber(args[2]), tonumber(args[3]), tonumber(args[4]), tonumber(args[5])}) end + local m = settings.controller.map + say(string.format("fpv: roll %d, pitch %d, throttle %d, yaw %d", m[1], m[2], m[3], m[4])) +end +commands.check = function() + local stick, pad = reskate.input.joystick(), reskate.input.gamepad() + if stick then + say(string.format("fpv: %s axes %+.2f %+.2f %+.2f %+.2f %+.2f %+.2f", stick.name, stick.axes[1], stick.axes[2], stick.axes[3], + stick.axes[4], stick.axes[5], stick.axes[6])) + local v = settings.values + local s = radio.map_axes(stick.axes, settings.controller.map, v.deadzone, v.throttlecentre >= 0.5, v.hover) + say(string.format("fpv: read as roll %+.2f pitch %+.2f throttle %.2f yaw %+.2f", s.roll, s.pitch, s.throttle, s.yaw)) + else + say("fpv: no RC radio") + end + say(pad and string.format("fpv: gamepad L %+.2f %+.2f R %+.2f %+.2f", pad.left_x, pad.left_y, pad.right_x, pad.right_y) or "fpv: no gamepad") +end +commands.status = function() + local s = drone.state + say(string.format("fpv: %s, %s, %d other drones", drone.mode, drone.armed and "armed" or "disarmed", beacon.count())) + if drone.mode ~= "off" and s then + say(string.format("fpv: %.0f%% throttle, %.0f km/h, %.1f m up, %.0f s%s", s.throttle * 100, s.speed * 3.6, s.altitude, + s.flight_time, drone.camera_problem and (", camera: " .. tostring(drone.camera_problem)) or "")) + end + if goggles.said then say("fpv: goggles: " .. goggles.said) end +end +-- Its own time a tick (tick and draw), against the 2 ms a plugin has. +commands.perf = function() + if perf.ticks == 0 then return say("fpv perf: no ticks yet") end + say(string.format("fpv perf: %.3f ms a tick on average over %d ticks (its budget is 2 ms), %.3f ms the slowest tick handler, %.1f raycasts a tick", + perf.total / perf.ticks, perf.ticks, perf.worst, perf.rays / perf.ticks)) + perf.total, perf.ticks, perf.worst, perf.rays = 0, 0, 0, 0 +end +commands.test = function() + require("tests").run(say) +end + +reskate.console.command("fpv", "The FPV drone: fpv fly, fpv follow, fpv stop, fpv tune (fpv help for the rest)", function(args) + local run = commands[args[1] or "help"] + if not run then return say("fpv: there is no " .. tostring(args[1]) .. " (fpv help lists them)") end + run(args) +end) + +reskate.on("unload", function() + drone.stop() + beacon.clear() + pcall(reskate.outfit.clear, "eyewear") + if drone.motor then pcall(reskate.audio.stop, drone.motor) end +end) + +settings.load() +page.install(drone) +reskate.log("FPV Drone loaded: fpv fly, or ADDONS > PLUGINS") diff --git a/contrib/plugins/fpv-drone/plugin/osd.lua b/contrib/plugins/fpv-drone/plugin/osd.lua new file mode 100644 index 00000000..2f9dbce4 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/osd.lua @@ -0,0 +1,274 @@ +-- The goggle display (OSD), sticks, crosshair and analogue video look, as +-- Extension/Skater/drone_osd.h and UI/Overlay/drone_hud_overlay.cpp had them. layout() decides what +-- to show (plain, for the tests); draw() puts it on the overlay. The pixel font and the static are +-- pictures drawn by source rectangle (images/, made by contrib/plugins/tools/build_fpv_drone_images.py). +local flight = require("flight") +local osd = {} + +local V = vector.create +local FONT, STATIC, SCAN, VIGNETTE = "images/font.png", "images/static.png", "images/scanlines.png", "images/vignette.png" + +local function rgba(r, g, b, a) + return ((math.floor(r) * 256 + math.floor(g)) * 256 + math.floor(b)) * 256 + math.clamp(math.floor(a), 0, 255) +end +osd.rgba = rgba +local function alpha(a) + return math.clamp(a, 0, 1) * 255 +end + +-- The `osdcolor` presets: white, green, yellow, cyan, orange, pink. +osd.colours = {{255, 255, 255}, {90, 255, 90}, {255, 232, 70}, {90, 232, 255}, {255, 150, 50}, {255, 100, 220}} +function osd.colour(index, warn, a) + if warn then return rgba(255, 60, 60, a or 255) end + local c = osd.colours[math.clamp(math.round(index), 0, 5) + 1] + return rgba(c[1], c[2], c[3], a or 255) +end + +-- What to show: items {x, y (fractions of the screen), align ("left", "centre", "right"), bottom +-- (y is the text's bottom), warn (red), text}, and the sticks. `t` is the flight state (flight.lua) +-- with `sticks` from the last command; `v` the settings' values. +function osd.layout(t, v) + local out = {items = {}, scale = v.osdscale, font = v.osdfont >= 0.5 and 1 or 0, colour = math.round(v.osdcolor), + margin = math.clamp(v.osdmargin, 0, 0.3), crosshair = v.crosshair >= 0.5} + local m = out.margin + local function add(on, x, y, align, bottom, text, warn) + if on then table.insert(out.items, {x = x, y = y, align = align, bottom = bottom, text = text, warn = warn or false}) end + end + local throttle, seconds = t.throttle, t.flight_time + local volts = flight.battery_volts(seconds, throttle) + local low = volts < 14 + local blink = math.floor(seconds * 2) % 2 == 1 + local total = math.floor(seconds) + -- The goggle display as a whole (`osd`): off hides every readout. The range warnings still + -- show, since flying on without them loses the drone. + local shown = v.osd >= 0.5 + add(shown and v.osdbattery >= 0.5, m, m, "left", false, string.format("%.1fV", volts), low and blink) + local armed = t.armed_now ~= false + add(shown and v.osdarmed >= 0.5, 0.5, m, "centre", false, armed and "ARMED" or "DISARMED", not armed) + add(shown and v.osdtimer >= 0.5, 1 - m, m, "right", false, string.format("%02d:%02d", total // 60, total % 60)) + add(shown and v.osdthrottle >= 0.5, m, 1 - m, "left", true, string.format("THR %.0f%%", throttle * 100)) + add(shown and v.osdalt >= 0.5, 0.5, 1 - m, "centre", true, string.format("ALT %.1fM", t.altitude)) + add(shown and v.osdspeed >= 0.5, 1 - m, 1 - m, "right", true, string.format("%.0fKM/H", t.speed * 3.6)) + -- A crash cut the motors: say how to fly on (with or without the goggle display). + if t.crashed then add(true, 0.5, 0.42, "centre", false, "CRASHED - ARM TO FLY AGAIN", true) end + -- Range: link quality and the way home, and what happens at the edge. + if v.range > 0 then + add(shown, 1 - m, m + 0.05, "right", false, string.format("RSSI %.0f%% %.0fM", t.signal * 100, t.home), t.signal < 0.35 and blink) + if t.failsafe then + add(true, 0.5, t.crashed and 0.47 or 0.42, "centre", false, "FAILSAFE - RESET TO FLY AGAIN", true) + elseif t.signal <= 0 then + local left = math.max(0, flight.failsafe_after - t.signal_lost) + add(true, 0.5, 0.42, "centre", false, string.format("NO SIGNAL - TURN BACK (%.0f)", math.ceil(left)), true) + end + end + local sticks = t.sticks or {} + out.sticks = {show = v.sticks >= 0.5, size = v.sticksize, yaw = sticks.yaw or 0, roll = sticks.roll or 0, pitch = sticks.pitch or 0} + -- A throttle stick that springs back is shown where it is; one that stays put, by the throttle. + out.sticks.throttle = sticks.throttle and math.clamp(sticks.throttle, -1, 1) or math.clamp(throttle, 0, 1) * 2 - 1 + return out +end + +-- ---- the pixel font ----------------------------------------------------------------------------- +local font = nil -- font.json, read once +local glyph_uv = {} -- character: {fill uv, outline uv} +local function load_font() + if font then return font end + local ok, data = pcall(reskate.package.read_json, "images/font.json") + font = ok and data or {order = "", columns = 1, cell = {36, 48}, pad = 1, size = {1, 1}, outline_offset = 0} + local w, h = font.size[1], font.size[2] + local cw, ch, pad = font.cell[1], font.cell[2], font.pad + local function uv(cell) + local x0 = (cell % font.columns) * (cw + 2 * pad) + pad + local y0 = (cell // font.columns) * (ch + 2 * pad) + pad + return {x0 / w, y0 / h, (x0 + cw) / w, (y0 + ch) / h} + end + for i = 1, #font.order do + local c = string.sub(font.order, i, i) + glyph_uv[c] = {uv(i - 1), uv(i - 1 + font.outline_offset)} + end + return font +end +osd.load_font = load_font + +-- Width of a pixel-font string, `px` being one font pixel on screen. +local function pixel_width(text, px) + if #text == 0 then return 0 end + return (#text * 6 - 1) * px +end +osd.pixel_width = pixel_width + +local TALL = 1.45 -- goggle glyphs are taller than wide +-- The text at `x, y` (its top left): every outline first, then every glyph, as the C++ drew it. +local function pixel_text(d, x, y, text, px, colour, shadow) + load_font() + text = string.upper(text) + for pass = 2, 1, -1 do + local style = {colour = pass == 2 and shadow or colour, uv = nil} + for i = 1, #text do + local uv = glyph_uv[string.sub(text, i, i)] + if uv then + local left = x + (i - 1) * 6 * px - 0.5 * px + local top = y - 0.5 * px * TALL + style.uv = uv[pass] + d:image(FONT, V(left, top, 0), V(left + 6 * px, top + 8 * px * TALL, 0), style) + end + end + end +end +osd.pixel_text = pixel_text + +local function draw_stick(d, k, cx, cy, half, x, y, colour, shadow) + local t = math.max(1.5, half * 0.04) + local lo, hi = V(cx - half, cy - half, 0), V(cx + half, cy + half, 0) + d:rect(lo, hi, {colour = rgba(0, 0, 0, 70), fill = true}) + d:rect(lo, hi, {colour = shadow, thickness = t * 2.4 / k}) + d:rect(lo, hi, {colour = colour, thickness = t / k}) + d:line(V(cx - half, cy, 0), V(cx + half, cy, 0), {colour = rgba(255, 255, 255, 60), thickness = t * 0.6 / k}) + d:line(V(cx, cy - half, 0), V(cx, cy + half, 0), {colour = rgba(255, 255, 255, 60), thickness = t * 0.6 / k}) + local limit = half * 0.86 + local dot = V(cx + math.clamp(x, -1, 1) * limit, cy - math.clamp(y, -1, 1) * limit, 0) + local radius = half * 0.12 + d:circle(dot, radius + t, {colour = shadow, fill = true}) + d:circle(dot, radius, {colour = colour, fill = true}) +end + +-- ---- the analogue look ------------------------------------------------------------------------ +local rng = 0x9e3779b9 +local function random() + rng = bit32.bxor(rng, bit32.lshift(rng, 13)) + rng = bit32.bxor(rng, bit32.rshift(rng, 17)) + rng = bit32.bxor(rng, bit32.lshift(rng, 5)) + return bit32.band(rng, 0xffffff) / 16777216 +end + +-- The static picture over the screen in tiles of a random part of it each, so it moves every frame. +local function static_layer(d, w, h, a) + if a <= 0 then return end + local columns = 4 + local rows = 2 + local tw, th = w / columns, h / rows + local span_x = math.min(1, tw / (512 * 1.6 * h / 1080)) -- about 1.6 screen pixels a speck at 1080 lines + local span_y = math.min(1, th / (512 * 1.6 * h / 1080)) + local colour = rgba(255, 255, 255, alpha(a)) + for r = 0, rows - 1 do + for c = 0, columns - 1 do + local u, v = random() * (1 - span_x), random() * (1 - span_y) + d:image(STATIC, V(c * tw, r * th, 0), V((c + 1) * tw, (r + 1) * th, 0), {colour = colour, uv = {u, v, u + span_x, v + span_y}}) + end + end +end + +-- Scanlines, static, dark corners and rolling glitches drawn over the game; and the picture +-- breaking up near the range's edge or after a hit, with or without the look. +function osd.analog(d, w, h, t, v, time) + local signal = math.clamp(t.signal or 1, 0, 1) + local breakup = math.max(1 - signal, math.clamp(t.glitch or 0, 0, 1)) + local look = v.analog >= 0.5 + if not look and breakup <= 0 then return end + local noise = math.max(look and math.clamp(v.analognoise, 0, 1) or 0, breakup) + local scan = look and math.clamp(v.analogscan, 0, 1) or 0 + local vignette = math.max(look and math.clamp(v.analogvignette, 0, 1) or 0, breakup * 0.6) + local glitch = math.max(look and math.clamp(v.analogglitch, 0, 1) or 0, breakup) + local unit = h / 1080 + local full0, full1 = V(0, 0, 0), V(w, h, 0) + + -- A cold, slightly washed-out cast, and a faint brightness flicker. + local flicker = 0.02 + 0.02 * math.sin(time * 37) + 0.02 * random() + d:rect(full0, full1, {colour = rgba(40, 70, 70, alpha(0.05 + 0.07 * scan)), fill = true}) + d:rect(full0, full1, {colour = rgba(0, 0, 0, alpha(flicker * (0.4 + noise))), fill = true}) + -- Scanlines: 512 dark lines down the picture (one every other line of 1080), the picture's 256 + -- in each half. + if scan > 0 then + local colour = rgba(255, 255, 255, alpha(0.10 + 0.35 * scan)) + d:image(SCAN, V(0, 0, 0), V(w, h * 0.5, 0), {colour = colour}) + d:image(SCAN, V(0, h * 0.5, 0), V(w, h, 0), {colour = colour}) + end + if vignette > 0 then d:image(VIGNETTE, full0, full1, {colour = rgba(255, 255, 255, alpha(0.65 * vignette))}) end + -- Static: specks, and short streaks. + if noise > 0 then + static_layer(d, w, h, 0.25 + 0.75 * noise) + for _ = 1, math.floor(14 * noise) do + local x, y = random() * w, random() * h + local length = (20 + random() * 160) * unit + d:rect(V(x, y, 0), V(x + length, y + math.max(1, unit), 0), {colour = rgba(220, 235, 235, alpha(0.10 + 0.2 * noise)), fill = true}) + end + end + -- Glitches: a pale band rolling down the picture, and now and then a burst of torn lines. + if glitch > 0 then + local cycle = 5 + local phase = (time % cycle) / cycle + local band = h * 0.09 + local y = -band + phase * (h + band * 2) + d:rect(V(0, y, 0), V(w, y + band, 0), {colour = rgba(200, 230, 230, alpha(0.05 + 0.07 * glitch)), fill = true}) + d:rect(V(0, y + band * 0.45, 0), V(w, y + band * 0.55, 0), {colour = rgba(255, 255, 255, alpha(0.04 + 0.06 * glitch)), fill = true}) + if time % 7 < 0.25 * (0.4 + glitch) then + for _ = 1, 3 + math.floor(8 * glitch) do + local ty = random() * h + local th = (2 + random() * 10) * unit + local shift = (random() - 0.5) * w * 0.08 * glitch + d:rect(V(math.max(0, shift), ty, 0), V(w + math.min(0, shift), ty + th, 0), {colour = rgba(230, 240, 240, alpha(0.10 + 0.18 * glitch)), fill = true}) + d:rect(V(0, ty + th, 0), V(w, ty + th + math.max(1, unit), 0), {colour = rgba(0, 0, 0, alpha(0.35 * glitch)), fill = true}) + end + end + end + -- Range: as the link weakens the picture sinks under grey snow, until only snow is left. + if breakup > 0 then + local b = math.clamp(breakup, 0, 1) + local fade = b * b * (3 - 2 * b) + d:rect(full0, full1, {colour = rgba(24, 26, 26, alpha(0.92 * fade * fade)), fill = true}) + local layers = math.ceil(4 * b ^ 1.4) + for _ = 1, layers do static_layer(d, w, h, 0.4 + 0.6 * b) end + for _ = 1, math.floor(40 * b * b) do + local y, lh = random() * h, (1 + random() * 3) * unit + local g = 150 + random() * 100 + d:rect(V(0, y, 0), V(w, y + lh, 0), {colour = rgba(g, g, g, alpha(0.08 + 0.3 * b * random())), fill = true}) + end + end +end + +-- The goggle display, crosshair and sticks. +function osd.draw(d, layout, w, h) + local k = h / 1080 + local px = math.max(1, layout.scale * 2 * k) -- one font pixel + local text_height = 7 * px * TALL + local shadow = rgba(0, 0, 0, 220) + local normal = osd.colour(layout.colour, false) + for _, item in layout.items do + local colour = osd.colour(layout.colour, item.warn) + local width + if layout.font == 0 then width = pixel_width(item.text, px) + else width = #item.text * text_height * 0.55 end + local x, y = item.x * w, item.y * h + if item.bottom then y -= text_height end + if layout.font == 0 then + if item.align == "centre" then x -= width * 0.5 elseif item.align == "right" then x -= width end + pixel_text(d, x, y, item.text, px, colour, shadow) + else + -- The game's own font: the overlay aligns it. + d:text(V(x, y, 0), item.text, {colour = colour, size = text_height / k, align = item.align, shadow = true}) + end + end + if layout.crosshair then + local arm, gap = 14 * k * layout.scale, 4 * k * layout.scale + local t = math.max(2, 2 * k * layout.scale) + local cx, cy = w * 0.5, h * 0.5 + for pass = 1, 2 do + local style = {colour = pass == 1 and shadow or normal, thickness = (pass == 1 and t * 2.2 or t) / k} + d:line(V(cx - gap - arm, cy, 0), V(cx - gap, cy, 0), style) + d:line(V(cx + gap, cy, 0), V(cx + gap + arm, cy, 0), style) + d:line(V(cx, cy - gap - arm, 0), V(cx, cy - gap, 0), style) + d:line(V(cx, cy + gap, 0), V(cx, cy + gap + arm, 0), style) + end + end + local sticks = layout.sticks + if sticks.show then + -- Mode 2: left stick yaw (side to side) and throttle (up and down); right stick roll and pitch. + local half = 0.5 * 0.17 * h * math.clamp(sticks.size, 0.4, 3) + local margin = layout.margin * w + local cy = h * (1 - layout.margin) - text_height * 1.6 - half + draw_stick(d, k, margin + half, cy, half, sticks.yaw, sticks.throttle, normal, shadow) + draw_stick(d, k, w - margin - half, cy, half, sticks.roll, sticks.pitch, normal, shadow) + end +end + +return osd diff --git a/contrib/plugins/fpv-drone/plugin/page.lua b/contrib/plugins/fpv-drone/plugin/page.lua new file mode 100644 index 00000000..82487c4a --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/page.lua @@ -0,0 +1,368 @@ +-- The drone's menu panels on ADDONS > PLUGINS, as UI/Overlay/drone_page.cpp's DRONE page had them: +-- flying, flight and rates, the display, and the controller with its guided setup. Every change +-- takes effect at once and is saved (settings.set). +local settings = require("settings") +local radio = require("radio") +local page = {} + +local eyewear_labels = {"None", "Large visor 1", "Large visor 2", "Large visor 3", "Large visor 4", "Shield aviators", + "Wraparounds", "Slim wraparounds", "Speed shades"} + +-- A slider hands its value back as a float: only a real change is one. +local function moved(got, value) + return math.abs(got - value) > 1e-5 * math.max(1, math.abs(value)) +end +local function slider(ui, name, text) + local info = settings.info[name] + local value = settings.get(name) + local got = ui:slider(name, text, value, info.lo, info.hi) + if moved(got, value) then settings.set(name, got) end +end +local function whole(ui, name, text) + local info = settings.info[name] + local value = settings.get(name) + local got = math.round(ui:slider(name, text, value, info.lo, info.hi)) + if got ~= value then settings.set(name, got) end +end +local function toggle(ui, name, text) + local on = settings.on(name) + local got = ui:toggle(name, text, on) + if got ~= on then settings.set(name, got and 1 or 0) end +end +local function choice(ui, name, text, choices) + local value = math.clamp(math.round(settings.get(name)), 0, #choices - 1) + local got = ui:dropdown(name, text, choices, value + 1) - 1 + if got ~= value then settings.set(name, got) end +end + +-- `drone` is main.lua's: mode, armed, state, and what the page asks of it (start, stop, follow, +-- arm, reset). +function page.install(drone) + reskate.menu.panel("FPV Drone", function(ui) + local flying, following = drone.mode == "fly", drone.mode == "follow" + ui:heading("FLY") + if ui:button("fly", flying and "STOP FLYING" or "START FLYING") then + if flying then drone.stop() else drone.start("fly") end + end + if drone.problem then ui:label(drone.problem) end + local controller = settings.controller.enabled + if flying then + if not controller then ui:label("Flying with the keyboard and mouse. Close the menu to fly.") + elseif settings.on("gamepad") then + ui:label(reskate.input.gamepad() and "Flying with the gamepad. Close the menu to fly." + or "No gamepad found: flying with the keyboard and mouse. Close the menu to fly.") + else ui:label("Flying with the controller. Close the menu to fly.") end + else + ui:label("Keyboard: W/S tilt forward/back, A/D roll, E/Q throttle up/down, mouse turns, Shift boosts.") + end + local wanted = ui:toggle("controller", "Fly with a controller", controller) + if wanted ~= controller then settings.set_controller(wanted) end + if controller then + choice(ui, "gamepad", "Controller", {"RC radio", "Gamepad"}) + if settings.on("gamepad") then + ui:label("Left stick: throttle (the middle hovers) and turning. Right stick: tilt forward, back and sideways. " + .. "A (cross) arms and disarms, Menu (Options) puts the drone back.") + end + elseif reskate.input.gamepad() or reskate.input.joystick() then + ui:label("A controller is connected. Turn on Fly with a controller to use it.") + end + toggle(ui, "hideui", "Hide the game's interface while flying or following") + + ui:heading("FOLLOW ME") + if ui:button("follow", following and "STOP FOLLOWING" or "FOLLOW ME") then + if following then drone.stop() else drone.start("follow") end + end + ui:label(following and "Skate as normal: the drone flies itself behind you, as a pilot would." + or "The drone takes off behind you and flies itself, keeping you in shot while you skate. " + .. "It uses your drone's settings, flown self-levelling.") + slider(ui, "followdistance", "Follow distance (m)") + slider(ui, "followaggression", "Aggressiveness") + + ui:heading("DRONE") + local current = settings.matching_preset() + local names = {"Custom"} + for _, preset in settings.presets do table.insert(names, preset.name) end + local picked = ui:dropdown("preset", "Preset", names, (current or 0) + 1) + if picked ~= (current or 0) + 1 and picked > 1 then + settings.apply_preset(picked - 1) + current = picked - 1 + end + ui:label(current and settings.presets[current].description or "Your own settings. Pick a preset to start from one of them.") + local worn = math.clamp(math.round(settings.get("goggles")), 0, 8) + local goggles = ui:dropdown("goggles", "Goggles", eyewear_labels, worn + 1) - 1 + if goggles ~= worn then settings.set("goggles", goggles) end + slider(ui, "topspeed", "Top speed") + ui:label(string.format("Flat out at about %.0f km/h (a little faster with boost).", settings.get("topspeed") * 4 * 3.6)) + + ui:heading("MOTORS") + local armed = drone.armed + ui:label("Motors: " .. (armed and "ARMED" or "DISARMED")) + local s = drone.state + if drone.mode ~= "off" and s then + ui:label(string.format("%.0f%% throttle, %.0f km/h, %.1f m up, %.0f s", s.throttle * 100, s.speed * 3.6, s.altitude, s.flight_time)) + end + if ui:button("arm", armed and "Disarm" or "Arm") then drone.armed = not armed end + if ui:button("takeoff", "Back to takeoff") then drone.reset = true end + if ui:button("reset", "Reset settings") then settings.reset() end + ui:label("Created by neekgate for ReSkate") + end) + + reskate.menu.panel("Drone flight", function(ui) + ui:heading("FLIGHT MODE") + choice(ui, "mode", "Mode", {"Acro", "Angle (self-levelling)"}) + if settings.on("mode") then + slider(ui, "maxangle", "Most lean (degrees)") + ui:label("The sticks set how far the drone leans; let go and it levels out. Easier to learn on, but no flips.") + else + ui:label("The sticks set how fast the drone turns, and it stays at whatever angle you leave it: how real FPV pilots fly.") + end + toggle(ui, "althold", "Hold height with a springing throttle") + slider(ui, "climbrate", "Climb speed (m/s)") + ui:label("For gamepads and the keyboard, whose throttle springs back. Off: the middle of the stick is hover power. " + .. "On: the middle holds your height, adding throttle as you lean to keep it. An RC radio's throttle is not affected.") + ui:heading("FLIGHT") + slider(ui, "gravity", "Gravity") + slider(ui, "hover", "Hover throttle") + slider(ui, "drag", "Air drag") + slider(ui, "uptilt", "Camera tilt (degrees)") + ui:heading("COLLISIONS") + toggle(ui, "collide", "Collide with the world") + slider(ui, "radius", "Collision size (m)") + slider(ui, "floor", "Invisible floor (m)") + toggle(ui, "disarmfreeze", "Hang in the air when disarmed") + ui:heading("CRASHES") + toggle(ui, "crash", "Crashes") + if settings.on("crash") then + slider(ui, "crashspeed", "Hit that cuts the motors (m/s)") + if settings.get("crashspeed") > 0 then + ui:label(string.format("Bumps bounce the drone off and knock it about. Hitting something at %.0f km/h or more cuts the " + .. "motors: it tumbles down, and you arm again to fly on.", settings.get("crashspeed") * 3.6)) + else + ui:label("Bumps bounce the drone off and knock it about, but never cut the motors.") + end + else + ui:label("Off: the drone just stops against whatever it meets.") + end + slider(ui, "propwash", "Prop wash shake") + ui:heading("RANGE") + local limited = settings.get("range") > 0 + local want = ui:toggle("limited", "Limited range", limited) + if want ~= limited then settings.set("range", want and 300 or 0) end + if want then + local range = settings.get("range") + local got = ui:slider("range", "Range (m)", range, 50, 5000) + if moved(got, range) then settings.set("range", got) end + range = settings.get("range") + ui:label(string.format("Measured from where you took off. The picture starts to break up at %.0f m and is lost at %.0f m. " + .. "Stay without a picture for 5 seconds, or go past %.0f m, and the motors cut.", range * 0.5, range, range * 1.5)) + else + ui:label("Unlimited: fly anywhere on the map.") + end + end) + + reskate.menu.panel("Drone rates", function(ui) + ui:label("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.") + for _, axis in {"roll", "pitch", "yaw"} do + ui:heading(string.upper(axis)) + slider(ui, axis .. "center", "Centre sensitivity (deg/s)") + slider(ui, axis .. "max", "Max rate (deg/s)") + slider(ui, axis .. "expo", "Expo") + end + end) + + reskate.menu.panel("Drone display", function(ui) + ui:heading("GOGGLE DISPLAY") + toggle(ui, "osd", "Show the goggle display") + slider(ui, "osdscale", "Text size") + choice(ui, "osdcolor", "Colour", {"White", "Green", "Yellow", "Cyan", "Orange", "Pink"}) + choice(ui, "osdfont", "Font", {"Pixel", "Smooth"}) + slider(ui, "osdmargin", "Distance from the edge") + toggle(ui, "crosshair", "Crosshair") + ui:heading("SHOW") + toggle(ui, "osdarmed", "Armed / disarmed") + toggle(ui, "osdbattery", "Battery") + toggle(ui, "osdtimer", "Timer") + toggle(ui, "osdthrottle", "Throttle") + toggle(ui, "osdalt", "Altitude") + toggle(ui, "osdspeed", "Speed") + toggle(ui, "sticks", "My sticks on screen") + slider(ui, "sticksize", "Sticks size") + ui:heading("ANALOGUE VIDEO") + toggle(ui, "analog", "Analogue video look") + slider(ui, "analognoise", "Static") + slider(ui, "analogscan", "Scanlines") + slider(ui, "analogvignette", "Dark corners") + slider(ui, "analogglitch", "Rolling glitches") + ui:heading("SOUND") + slider(ui, "volume", "Motor volume") + slider(ui, "motorpitch", "Motor pitch") + ui:heading("OTHER PLAYERS") + toggle(ui, "beacon", "Let other players see my drone") + toggle(ui, "showbeacons", "Show other players' drones") + ui:label("Other players see your drone (a marker, FLYING DRONE on your nametag and its motor) if they have this mod too.") + end) + + reskate.menu.panel("Drone controller", function(ui) + page.controller(ui, drone) + end) +end + +-- ---- the controller --------------------------------------------------------------------------- +local function axis_text(signed) + if signed == 0 then return "none" end + return "axis " .. math.abs(signed) .. (signed < 0 and " (reversed)" or "") +end +-- Live bars for the six axes; `highlight` (1-6) is the one being picked. +local function axis_bars(ui, axes, highlight) + for i = 1, 6 do + local v = axes[i] or 0 + ui:bar(string.format("Axis %d %+.2f%s", i, v, i == highlight and " <" or ""), v, -1, 1) + end +end + +local step_text = { + rest = {"STEP 1 OF 7: STICKS AT REST", "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."}, + throttle = {"STEP 2 OF 7: THROTTLE", "Push the throttle all the way up and hold it there. On a gamepad, that's the left stick, up.", "Got it. Now let the throttle back down."}, + yaw = {"STEP 3 OF 7: YAW", "Push the yaw stick (left stick, sideways) fully right and hold it.", "Got it. Let the stick go."}, + pitch = {"STEP 4 OF 7: PITCH", "Push the right stick fully forward (away from you) and hold it.", "Got it. Let the stick go."}, + roll = {"STEP 5 OF 7: ROLL", "Push the right stick fully right and hold it.", "Got it. Let the stick go."}, + arm = {"STEP 6 OF 7: ARM SWITCH (OPTIONAL)", "Flip the switch you want to arm the motors with to its ARMED position. No arm switch? Press Skip."}, + reset = {"STEP 7 OF 7: RESET SWITCH (OPTIONAL)", "Flick the switch or press the button you want to put the drone back at its start. No reset switch? Press Skip.", "Got it. Let the switch go back."}, +} + +local last_build = nil +local saved = false +function page.wizard(ui, drone, sample) + local c = drone.calibration + local now = reskate.time.now() + local seconds = last_build and math.clamp((now - last_build) / 1000, 0, 0.25) or 0 + last_build = now + local bits = sample and radio.button_bits(sample.buttons) or 0 + if sample then c:update(sample.axes, bits, seconds) end + if c:active() then drone.calibration_seen = now end + if c.step == "done" then + -- Saved once, then what was found shown. + if not saved then + settings.set_controller(true, c.map) + settings.set("armaxis", c.arm_axis) + settings.set("armbutton", c.arm_button) + settings.set("resetaxis", c.reset_axis) + settings.set("resetbutton", c.reset_button) + settings.set("throttlecentre", c.throttle_centred and 1 or 0) + -- Gamepad sticks drift more than a radio's gimbals. + if c.throttle_centred and settings.get("deadzone") < 0.12 then settings.set("deadzone", 0.12) end + saved = true + end + ui:heading("SETUP SAVED: the drone now flies with this controller") + local names = {"Roll", "Pitch", "Throttle", "Yaw"} + for i = 1, 4 do ui:label(names[i] .. ": " .. axis_text(c.map[i])) end + local function switch_text(axis, button) + if button > 0 then return "button " .. button end + return axis_text(axis) + end + ui:label("Arm switch: " .. switch_text(c.arm_axis, c.arm_button)) + ui:label("Reset switch: " .. switch_text(c.reset_axis, c.reset_button)) + if c.throttle_centred then + ui:label("Your throttle springs back to the middle, so the middle now holds the drone up: push up to climb, down to descend.") + end + if ui:button("done", "DONE") then + c:cancel() + saved = false + end + return + end + saved = false + local text = step_text[c.step] + ui:heading(text[1]) + ui:label(c.returning and text[3] or text[2]) + if not sample then ui:label("The controller is not connected.") + else axis_bars(ui, sample.axes, not c.returning and c.candidate or -1) end + if c.step == "rest" then + if ui:button("ready", "Ready") and sample then c:capture_rest(sample.axes, bits) end + elseif (c.step == "arm" or c.step == "reset") and not c.returning then + if ui:button("skip", "Skip") then c:skip() end + end + if ui:button("cancel", "Cancel") then c:cancel() end +end + +function page.controller(ui, drone) + local c = drone.calibration + if settings.on("gamepad") then + choice(ui, "gamepad", "Controller", {"RC radio", "Gamepad"}) + local pad = reskate.input.gamepad() + ui:heading("GAMEPAD") + if not pad then + ui:label("No gamepad found. Plug in an Xbox or PlayStation controller.") + else + ui:label("Connected: " .. (pad.playstation and "PlayStation controller" or "Xbox controller")) + local s = radio.gamepad_sticks(pad, settings.get("deadzone"), settings.get("padrate"), settings.get("padexpo"), + settings.get("hover"), settings.on("padinvert"), settings.on("mode")) + ui:bar(string.format("Throttle %+.2f", s.climb), s.climb, -1, 1) + ui:bar(string.format("Yaw %+.2f", s.yaw), s.yaw, -1, 1) + ui:bar(string.format("Pitch %+.2f", s.pitch), s.pitch, -1, 1) + ui:bar(string.format("Roll %+.2f", s.roll), s.roll, -1, 1) + ui:label("What the drone gets from each stick, after the dead zone and sensitivity below.") + end + ui:label("Left stick: throttle up and down, turning left and right. Right stick: tilt (pitch, see Invert below) and roll. " + .. "A (cross) arms and disarms, Menu (Options) puts the drone back where it took off.") + ui:heading("STICKS") + slider(ui, "padrate", "Sensitivity") + slider(ui, "padexpo", "Softer middle") + slider(ui, "deadzone", "Stick dead zone") + toggle(ui, "padinvert", "Invert the right stick (up tips the nose up)") + ui:label("Sensitivity scales how fast the drone can turn at full stick (your rates still shape it). A softer middle makes " + .. "small corrections gentler without slowing full-stick flips. Gamepads use a dead zone of at least 0.08.") + return + end + local sample = reskate.input.joystick() + if c.step ~= "idle" then + page.wizard(ui, drone, sample) + return + end + last_build = nil + choice(ui, "gamepad", "Controller", {"RC radio", "Gamepad"}) + ui:heading("CONTROLLER") + if not sample then + ui:label("No controller found. Plug in an RC radio with its USB mode set to Joystick (on EdgeTX radios, pick " + .. "\"USB Joystick (HID)\" when you plug it in).") + else + ui:label("Connected: " .. sample.name) + if ui:button("setup", "SET UP CONTROLLER") then c:start() end + ui:label("Goes through each stick and switch and saves which is which. The drone holds still meanwhile.") + end + ui:heading("MANUAL SETUP") + if sample then + axis_bars(ui, sample.axes, -1) + local pressed = {} + for n = 1, 32 do + if sample.buttons[n] then table.insert(pressed, tostring(n)) end + end + ui:label("Buttons pressed: " .. (#pressed > 0 and table.concat(pressed, " ") or "none")) + ui:label("Move a stick or flip a switch to see which axis or button it is.") + end + local names = {"Roll", "Pitch", "Throttle", "Yaw"} + local axes = {"Axis 1", "Axis 2", "Axis 3", "Axis 4", "Axis 5", "Axis 6"} + local map = table.clone(settings.controller.map) + local changed = false + for i = 1, 4 do + local axis = math.clamp(math.abs(map[i]), 1, 6) + local flip = map[i] < 0 + local picked = ui:dropdown("axis" .. i, names[i], axes, axis) + local reversed = ui:toggle("flip" .. i, names[i] .. " reversed", flip) + if picked ~= axis or reversed ~= flip then + map[i] = picked * (reversed and -1 or 1) + changed = true + end + end + if changed then settings.set_controller(nil, map) end + toggle(ui, "throttlecentre", "Throttle springs back to the middle") + slider(ui, "deadzone", "Stick dead zone") + ui:heading("SWITCHES: an axis or button no stick uses; 0 = none") + whole(ui, "armaxis", "Arm switch axis") + whole(ui, "armbutton", "Arm button") + whole(ui, "resetaxis", "Reset switch axis") + whole(ui, "resetbutton", "Reset button") +end + +return page diff --git a/contrib/plugins/fpv-drone/plugin/radio.lua b/contrib/plugins/fpv-drone/plugin/radio.lua new file mode 100644 index 00000000..6ffc3fe6 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/radio.lua @@ -0,0 +1,278 @@ +-- Controllers for the drone, as Extension/Skater/drone_radio.h and drone_flight.h had them: an RC +-- radio (reskate.input.joystick(): six axes, 32 buttons) and a gamepad (reskate.input.gamepad()), +-- both read into the same sticks; the radio's arm and reset switches; and the guided setup that +-- finds which axis each stick is on. Plain maths, so the tests drive it. +local flight = require("flight") +local radio = {} + +-- Sticks as the flight model wants them: roll (+ right), pitch (+ stick forward), yaw (+ right), +-- throttle (0-1), centred (the throttle springs back) and climb (that stick, -1 to 1). +local function sticks() + return {valid = false, roll = 0, pitch = 0, yaw = 0, throttle = 0, centred = false, climb = 0} +end +radio.sticks = sticks + +function radio.deadzone(v, dz) + local a = math.abs(v) + if not (a > dz) then return 0 end + local out = math.min(1, (a - dz) / (1 - dz)) + return v < 0 and -out or out +end +-- Axis n (1-6) of the six, minus reversed; 0 for none. +function radio.pick_axis(axes, signed) + local index = math.abs(signed) + if index < 1 or index > 6 then return 0 end + local v = axes[index] or 0 + return signed < 0 and -v or v +end + +-- An RC radio's six axes, with the saved axes (roll, pitch, throttle, yaw), dead zone and throttle kind. +function radio.map_axes(axes, map, dz, centred_throttle, hover) + local out = sticks() + out.valid = true + out.roll = radio.deadzone(radio.pick_axis(axes, map[1]), dz) + out.pitch = radio.deadzone(radio.pick_axis(axes, map[2]), dz) + out.centred = centred_throttle + if centred_throttle then + out.climb = radio.deadzone(radio.pick_axis(axes, map[3]), dz) + out.throttle = flight.spring_throttle(out.climb, hover) + else + out.throttle = math.clamp((radio.pick_axis(axes, map[3]) + 1) * 0.5, 0, 1) + end + out.yaw = radio.deadzone(radio.pick_axis(axes, map[4]), dz) + return out +end + +-- A gamepad's sticks, Mode 2: left stick throttle (springing back) and yaw, right stick pitch and +-- roll. Each stick has a round dead zone; then the middle is softened (`expo`) and the whole travel +-- scaled down (`scale`), since a pad's short sticks at a radio's rates make every touch a flip. +-- `invert`: up on the right stick tips the nose up. In angle mode `scale` leaves pitch and roll +-- alone: full stick is the full lean. +function radio.gamepad_sticks(pad, dz, scale, expo, hover, invert, angle) + local out = sticks() + if not pad then return out end + out.valid = true + local zone = math.clamp(flight.finite(dz) and dz or 0, 0.08, 0.5) + local function stick(x, y) + x = flight.finite(x) and math.clamp(x, -1, 1) or 0 + y = flight.finite(y) and math.clamp(y, -1, 1) or 0 + local length = math.sqrt(x * x + y * y) + if not (length > zone) then return 0, 0 end + local k = math.min(1, (length - zone) / (1 - zone)) / length + return x * k, y * k + end + local e = math.clamp(flight.finite(expo) and expo or 0, 0, 1) + local s = math.clamp(flight.finite(scale) and scale or 1, 0.05, 1) + local function shape(v, most) + local a = math.abs(v) + local out_v = (e * a * a * a + (1 - e) * a) * most + return v < 0 and -out_v or out_v + end + local tilt = angle and 1 or s + local lx, ly = stick(pad.left_x, pad.left_y) + local rx, ry = stick(pad.right_x, pad.right_y) + out.yaw = shape(lx, s) + out.roll = shape(rx, tilt) + out.pitch = shape(invert and -ry or ry, tilt) -- + = forward = nose down + out.centred = true + out.climb = ly + out.throttle = flight.spring_throttle(ly, hover) + return out +end + +-- The 32 buttons as a number, bit n-1 for button n. +function radio.button_bits(list) + local bits = 0 + if type(list) ~= "table" then return 0 end + for n = 1, 32 do + if list[n] then bits = bit32.bor(bits, bit32.lshift(1, n - 1)) end + end + return bits +end +local function pressed(bits, n) + return n >= 1 and n <= 32 and bit32.btest(bits, bit32.lshift(1, n - 1)) +end +radio.pressed = pressed + +-- Radio switches: an arm switch and a reset switch, each an axis (-6 to 6) or a button (1-32). 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 sets them off. Returns the +-- armed switch (true, false, or nil when no switch says) and whether to reset. +function radio.switches(memory, axes, bits, arm_axis, arm_button, reset_axis, reset_button) + local armed, reset = nil, false + arm_axis, reset_axis = math.round(arm_axis), math.round(reset_axis) + arm_button, reset_button = math.round(arm_button), math.round(reset_button) + if arm_axis ~= 0 and math.abs(arm_axis) <= 6 then + local v = radio.pick_axis(axes, arm_axis) + if v > 0.5 then armed = true elseif v < -0.5 then armed = false end + end + if arm_button ~= 0 then armed = pressed(bits, arm_button) end + if reset_axis ~= 0 and math.abs(reset_axis) <= 6 then + local v = radio.pick_axis(axes, reset_axis) + if v > 0.5 and not memory.reset_axis_high then + reset = true + memory.reset_axis_high = true + elseif v < 0.2 then + memory.reset_axis_high = false + end + end + if reset_button ~= 0 then + local down = pressed(bits, reset_button) + if down and not memory.reset_button_down then reset = true end + memory.reset_button_down = down + end + return armed, reset +end + +-- A gamepad's buttons: A (cross) arms or disarms, Menu (Options) puts the drone back, each on the +-- press. With `act` false (the menu has the pad) they are only watched, so a button still held when +-- the menu closes does nothing. Returns toggle_arm, reset. +function radio.pad_buttons(memory, buttons, act) + local a, start = buttons and buttons.a or false, buttons and buttons.start or false + -- Nothing counts as pressed until it has been seen up. + local a_pressed = a and memory.a_up == true + local start_pressed = start and memory.start_up == true + memory.a_up, memory.start_up = not a, not start + if not act then return false, false end + return a_pressed, start_pressed +end + +-- 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 held well over for a moment, and takes the way 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. +local Calibration = {} +Calibration.__index = Calibration +radio.move_threshold, radio.switch_threshold, radio.return_threshold, radio.hold_seconds = 0.6, 0.8, 0.25, 0.3 +radio.steps = {"rest", "throttle", "yaw", "pitch", "roll", "arm", "reset", "done"} + +function radio.calibration() + local c = setmetatable({}, Calibration) + c:cancel() + return c +end +function Calibration:cancel() + self.step = "idle" + self.rest = {0, 0, 0, 0, 0, 0} + self.rest_buttons = 0 + self.map = {1, 2, 3, 4} + self.used = {false, false, false, false, false, false} + self.used_buttons = 0 + self.arm_axis, self.arm_button, self.reset_axis, self.reset_button = 0, 0, 0, 0 + self.candidate, self.candidate_button, self.held = -1, 0, 0 + self.returning = false + self.throttle_centred = false + self.found_axis, self.found_button = -1, 0 +end +function Calibration:start() + self:cancel() + self.step = "rest" +end +function Calibration:active() + return self.step ~= "idle" and self.step ~= "done" +end +-- "Ready" on the rest step: the sticks as they are now are where they rest. +function Calibration:capture_rest(axes, bits) + if self.step ~= "rest" then return end + self.rest = table.clone(axes) + self.rest_buttons = bits + self.step = "throttle" +end +-- "Skip" on a switch step: no switch for it. +function Calibration:skip() + if self.step == "arm" then + self.step = "reset" + self:clear_hold() + elseif self.step == "reset" then + self.step = "done" + self:clear_hold() + end +end +function Calibration:clear_hold() + self.candidate, self.candidate_button, self.held = -1, 0, 0 +end +local slots = {roll = 1, pitch = 2, throttle = 3, yaw = 4} +function Calibration:furthest(axes, threshold) + local best, distance = -1, threshold + for i = 1, 6 do + if not self.used[i] then + local d = math.abs((axes[i] or 0) - self.rest[i]) + if flight.finite(d) and d > distance then + distance = d + best = i + end + end + end + return best +end +function Calibration:hold(axis, button, seconds) + if axis ~= self.candidate or button ~= self.candidate_button then + self.candidate, self.candidate_button, self.held = axis, button, 0 + return false + end + self.held += seconds + return (axis >= 1 or button > 0) and self.held >= radio.hold_seconds +end +function Calibration:advance() + local next_step = {throttle = "yaw", yaw = "pitch", pitch = "roll", roll = "arm", arm = "reset", reset = "done"} + self.step = next_step[self.step] or self.step +end +function Calibration:back_at_rest(axes, bits) + if self.found_axis >= 1 then + return math.abs((axes[self.found_axis] or 0) - self.rest[self.found_axis]) < radio.return_threshold + end + return self.found_button <= 0 or not pressed(bits, self.found_button) +end +-- Once a frame while the setup is shown. +function Calibration:update(axes, bits, seconds) + if not self:active() or self.step == "rest" then return end + if not flight.finite(seconds) or seconds < 0 then seconds = 0 end + if self.returning then + if self:back_at_rest(axes, bits) then + self.returning = false + self:advance() + end + return + end + if self.step == "arm" or self.step == "reset" then + local axis = self:furthest(axes, radio.switch_threshold) + local button = 0 + local fresh = bit32.band(bits, bit32.bnot(self.rest_buttons), bit32.bnot(self.used_buttons)) + for n = 1, 32 do + if pressed(fresh, n) then + button = n + break + end + end + if axis >= 1 then button = 0 end -- an axis is the clearer signal when both move + if not self:hold(axis, button, seconds) then return end + if axis >= 1 then + local value = ((axes[axis] or 0) >= self.rest[axis] and 1 or -1) * axis -- its new side counts as on + if self.step == "arm" then self.arm_axis = value else self.reset_axis = value end + self.used[axis] = true + else + if self.step == "arm" then self.arm_button = button else self.reset_button = button end + self.used_buttons = bit32.bor(self.used_buttons, bit32.lshift(1, button - 1)) + end + self.found_axis, self.found_button = axis, button + -- The arm switch can stay on; the reset switch has to be let go before finishing. + self.returning = self.step == "reset" + if not self.returning then self:advance() end + self:clear_hold() + else + local axis = self:furthest(axes, radio.move_threshold) + if not self:hold(axis, 0, seconds) then return end + local sign = (axes[axis] or 0) >= self.rest[axis] and 1 or -1 + self.map[slots[self.step]] = sign * axis + if self.step == "throttle" then self.throttle_centred = math.abs(self.rest[axis]) < 0.5 end + self.used[axis] = true + self.found_axis, self.found_button = axis, 0 + self.returning = true + self:clear_hold() + end +end + +return radio diff --git a/contrib/plugins/fpv-drone/plugin/settings.lua b/contrib/plugins/fpv-drone/plugin/settings.lua new file mode 100644 index 00000000..40afd705 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/settings.lua @@ -0,0 +1,214 @@ +-- The drone's settings: the same 62 the C++ drone had (dronetune's names), the top speed (freecam's +-- flyspeed there) and the controller's axes, kept in the plugin's storage. `fpv tune` and the menu +-- panels change them through set(), which clamps a value to its range and saves it. +local settings = {} + +-- name, help, lowest, highest, default. +settings.list = { + {"gravity", "How hard the drone is pulled down. 9.8 is Earth; higher feels heavier and snappier", 0, 50, 9.81}, + {"drag", "Air resistance. Higher slows the drone down sooner when you let off", 0, 3, 0.15}, + {"hover", "The throttle that keeps the drone in the air (0.1-0.9). Lower means more power to spare: 0.22 is about ten times its weight in thrust, a 5-inch's", 0.1, 0.9, 0.35}, + {"rollcenter", "Roll speed near the middle of the stick, in degrees per second (Betaflight \"Actual\" rates)", 1, 1000, 80}, + {"rollmax", "Roll speed with the stick fully over, in degrees per second", 30, 2000, 700}, + {"rollexpo", "How much softer roll is near the middle of the stick (0 = none, 1 = most)", 0, 1, 0.15}, + {"pitchcenter", "Pitch speed near the middle of the stick, in degrees per second", 1, 1000, 80}, + {"pitchmax", "Pitch speed with the stick fully over, in degrees per second", 30, 2000, 700}, + {"pitchexpo", "How much softer pitch is near the middle of the stick (0 = none, 1 = most)", 0, 1, 0.15}, + {"yawcenter", "Yaw (turning) speed near the middle of the stick, in degrees per second", 1, 1000, 130}, + {"yawmax", "Yaw speed with the stick fully over, in degrees per second", 30, 2000, 780}, + {"yawexpo", "How much softer yaw is near the middle of the stick (0 = none, 1 = most)", 0, 1, 0.30}, + {"uptilt", "How far the camera points up, in degrees. Real FPV cameras sit at about 25-40; faster flying needs more", -30, 70, 0}, + {"floor", "With collisions off: an invisible floor this many metres below where you took off (0 = none)", 0, 1000, 1.5}, + {"collide", "Hit the ground, walls and ramps (1), or fly through everything (0)", 0, 1, 1}, + {"radius", "How big the drone is for collisions, in metres: how close it gets to things", 0.05, 3, 0.3}, + {"osd", "Show the goggle display: battery, timer, throttle, altitude, speed and signal. The range warnings show either way", 0, 1, 1}, + {"volume", "Motor sound volume (0 = silent)", 0, 1, 0.09}, + {"osdscale", "Size of the goggle display's text (1 = normal, 2 = double)", 0.4, 5, 1.5}, + {"osdcolor", "Goggle display colour: 0 white, 1 green, 2 yellow, 3 cyan, 4 orange, 5 pink", 0, 5, 0}, + {"osdfont", "Goggle display font: 0 = pixel, like real goggles; 1 = smooth", 0, 1, 0}, + {"osdmargin", "How far the goggle display sits in from the screen edges (0-0.3 of the screen)", 0, 0.3, 0.06}, + {"crosshair", "Show a crosshair in the middle of the screen", 0, 1, 0}, + {"osdarmed", "Show ARMED / DISARMED", 0, 1, 1}, + {"osdbattery", "Show the battery voltage", 0, 1, 1}, + {"osdtimer", "Show the flight timer", 0, 1, 1}, + {"osdthrottle", "Show the throttle", 0, 1, 1}, + {"osdalt", "Show the altitude", 0, 1, 1}, + {"osdspeed", "Show the speed", 0, 1, 1}, + {"sticks", "Show your sticks on screen (left: throttle and yaw, right: pitch and roll)", 0, 1, 1}, + {"sticksize", "Size of the on-screen sticks (1 = normal)", 0.4, 3, 1}, + {"armaxis", "Controller axis (1-6) for an arm switch: switch on = armed. Minus reverses it; 0 = none. Use one no stick is on", -6, 6, 0}, + {"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, 6, 0}, + {"disarmfreeze", "When disarmed, the drone hangs in the air (1) instead of falling (0)", 0, 1, 0}, + {"beacon", "Let other players see your drone (they need this mod too)", 0, 1, 1}, + {"showbeacons", "Show other players' drones (0 hides them)", 0, 1, 1}, + {"armbutton", "Controller button (1-32) to arm with: held = armed. 0 = none", 0, 32, 0}, + {"resetbutton", "Controller button (1-32) that puts the drone back where it took off. 0 = none", 0, 32, 0}, + {"analog", "Make the picture look like analogue FPV video: scanlines, static, dark corners and glitches", 0, 1, 0}, + {"analognoise", "Analogue look: how much static (0-1)", 0, 1, 0.35}, + {"analogscan", "Analogue look: how strong the scanlines are (0-1)", 0, 1, 0.5}, + {"analogvignette", "Analogue look: how dark the corners are (0-1)", 0, 1, 0.5}, + {"analogglitch", "Analogue look: how often the picture rolls and tears (0-1)", 0, 1, 0.3}, + {"range", "How far your signal reaches from where you took off, in metres. 0 = unlimited", 0, 5000, 0}, + {"motorpitch", "Motor sound pitch. Higher sounds like a smaller drone", 0.4, 2.5, 1}, + {"throttlecentre", "Your throttle stick springs back to the middle (gamepads): the middle hovers, up climbs, down descends", 0, 1, 0}, + {"deadzone", "How far a stick must move before the drone reacts (0-0.3). Raise it if the drone drifts on its own", 0, 0.3, 0.03}, + {"goggles", "What your skater wears over their eyes while you fly: 0 = nothing, 1-8 = one of the eyewear choices on the FPV Drone panel", 0, 8, 1}, + {"hideui", "Hide the game's own interface while you fly or the drone follows you", 0, 1, 1}, + {"gamepad", "Fly with a gamepad (Xbox or PlayStation) instead of an RC radio: left stick throttle and yaw, right stick pitch and roll, with gentler sticks. A arms, Menu / Options puts the drone back", 0, 1, 1}, + {"padrate", "Gamepad: how much of your rates the sticks reach at full tilt (0.2-1). Lower is calmer. In angle mode it slows turning only: full stick still leans all the way", 0.2, 1, 0.6}, + {"padexpo", "Gamepad: how much softer the middle of the sticks is, for small corrections (0 = none, 1 = most)", 0, 1, 0.45}, + {"althold", "With a throttle that springs back to the middle (a gamepad, or the keyboard): hold your height (1), adding throttle as you lean to keep it; up climbs, down descends. Off (0): the middle is hover power and the throttle is yours alone", 0, 1, 0}, + {"climbrate", "Height hold: how fast full throttle stick climbs or descends, in metres per second", 1, 40, 20}, + {"mode", "Flight mode: 0 = acro (the stick sets how fast it turns, full flips), 1 = angle (self-levelling: the stick sets how far it leans, and letting go levels it)", 0, 1, 0}, + {"maxangle", "Angle mode: how far the drone leans at full stick, in degrees", 10, 80, 55}, + {"crash", "Crashes: hits bounce and tumble the drone, and a hard one cuts the motors until you arm again (1). Off: it just stops against things (0)", 0, 1, 1}, + {"crashspeed", "Crashes: how hard a hit cuts the motors, in metres per second into the surface. 0 = never", 0, 30, 7}, + {"propwash", "How much the drone shakes when it drops through its own prop wash (0-1)", 0, 1, 0.5}, + {"padinvert", "Gamepad: push the right stick up to tip the nose up, like aiming a camera (1); or to tip it down and fly forward, like an RC radio (0)", 0, 1, 1}, + {"followdistance", "Follow me: how far behind you the drone flies, in metres (it flies a little higher the further back it is)", 3, 12, 4.5}, + {"followaggression", "Follow me: how hard the pilot flies (0-1). Low is a calm cinematic pilot who lags a little and leans gently; high reacts fast, leans hard and stays right on you", 0, 1, 0.5}, + -- Not one of dronetune's: freecam's flyspeed there. + {"topspeed", "Top speed: full throttle flat out settles near four times this, in metres per second", 0.6, 300, 15}, +} +settings.count = #settings.list +settings.info = {} +for index, entry in settings.list do + settings.info[entry[1]] = {index = index, name = entry[1], help = entry[2], lo = entry[3], hi = entry[4], def = entry[5]} +end + +-- The values now, by name; and the controller: whether the drone flies with it, and which axis +-- (1-6, minus reversed) is roll, pitch, throttle and yaw (AETR, an EdgeTX radio's Mode 2). +settings.values = {} +settings.controller = {enabled = true, map = {1, 2, 3, 4}} +for name, info in settings.info do settings.values[name] = info.def end + +local function clamp_to(info, value) + if type(value) ~= "number" or value ~= value then return info.def end + return math.clamp(value, info.lo, info.hi) +end + +function settings.get(name) + return settings.values[name] +end +function settings.on(name) + return settings.values[name] >= 0.5 +end + +local saving = true +local function save() + if not saving then return end + pcall(reskate.storage.set, "settings", settings.values) + pcall(reskate.storage.set, "controller", settings.controller) +end + +-- The value it was set to (clamped), or nil and why for a name there is none of. +function settings.set(name, value) + local info = settings.info[name] + if not info then return nil, "there is no setting called '" .. tostring(name) .. "'" end + local v = clamp_to(info, value) + if settings.values[name] ~= v then + settings.values[name] = v + save() + end + return v +end + +function settings.reset() + for name, info in settings.info do settings.values[name] = info.def end + save() +end + +function settings.set_controller(enabled, map) + if enabled ~= nil then settings.controller.enabled = enabled and true or false end + if map then + local fixed = {} + for i = 1, 4 do + local a = math.round(tonumber(map[i]) or i) + if a == 0 or math.abs(a) > 6 then a = i end + fixed[i] = a + end + settings.controller.map = fixed + end + save() +end + +function settings.load() + local ok, stored = pcall(reskate.storage.get, "settings") + if ok and type(stored) == "table" then + for name, info in settings.info do + if stored[name] ~= nil then settings.values[name] = clamp_to(info, stored[name]) end + end + end + local ok2, controller = pcall(reskate.storage.get, "controller") + if ok2 and type(controller) == "table" then + saving = false + settings.set_controller(controller.enabled ~= false, controller.map) + saving = true + end +end + +-- Without storage (the tests): nothing is kept. +function settings.keep(on) + saving = on +end + +-- Presets: whole drones in one choice, handling, rates, size, motor sound and top speed. +settings.presets = { + {name = "Tinywhoop", description = "A 65 mm ducted whoop: small, slow and forgiving, made for indoors and tight lines.", + values = {hover = 0.45, drag = 0.60, radius = 0.06, uptilt = 15, rollcenter = 70, rollmax = 600, rollexpo = 0.30, + pitchcenter = 70, pitchmax = 600, pitchexpo = 0.30, yawcenter = 100, yawmax = 500, yawexpo = 0.30, + motorpitch = 2.0, topspeed = 3.5}}, + {name = "Toothpick", description = "A light 3-inch: quick and nimble, faster than a whoop without the punch of a 5-inch.", + values = {hover = 0.30, drag = 0.30, radius = 0.10, uptilt = 25, rollcenter = 80, rollmax = 700, rollexpo = 0.20, + pitchcenter = 80, pitchmax = 700, pitchexpo = 0.20, yawcenter = 130, yawmax = 650, yawexpo = 0.30, + motorpitch = 1.5, topspeed = 6.0}}, + {name = "5 inch", description = "A 5-inch freestyle quad: lots of power and speed, for big lines across the map.", + values = {hover = 0.22, drag = 0.12, radius = 0.25, uptilt = 30, rollcenter = 80, rollmax = 720, rollexpo = 0.15, + pitchcenter = 80, pitchmax = 720, pitchexpo = 0.15, yawcenter = 130, yawmax = 780, yawexpo = 0.30, + motorpitch = 0.85, topspeed = 10.0}}, +} + +function settings.apply_preset(index) + local preset = settings.presets[index] + if not preset then return false end + saving = false + for name, value in preset.values do settings.set(name, value) end + saving = true + save() + return true +end + +-- The preset the settings match now, or nil (custom). +function settings.matching_preset() + for index, preset in settings.presets do + local same = true + for name, value in preset.values do + local tolerance = name == "topspeed" and 0.05 or 0.005 * math.max(1, math.abs(value)) + if math.abs(settings.values[name] - value) >= tolerance then + same = false + break + end + end + if same then return index end + end + return nil +end + +-- The flight model's settings table (flight.lua) from the values now. `armed` is the pilot's switch. +function settings.flight(armed) + local v = settings.values + return { + gravity = v.gravity, lin_drag = v.drag, hover = v.hover, + roll = {center = v.rollcenter, max = v.rollmax, expo = v.rollexpo}, + pitch = {center = v.pitchcenter, max = v.pitchmax, expo = v.pitchexpo}, + yaw = {center = v.yawcenter, max = v.yawmax, expo = v.yawexpo}, + uptilt = v.uptilt, floor = v.floor, collide = v.collide >= 0.5, radius = v.radius, range = v.range, + armed = armed, disarm_freeze = v.disarmfreeze >= 0.5, + angle_mode = v.mode >= 0.5, max_angle = v.maxangle, angle_response = 8, + alt_hold = v.althold >= 0.5, climb_rate = v.climbrate, + crash = v.crash >= 0.5, crash_speed = v.crashspeed, propwash = v.propwash, + yaw_look_scale = 0.0025, top_speed = v.topspeed, + } +end + +return settings diff --git a/contrib/plugins/fpv-drone/plugin/tests.lua b/contrib/plugins/fpv-drone/plugin/tests.lua new file mode 100644 index 00000000..910dc228 --- /dev/null +++ b/contrib/plugins/fpv-drone/plugin/tests.lua @@ -0,0 +1,493 @@ +-- The drone's maths, checked without the game: the flight model over a fake world, the +-- controllers, the follow autopilot, the messages and the display. `fpv test` runs them (one every +-- few ticks, as each call into a plugin has 50 ms) and prints the failures and a summary; the host +-- test runner (dingosdk_plugin_tests) runs it too. +local settings = require("settings") +local flight = require("flight") +local radio = require("radio") +local follow = require("follow") +local osd = require("osd") +local beacon = require("beacon") + +local V = vector.create +local len, dot = vector.magnitude, vector.dot +local tests = {} +local list = {} +local function test(name, fn) + table.insert(list, {name = name, fn = fn}) +end + +local function near(a, b, within, what) + if not (math.abs(a - b) <= within) then error(string.format("%s: %g, not %g (within %g)", what or "value", a, b, within), 2) end +end +local function check(ok, what) + if not ok then error(what or "check failed", 2) end +end + +-- A world of flat ground at y = 0 and, when asked, a wall at x = wall. +local function world(wall) + return function(from, to) + local best, at = 2, nil + if (from.y > 0) ~= (to.y > 0) then + local f = from.y / (from.y - to.y) + if f < best then best, at = f, from + (to - from) * f end + end + if wall and (from.x < wall) ~= (to.x < wall) then + local f = (wall - from.x) / (to.x - from.x) + if f < best then best, at = f, from + (to - from) * f end + end + return at + end +end + +local function config(changes) + local cfg = settings.flight(true) + for k, v in changes or {} do cfg[k] = v end + return cfg +end +local function hover_command(cfg) + local cmd = flight.command() + cmd.throttle = cfg.hover + return cmd +end +local function basis_square(m) + return math.abs(len(m.r) - 1) < 1e-3 and math.abs(len(m.u) - 1) < 1e-3 and math.abs(len(m.b) - 1) < 1e-3 + and math.abs(dot(m.r, m.u)) < 1e-3 and math.abs(dot(m.u, m.b)) < 1e-3 and math.abs(dot(m.r, m.b)) < 1e-3 +end + +local DT = 1 / 50 + +test("rates follow Betaflight's actual curve", function() + local rate = {center = 80, max = 700, expo = 0.15} + near(flight.actual_rate(0, rate), 0, 1e-6, "no stick") + near(flight.actual_rate(1, rate), 700, 1e-3, "full stick") + near(flight.actual_rate(-1, rate), -700, 1e-3, "full stick the other way") + local small = flight.actual_rate(0.005, rate) + near(small / 0.005, 80, 3, "the centre sensitivity") + check(flight.actual_rate(0.5, rate) < flight.actual_rate(0.5, {center = 80, max = 700, expo = 0}), "expo softens the middle") +end) + +test("a springing throttle hovers in the middle", function() + near(flight.spring_throttle(0, 0.35), 0.35, 1e-6, "middle") + near(flight.spring_throttle(1, 0.35), 1, 1e-6, "up") + near(flight.spring_throttle(-1, 0.35), 0, 1e-6, "down") + near(flight.spring_throttle(0.5, 0.2), 0.6, 1e-6, "half up") +end) + +test("thrust grows with output to the power 1.5 and the hover throttle holds it up", function() + local cfg = config({hover = 0.22}) + near(flight.thrust_at(0.22, cfg), cfg.gravity, 1e-3, "thrust at hover") + near(flight.max_thrust(cfg) / cfg.gravity, (1 / 0.22) ^ 1.5, 1e-3, "thrust to weight") + near(flight.output_for(cfg.gravity, cfg), 0.22, 1e-4, "the output that hovers") + near(flight.thrust_at(0.5, cfg) / flight.thrust_at(1, cfg), 0.5 ^ 1.5, 1e-4, "the power") +end) + +test("hovering holds its height, full throttle climbs, none falls", function() + local cfg = config({collide = false, floor = 0}) + local s = flight.new_state(V(0, 50, 0), V(0, 0, -1), true, cfg) + for _ = 1, 100 do flight.step(s, hover_command(cfg), DT, cfg) end + near(s.position.y, 50, 0.3, "height after 2 s of hover") + local climb = flight.command() + climb.throttle = 1 + for _ = 1, 50 do flight.step(s, climb, DT, cfg) end + check(s.position.y > 55, "full throttle climbs: " .. s.position.y) + local fall = flight.command() + fall.throttle = 0 + local peak = s.position.y + for _ = 1, 250 do + flight.step(s, fall, DT, cfg) + peak = math.max(peak, s.position.y) + end + check(s.position.y < peak - 10, "no throttle falls") + check(s.velocity.y < -3, "and keeps falling") +end) + +test("motors spool up and down rather than jump", function() + local cfg = config({collide = false}) + local s = flight.new_state(V(0, 10, 0), V(0, 0, -1), true, cfg) + local cmd = flight.command() + cmd.throttle = 1 + flight.step(s, cmd, 0.01, cfg) + check(s.motor < 0.6, "one step does not reach full: " .. s.motor) + for _ = 1, 20 do flight.step(s, cmd, DT, cfg) end + near(s.motor, 1, 0.01, "full after a while") +end) + +test("flat out it settles near four times the top speed", function() + for _, top in {6, 10} do + local cfg = config({collide = false, floor = 0, angle_mode = true, max_angle = 60, top_speed = top, hover = 0.22, lin_drag = 0}) + local s = flight.new_state(V(0, 500, 0), V(0, 0, -1), true, cfg) + local cmd = flight.command() + cmd.pitch = -1 -- nose down: forward + cmd.throttle = 1 + for _ = 1, 400 do flight.step(s, cmd, DT, cfg) end + local level = math.sqrt(s.velocity.x ^ 2 + s.velocity.z ^ 2) + check(level > top * 4 * 0.75 and s.speed < top * 4 * 1.3, string.format("top speed %g: %.1f m/s across, %.1f all, wanted near %g", top, level, s.speed, top * 4)) + end +end) + +test("acro keeps the angle it is left at; angle mode levels out", function() + local cfg = config({collide = false, floor = 0}) + local s = flight.new_state(V(0, 100, 0), V(0, 0, -1), true, cfg) + local roll = hover_command(cfg) + roll.roll = 0.3 + for _ = 1, 10 do flight.step(s, roll, DT, cfg) end + local leaning = s.body.r.y + check(math.abs(leaning) > 0.2, "rolled: " .. leaning) + for _ = 1, 25 do flight.step(s, hover_command(cfg), DT, cfg) end + near(s.body.r.y, leaning, 0.15, "acro stays rolled") + cfg.angle_mode = true + for _ = 1, 100 do flight.step(s, hover_command(cfg), DT, cfg) end + near(s.body.r.y, 0, 0.05, "angle mode is level again") + check(basis_square(s.body), "the body stays square") +end) + +test("the body stays square through flips", function() + local cfg = config({collide = false, floor = 0}) + local s = flight.new_state(V(0, 100, 0), V(1, 0, 0), true, cfg) + local cmd = hover_command(cfg) + cmd.roll, cmd.pitch, cmd.yaw_rad = 1, -0.7, 0.05 + for _ = 1, 300 do flight.step(s, cmd, DT, cfg) end + check(basis_square(s.body), "square after 6 s of flips") + check(basis_square(s.camera), "and so is the camera") +end) + +test("the ground stops it and it rests on the ground", function() + local cfg = config({radius = 0.3}) + local cast = world() + local s = flight.new_state(V(0, 3, 0), V(0, 0, -1), true, cfg) + local cmd = flight.command() + cmd.throttle = 0.1 + local lowest = 1e9 + for _ = 1, 150 do + flight.step(s, cmd, DT, cfg, cast) + lowest = math.min(lowest, s.position.y) + end + check(lowest > 0.2, "it never goes into the ground: " .. lowest) + check(s.position.y < 0.5, "it comes down: " .. s.position.y) + check(s.grounded, "and is on the ground") + check(not s.crashed, "a gentle landing is no crash") +end) + +test("a hard hit cuts the motors, and arming again puts it right", function() + local cfg = config({radius = 0.3, crash_speed = 7}) + local cast = world(10) + local s = flight.new_state(V(0, 5, 0), V(1, 0, 0), true, cfg) + s.velocity = V(20, 0, 0) + local cmd = hover_command(cfg) + local crashed = false + for _ = 1, 50 do + flight.step(s, cmd, DT, cfg, cast) + crashed = crashed or s.crashed_now + check(s.position.x < 10, "it never goes through the wall: " .. s.position.x) + end + check(crashed and s.crashed, "20 m/s into a wall is a crash") + check(s.glitch > 0, "and breaks up the picture") + cfg.armed = false + flight.step(s, cmd, DT, cfg, cast) + cfg.armed = true + flight.step(s, cmd, DT, cfg, cast) + check(not s.crashed, "off and on again arms it") + local soft = config({radius = 0.3, crash = false}) + local t = flight.new_state(V(0, 5, 0), V(1, 0, 0), true, soft) + t.velocity = V(20, 0, 0) + for _ = 1, 50 do flight.step(t, hover_command(soft), DT, soft, cast) end + check(not t.crashed and t.position.x < 10, "with crashes off it just stops") +end) + +test("without collisions an invisible floor holds it", function() + local cfg = config({collide = false, floor = 1.5}) + local s = flight.new_state(V(0, 10, 0), V(0, 0, -1), true, cfg) + local cmd = flight.command() + cmd.throttle = 0 + for _ = 1, 200 do flight.step(s, cmd, DT, cfg) end + near(s.position.y, 8.5, 1e-3, "it sits on the floor") +end) + +test("range breaks the picture up and then cuts the motors", function() + near(flight.signal_strength(100, 300), 1, 1e-6, "clean near") + near(flight.signal_strength(300, 300), 0, 1e-6, "lost at the range") + local mid = flight.signal_strength(225, 300) + check(mid > 0 and mid < 1, "fading between") + check(not flight.failsafe_due(300, 300, 4.9), "not yet") + check(flight.failsafe_due(300, 300, 5), "five seconds without a picture") + check(flight.failsafe_due(451, 300, 0), "far past it at once") + check(not flight.failsafe_due(1e6, 0, 100), "unlimited never") + local cfg = config({collide = false, floor = 0, range = 50}) + local s = flight.new_state(V(0, 100, 0), V(0, 0, -1), true, cfg) + s.position = V(0, 100, 80) + flight.step(s, hover_command(cfg), DT, cfg) + check(s.failsafe and s.signal == 0, "past 1.5 times the range the motors cut") + flight.reset(s, true, cfg) + flight.step(s, hover_command(cfg), DT, cfg) + check(not s.failsafe, "a reset puts it right") +end) + +test("disarmed it falls, or hangs with disarmfreeze", function() + local cfg = config({collide = false, floor = 0, armed = false}) + local s = flight.new_state(V(0, 50, 0), V(0, 0, -1), false, cfg) + for _ = 1, 50 do flight.step(s, hover_command(cfg), DT, cfg) end + check(s.position.y < 47, "it falls") + cfg.disarm_freeze = true + local y = s.position.y + for _ = 1, 50 do flight.step(s, hover_command(cfg), DT, cfg) end + near(s.position.y, y, 1e-4, "it hangs") +end) + +test("height hold keeps the height while leaning", function() + local cfg = config({collide = false, floor = 0, angle_mode = true, alt_hold = true}) + local s = flight.new_state(V(0, 30, 0), V(0, 0, -1), true, cfg) + local cmd = flight.command() + cmd.hold, cmd.climb, cmd.pitch = true, 0, -0.6 + for _ = 1, 150 do flight.step(s, cmd, DT, cfg) end + near(s.position.y, 30, 1.5, "height after 3 s leaning") + check(len(s.velocity) > 5, "and it went somewhere") +end) + +test("the gimbal keeps its tilt whatever the body does", function() + local body = flight.level_basis(V(0, 0, -1)) + flight.rotate_basis(body, body.r, -0.5) -- nose down + local cam = flight.camera_on_gimbal(body, 0.2, 0.3) + near(math.asin(-cam.b.y), 0.2, 1e-3, "the camera's pitch") + near(cam.r.y, 0, 1e-3, "no roll without the body's") +end) + +test("the keyboard flies as the C++ drone's did", function() + local cfg = config({}) + local cmd, shown = flight.keyboard_command({right = 1, up = 0, forward = 1, look_x = 40, look_y = 0, boost = true}, cfg) + near(cmd.pitch, -1, 1e-6, "forward is nose down") + near(cmd.roll, 1, 1e-6, "right rolls right") + check(cmd.yaw_rad < 0, "the mouse right turns right") + near(cmd.throttle, cfg.hover, 1e-6, "the middle hovers") + check(cmd.boost and shown.yaw == 1, "boost, and the yaw shown") +end) + +test("a radio's axes, reversed and with a dead zone", function() + local s = radio.map_axes({0.5, -1, 0.0, 0.02, 0, 0}, {1, -2, 3, 4}, 0.03, false, 0.35) + check(s.valid, "valid") + near(s.roll, (0.5 - 0.03) / 0.97, 1e-5, "roll") + near(s.pitch, 1, 1e-6, "pitch reversed") + near(s.throttle, 0.5, 1e-6, "a throttle that stays put is absolute") + near(s.yaw, 0, 1e-6, "inside the dead zone") + local c = radio.map_axes({0, 0, -0.5, 0, 0, 0}, {1, 2, 3, 4}, 0.03, true, 0.35) + check(c.centred and c.climb < 0 and c.throttle < 0.35, "a springing throttle below the middle descends") +end) + +test("a gamepad's sticks: round dead zone, softer middle, inverted pitch", function() + local s = radio.gamepad_sticks({left_x = 0.05, left_y = 0, right_x = 1, right_y = 1}, 0.03, 0.6, 0.45, 0.35, true, false) + near(s.yaw, 0, 1e-6, "inside the dead zone") + check(s.roll > 0 and s.roll <= 0.6, "full roll is scaled down: " .. s.roll) + check(s.pitch < 0, "inverted: up tips the nose up") + near(s.throttle, 0.35, 1e-6, "the middle hovers") + local angle = radio.gamepad_sticks({left_x = 0, left_y = 0, right_x = 1, right_y = 0}, 0.1, 0.6, 0, 0.35, false, true) + near(angle.roll, 1, 1e-6, "in angle mode full stick leans all the way") +end) + +test("switches arm and reset on clear moves only", function() + local memory = {} + local armed, reset = radio.switches(memory, {0, 0, 0, 0, 0.9, 0}, 0, 5, 0, 0, 0) + check(armed == true and not reset, "axis 5 high arms") + armed = radio.switches(memory, {0, 0, 0, 0, 0.3, 0}, 0, 5, 0, 0, 0) + check(armed == nil, "the middle says nothing") + armed = radio.switches(memory, {0, 0, 0, 0, 0.9, 0}, 0, -5, 0, 0, 0) + check(armed == false, "reversed") + local _, first = radio.switches(memory, {0, 0, 0, 0, 0, 0.9}, 0, 0, 0, 6, 0) + local _, again = radio.switches(memory, {0, 0, 0, 0, 0, 0.9}, 0, 0, 0, 6, 0) + check(first and not again, "a reset switch resets once when flicked") + local bits = radio.button_bits({false, false, true}) + check(radio.pressed(bits, 3) and not radio.pressed(bits, 2), "button bits") + local _, held = radio.switches(memory, {0, 0, 0, 0, 0, 0}, bits, 0, 3, 0, 0) + check(held == false, "an arm button") + check(radio.switches(memory, {0, 0, 0, 0, 0, 0}, bits, 0, 3, 0, 0) == true, "held is armed") +end) + +test("gamepad buttons count once each press, and not while the menu has them", function() + local memory = {} + local a = radio.pad_buttons(memory, {a = true}, true) + check(not a, "held from before counts for nothing") + radio.pad_buttons(memory, {a = false}, true) + a = radio.pad_buttons(memory, {a = true}, true) + check(a, "pressed") + a = radio.pad_buttons(memory, {a = true}, true) + check(not a, "held is not pressed again") + radio.pad_buttons(memory, {}, false) + local _, start = radio.pad_buttons(memory, {start = true}, false) + check(not start, "not while the menu has the pad") +end) + +test("the guided setup finds each stick and switch", function() + local c = radio.calibration() + c:start() + local rest = {0, 0, -1, 0, -1, 0} + c:capture_rest(rest, 0) + check(c.step == "throttle", "after rest: throttle") + local function hold(axes, bits) + for _ = 1, 20 do c:update(axes, bits or 0, 0.05) end + end + hold({0, 0, -1, 1, -1, 0}) -- throttle is axis 4 here... and reversed below + hold(rest) + hold({0, 0, -1, 0, -1, 0}) -- (nothing) + check(c.step == "yaw", "throttle found: " .. c.step) + hold({0, 0, 0.5, 0, -1, 0}) -- yaw: axis 3 moved up from -1 + hold(rest) + hold({0, -1, -1, 0, -1, 0}) -- pitch forward is axis 2 going down + hold(rest) + hold({1, 0, -1, 0, -1, 0}) -- roll axis 1 + hold(rest) + check(c.step == "arm", "the sticks are done: " .. c.step) + hold({0, 0, -1, 0, 1, 0}) -- arm switch on axis 5 + check(c.step == "reset", "arm found: " .. c.step) + hold({0, 0, -1, 0, 1, 0}, bit32.lshift(1, 6)) -- reset: button 7 + hold({0, 0, -1, 0, 1, 0}, 0) + check(c.step == "done", "done: " .. c.step) + check(c.map[1] == 1 and c.map[2] == -2 and c.map[3] == 4 and c.map[4] == 3, + string.format("the axes: %d %d %d %d", c.map[1], c.map[2], c.map[3], c.map[4])) + check(c.throttle_centred, "a throttle resting in the middle springs back") + check(c.arm_axis == 5 and c.reset_button == 7, "the switches") +end) + +test("follow mode sits behind a skater riding along, above the ground", function() + local cast = world() + local t = follow.tuning(4.5, 0.5) + local cfg = follow.flight_settings(settings.flight(true), t) + local skater = V(0, 0, 0) + local heading = V(0, 0, 1) + local s = flight.new_state(V(0, 2, -5), heading, true, cfg) + local p = follow.pilot() + local lowest = 1e9 + for _ = 1, 400 do + skater += heading * (5 * DT) + local cmd = follow.fly(p, s, skater, heading, DT, cfg, t, cast) + flight.step(s, cmd, DT, cfg, cast) + lowest = math.min(lowest, s.position.y) + end + local behind = -dot(s.position - skater, heading) + check(behind > 2 and behind < 9, "behind the skater: " .. behind) + check(lowest > 0.5, "never on the ground: " .. lowest) + local to = (skater + V(0, 1, 0)) - s.position + local facing = -s.body.b + check(dot(vector.normalize(V(to.x, 0, to.z)), vector.normalize(V(facing.x, 0, facing.z))) > 0.9, "and looks at the skater") +end) + +test("follow mode films a session from the side", function() + local t = follow.tuning(4.5, 0.5) + local cfg = follow.flight_settings(settings.flight(true), t) + local s = flight.new_state(V(0, 2, -6), V(0, 0, 1), true, cfg) + local p = follow.pilot() + local time = 0 + for _ = 1, 500 do + time += DT + local skater = V(0, 0, 6 * math.sin(time * 1.2)) -- back and forth across a halfpipe + local cmd = follow.fly(p, s, skater, V(0, 0, math.cos(time * 1.2) >= 0 and 1 or -1), DT, cfg, t, nil) + flight.step(s, cmd, DT, cfg, nil) + end + check(p.session, "riding back and forth is a session") + check(math.abs(s.position.x) > 3, "watched from the side: " .. tostring(s.position)) +end) + +test("messages carry where a drone is, and markers glide", function() + local body = flight.level_basis(V(1, 0, 0)) + local q = beacon.rotation(body) + near(q[1] ^ 2 + q[2] ^ 2 + q[3] ^ 2 + q[4] ^ 2, 1, 1e-4, "a unit rotation") + local id = beacon.rotation(flight.level_basis(V(0, 0, -1))) + near(id[4], 1, 1e-4, "facing -z is no rotation") + local m = beacon.decode(beacon.encode(true, V(10, 2, -3), q, 0.5, 12.5)) + check(m and m.flying, "decoded") + near(m.position.x, 10, 1e-4, "x") + near(m.throttle, 0.5, 0.01, "throttle") + near(m.speed, 12.5, 0.01, "speed") + check(beacon.decode("hello") == nil, "not one of ours") + local player = {id = "7656", name = "Zee"} + beacon.receive(player, beacon.encode(true, V(0, 0, 0), q, 0.4, 0), 1000) + beacon.receive(player, beacon.encode(true, V(10, 0, 0), q, 0.4, 0), 1100) + local r = beacon.remotes["7656"] + near(beacon.position(r, 1150).x, 5, 1e-3, "halfway through the glide") + near(beacon.position(r, 1300).x, 10, 1e-3, "there at the end") + beacon.receive(player, beacon.encode(true, V(500, 0, 0), q, 0.4, 0), 1150) + near(beacon.position(r, 1150).x, 500, 1e-3, "a jump is shown at once") + beacon.receive(player, beacon.encode(false, V(0, 0, 0), q, 0, 0), 1200) + check(beacon.remotes["7656"] == nil and beacon.count() == 0, "a stop takes it away") +end) + +test("a flying drone sends about 12 times a second and says when it stops", function() + local cfg = config({}) + local s = flight.new_state(V(0, 5, 0), V(0, 0, -1), true, cfg) + local sent = 0 + for ms = 0, 1000, 10 do + if beacon.outgoing(100000 + ms, s, true) then sent += 1 end + end + check(sent >= 11 and sent <= 14, "sent " .. sent) + local last = beacon.outgoing(101100, nil, true) + check(last and not beacon.decode(last).flying, "and once to say it stopped") + check(beacon.outgoing(101200, nil, true) == nil, "then nothing") +end) + +test("the goggle display shows what the C++ one did", function() + local cfg = config({}) + local s = flight.new_state(V(0, 5, 0), V(0, 0, -1), true, cfg) + s.armed_now = true + s.sticks = {roll = 0.5, pitch = 0, yaw = 0} + local values = table.clone(settings.values) + local layout = osd.layout(s, values) + local texts = {} + for _, item in layout.items do texts[item.text] = item end + check(texts["ARMED"] and texts["00:00"] and texts["THR 35%"] and texts["ALT 0.0M"] and texts["0KM/H"] and texts["16.6V"], + "the readouts") + check(texts["16.6V"].align == "left" and texts["00:00"].align == "right" and texts["THR 35%"].bottom, "where they sit") + near(layout.sticks.throttle, 0.35 * 2 - 1, 1e-6, "a throttle that stays put shows as the throttle") + values.osd = 0 + values.range = 100 + s.signal, s.signal_lost = 0, 2 + s.crashed = true + local warnings = osd.layout(s, values) + local found = {} + for _, item in warnings.items do found[item.text] = item end + check(found["NO SIGNAL - TURN BACK (3)"] and found["CRASHED - ARM TO FLY AGAIN"], "the warnings show without the display") + check(#warnings.items == 2, "and nothing else") + near(osd.pixel_width("ABC", 2), 34, 1e-6, "a pixel string's width") +end) + +test("settings clamp, presets set a whole drone", function() + check(settings.count == 63, "62 of the C++ drone's and the top speed: " .. settings.count) + local before = settings.get("hover") + check(settings.set("hover", 5) == 0.9, "clamped to its range") + check(settings.set("nothing", 1) == nil, "an unknown one is refused") + settings.apply_preset(3) + check(settings.matching_preset() == 3, "the 5 inch matches") + check(settings.get("topspeed") == 10 and settings.get("hover") == 0.22, "its values") + settings.set("rollmax", 300) + check(settings.matching_preset() == nil, "changed: custom") + settings.set("hover", before) +end) + +-- Runs one test every few ticks; `say` prints. +function tests.run(say) + -- From the defaults, whatever the player has set; theirs come back after. + local saved_values, saved_controller = table.clone(settings.values), table.clone(settings.controller) + settings.keep(false) + for name, info in settings.info do settings.values[name] = info.def end + local passed, failed, index = 0, 0, 0 + local timer + local function finish() + reskate.time.cancel(timer) + for name, value in saved_values do settings.values[name] = value end + settings.controller = saved_controller + settings.keep(true) + beacon.clear() + say(string.format("fpv test: %d passed, %d failed", passed, failed)) + end + timer = reskate.time.every(50, function() + index += 1 + local entry = list[index] + if not entry then return finish() end + local ok, why = pcall(entry.fn) + if ok then passed += 1 + else + failed += 1 + say("FAIL " .. entry.name .. ": " .. tostring(why)) + end + end) +end +tests.list = list + +return tests diff --git a/contrib/plugins/fpv-drone/reskate-plugin.json b/contrib/plugins/fpv-drone/reskate-plugin.json new file mode 100644 index 00000000..7afc4879 --- /dev/null +++ b/contrib/plugins/fpv-drone/reskate-plugin.json @@ -0,0 +1,7 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["console", "storage", "overlay", "menu", "input", "camera", "hud", "world.raycast", "skater.read", + "audio", "outfit", "session.read", "session.nametags", "net.session"] +} diff --git a/contrib/plugins/reskate-hostile/plugin/main.lua b/contrib/plugins/reskate-hostile/plugin/main.lua index eac132fe..26f34a1a 100644 --- a/contrib/plugins/reskate-hostile/plugin/main.lua +++ b/contrib/plugins/reskate-hostile/plugin/main.lua @@ -129,6 +129,57 @@ attack("screen-loop", "stopped as its screen is built", function() reskate.ui.open("hostile") end) +attack("notice-spam", "keeps running: five chat notices a second, the rest refused", function() + local shown = 0 + for i = 1, 100 do if reskate.chat.notice("hostile notice " .. i) then shown += 1 end end + reskate.console.print("hostile notice-spam: " .. shown .. " of 100 shown") +end) +attack("notice-spoof", "keeps running: a notice cannot be two lines (a fake line under it)", function() + local ok = pcall(reskate.chat.notice, "hi\nServer: type your password") + reskate.console.print(ok and "ESCAPED: a notice of two lines" or "hostile notice-spoof: refused") +end) +attack("freeze-spam", "keeps running: four freezes a second, the rest refused", function() + local done = 0 + for i = 1, 50 do if reskate.skater.freeze(i % 2 == 1) then done += 1 end end + reskate.skater.freeze(false) + reskate.console.print("hostile freeze-spam: " .. done .. " of 50 asked") +end) +attack("server-spoof", "keeps running: a player's copy cannot rule as the server, nor speak as it", function() + local said = pcall(reskate.server.say, "the server says hi") + local kicked = pcall(reskate.server.kick, "76561197960287930", "bye") + local moved = pcall(reskate.server.move, "76561197960287930", vector.create(0, 0, 0)) + reskate.console.print((said or kicked or moved) and "ESCAPED: a player's copy ruled as the server" or "hostile server-spoof: refused") +end) +attack("picture-escape", "keeps running: a picture from outside the package is refused", function() + local escaped = false + reskate.on("draw", function(d) + if pcall(d.image, d, "../reskate-test/icons/ptest.png", vector.create(0, 0, 0), vector.create(9, 9, 0)) then escaped = true end + if pcall(d.image, d, "C:/Windows/Web/Screen/img100.jpg", vector.create(0, 0, 0), vector.create(9, 9, 0)) then escaped = true end + end) + reskate.time.after(200, function() + reskate.console.print(escaped and "ESCAPED: a picture from outside its package" or "hostile picture-escape: refused") + end) +end) + +attack("clip-garbage", "keeps running: a file that is no clip, or one outside the package, is refused", function() + local a = pcall(reskate.animation.play, "plugin/main.lua") + local b = pcall(reskate.animation.play, "../reskate-test/icons/ptest.png") + local c = pcall(reskate.animation.record, 600) + reskate.console.print((a or b or c) and "ESCAPED: a clip that is none, or a recording too long" or "hostile clip-garbage: refused") +end) +attack("throwdown-spam", "keeps running: one drop every 5 s, the rest refused", function() + local asked = 0 + for i = 1, 20 do if reskate.throwdowns.host("jam", {"76561197960287930"}) then asked += 1 end end + reskate.console.print("hostile throwdown-spam: " .. asked .. " of 20 asked") +end) + +attack("outfit-anywhere", "keeps running: items only in tattoo, moles and eyewear slots, and the board's place is not a plugin's", function() + local top = reskate.outfit.set("top", "anything") + local placed = pcall(reskate.board.set, 1, {position = vector.create(0, 100, 0)}) + reskate.console.print((top or placed) and "ESCAPED: an outfit slot or the board's place" + or "hostile outfit-anywhere: refused") +end) + -- Each returns true when that way out worked. local escapes = { {"io", function() return io ~= nil end}, diff --git a/contrib/plugins/reskate-hostile/reskate-plugin.json b/contrib/plugins/reskate-hostile/reskate-plugin.json index c076684d..5b613116 100644 --- a/contrib/plugins/reskate-hostile/reskate-plugin.json +++ b/contrib/plugins/reskate-hostile/reskate-plugin.json @@ -3,5 +3,7 @@ "entry": "plugin/main.lua", "side": "client", "permissions": ["console", "skater.read", "skater.teleport", "world.raycast", "events.tricks", "overlay", "menu", "audio.ui", "net.session", - "chat.send", "world.objects", "travel", "net.http", "ui.screen", "input"] + "chat.send", "world.objects", "travel", "net.http", "ui.screen", "input", + "chat.local", "skater.control", "camera", "server", + "skater.animation", "throwdowns", "outfit", "board"] } diff --git a/contrib/plugins/reskate-test/icons/ptest.png b/contrib/plugins/reskate-test/icons/ptest.png new file mode 100644 index 00000000..cf9bcde6 Binary files /dev/null and b/contrib/plugins/reskate-test/icons/ptest.png differ diff --git a/contrib/plugins/reskate-test/plugin/checks/more.lua b/contrib/plugins/reskate-test/plugin/checks/more.lua new file mode 100644 index 00000000..aff9bfac --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/more.lua @@ -0,0 +1,182 @@ +local h = require("harness") +local t = h.module("more") + +-- The set after phase 3: chat notices, the camera, freezing, pictures, the game's own songs, the +-- trick's name and manuals, and the server copy. What ptest all checks reads the game and changes +-- nothing; the rest is by hand: ptest notice, camera, follow, freeze, picture, song, skip, manual, +-- server. + +t.check("this copy is the player's", function() assert(reskate.side == "client", tostring(reskate.side)) end) +t.check("a chat notice is one line", function() h.refused(reskate.chat.notice, "two\nlines") end) +t.check("the camera reads, and a view needs a direction and a sane field of view", function() + local view = reskate.camera.get() + assert(view == nil or (typeof(view.position) == "vector" and typeof(view.forward) == "vector" and view.fov > 1), "camera.get") + h.refused(reskate.camera.look, vector.create(1, 1, 1), vector.create(1, 1, 1)) + h.refused(reskate.camera.look, vector.create(0, 0, 0), vector.create(0, 0, 1), 400) + local held, why = reskate.camera.held() + assert(not held and type(why) == "string", "it holds no camera before it asks") +end) +t.check("the game's own song is a track or nothing", function() + local song = reskate.music.game() + assert(song == nil or type(song.title) == "string", "music.game") +end) +t.check("the score names its trick", function() + local score = reskate.score.current() + if not score then return h.skip("no score HUD now") end + assert(type(score.trick) == "string" and type(score.detail) == "string" and type(score.manual) == "boolean") +end) +t.check("only the server copy rules", function() + local err = h.refused(reskate.server.kick, "76561197960287930") + assert(err:find("server copy", 1, true), err) +end) +t.check("clips: the trick's name, nothing playing, and a clip from the package only", function() + local trick = reskate.animation.trick() + assert(trick == nil or type(trick) == "string") + assert(reskate.animation.playing() == false) + h.refused(reskate.animation.play, "clips/missing.fas") + h.refused(reskate.animation.play, "../reskate-hostile/plugin/main.lua") +end) +t.check("a drop is for players in a linked mode", function() + h.refused(reskate.throwdowns.host, "bowling", {"76561197960287930"}) + h.refused(reskate.throwdowns.host, "jam", {}) +end) +t.check("pictures come from the package only", function() + local ok = pcall(reskate.package.read, "icons/ptest.png") + assert(ok, "the test picture is in the package") +end) + +-- ---- by hand --------------------------------------------------------------------------------- +local function said(name, ok, why) + reskate.console.print("ptest " .. name .. ": " .. (ok and "done" or ("refused: " .. tostring(why)))) +end +reskate.console.command("ptest notice", "A chat line only you see", function() + said("notice", reskate.chat.notice("ptest: only you see this line", 0x5ecdfaff)) +end) +-- The camera 8 m behind and 4 m above the skater, looking at it, for 6 s. +reskate.console.command("ptest camera", "Takes the camera for 6 s", function() + local s = reskate.skater.get() + if not s then return reskate.console.print("ptest camera: no skater") end + local back = vector.create(-math.sin(s.heading), 0, -math.cos(s.heading)) * 8 + local ok, why = reskate.camera.look(s.position + back + vector.create(0, 4, 0), s.position + vector.create(0, 1, 0), 60) + said("camera", ok, why) + reskate.time.after(1000, function() + local held, status = reskate.camera.held() + reskate.console.print("ptest camera: " .. (held and "shown" or ("not shown: " .. tostring(status)))) + end) + reskate.time.after(6000, function() + reskate.camera.release() + reskate.console.print("ptest camera: released") + end) +end) +reskate.console.command("ptest follow", "Spectate's camera on another player for 8 s", function() + for _, p in reskate.session.players() do + if not p.me then + said("follow " .. p.name, reskate.camera.follow(p)) + reskate.time.after(8000, function() + reskate.camera.follow(nil) + reskate.console.print("ptest follow: stopped") + end) + return + end + end + reskate.console.print("ptest follow: nobody else is in the session") +end) +local frozen = false +reskate.console.command("ptest freeze", "Holds the skater still, or lets it go", function() + frozen = not frozen + said("freeze " .. (frozen and "on" or "off"), reskate.skater.freeze(frozen)) +end) +local picture_until = 0 +reskate.on("draw", function(d) + if reskate.time.now() > picture_until then return end + d:image("icons/ptest.png", vector.create(60, 220, 0), vector.create(188, 348, 0), {rounding = 12}) + local s = reskate.skater.get() + if s then d:world_image("icons/ptest.png", s.position + vector.create(0, 2.6, 0), 0.8) end +end) +reskate.console.command("ptest picture", "Draws the test picture for 8 s, on screen and over the skater", function() + picture_until = reskate.time.now() + 8000 + reskate.console.print("ptest picture: drawn for 8 s") +end) +reskate.console.command("ptest song", "Says what the game's own music plays", function() + local song = reskate.music.game() + reskate.console.print("ptest song: " .. (song and (song.title .. " by " .. song.artist) or "none (a Spotify station, or no song yet)")) +end) +reskate.console.command("ptest skip", "Skips the game's own song", function() said("skip", reskate.music.game_next()) end) +local manual_until, last_named = 0, nil +reskate.on("tick", function() + if reskate.time.now() > manual_until then return end + local score = reskate.score.current() + if not score then return end + local named = score.trick .. " / " .. score.detail .. (score.manual and " (a manual)" or "") + if named ~= last_named then + last_named = named + reskate.console.print("ptest manual: " .. named) + end +end) +reskate.on("trick_landed", function(e) + if reskate.time.now() <= manual_until then reskate.console.print("ptest manual: landed " .. tostring(e.name)) end +end) +reskate.console.command("ptest manual", "For 20 s, says what the score HUD names (do a manual)", function() + manual_until, last_named = reskate.time.now() + 20000, nil + reskate.console.print("ptest manual: do a manual, and some tricks") +end) +reskate.on("message", function(from, text) + if from.server and text:sub(1, 6) == "pong: " then reskate.console.print("ptest server: the server copy said " .. text:sub(7)) end +end) +reskate.console.command("ptest server", "Asks the session's server copy for a pong", function() + said("server", reskate.net.send("server", "ping")) +end) + +-- Clips: ptest record (3 s of you), ptest anim (plays it on you), ptest replace (your kickflips play it). +local recording = nil +reskate.console.command("ptest record", "Records 3 s of your skater", function() + local ok, why = reskate.animation.record(3) + said("record", ok, why) + if not ok then return end + reskate.time.after(3500, function() + recording = reskate.animation.recorded() + reskate.console.print("ptest record: " .. (recording and ("recorded " .. recording) or "nothing came back")) + end) +end) +reskate.console.command("ptest anim", "Plays the recording on your skater (ptest anim loop: over and over; ptest anim stop)", function(args) + if args[1] == "stop" then + reskate.animation.stop() + return reskate.console.print("ptest anim: stopped") + end + if not recording then return reskate.console.print("ptest anim: run ptest record first") end + said("anim", reskate.animation.play(recording, {blend = 0.2, loop = args[1] == "loop"})) +end) +local replaced = false +reskate.console.command("ptest replace", "Your kickflips play the recording, or the game's own again", function() + if not recording and not replaced then return reskate.console.print("ptest replace: run ptest record first (do a kickflip in it)") end + replaced = not replaced + said("replace kickflip " .. (replaced and "on" or "off"), reskate.animation.replace("kickflip", replaced and recording or nil)) +end) +reskate.console.command("ptest trickname", "Says the game's name for your trick for 10 s", function() + local last, until_ms = nil, reskate.time.now() + 10000 + local id + id = reskate.time.every(50, function() + local name = reskate.animation.trick() + if name and name ~= last then + last = name + reskate.console.print("ptest trickname: the game says " .. name) + end + if reskate.time.now() > until_ms then reskate.time.cancel(id) end + end) +end) +-- Throwdowns: ptest throwdown hosts a Jam Session for everyone else in the session. +reskate.console.command("ptest throwdown", "Hosts a Jam for the other players (ptest throwdown cancel)", function(args) + if args[1] == "cancel" then + reskate.throwdowns.cancel() + return reskate.console.print("ptest throwdown: cancelled") + end + local others = {} + for _, p in reskate.session.players() do + if not p.me then table.insert(others, p) end + end + if #others == 0 then return reskate.console.print("ptest throwdown: nobody else is in the session") end + said("throwdown for " .. #others .. " player(s)", reskate.throwdowns.host(args[1] or "jam", others)) + for _, wait in {2000, 6000, 15000} do + reskate.time.after(wait, function() reskate.console.print("ptest throwdown: " .. tostring(reskate.throwdowns.hosting())) end) + end +end) diff --git a/contrib/plugins/reskate-test/plugin/checks/ports.lua b/contrib/plugins/reskate-test/plugin/checks/ports.lua new file mode 100644 index 00000000..761d430c --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/checks/ports.lua @@ -0,0 +1,144 @@ +local h = require("harness") +local t = h.module("ports") + +-- What the drone, Board Break and Road Rash need to be mods of their own: flight input and capture, +-- a rolled camera, sounds of a package's own and a motor, the skater's physics news, items over the +-- outfit, and the board's bones. ptest all reads; the rest is by hand: ptest fly, pad, sound, +-- physics, wipeout, outfit, board. + +t.check("flight input only with the camera, pads and the board's motion read", function() + local f = reskate.input.flight() + assert(f == nil or type(f.forward) == "number", "flight input is a table, or nil without the plugin's camera") + local pad = reskate.input.gamepad() + assert(pad == nil or (type(pad.left_x) == "number" and type(pad.buttons.a) == "boolean")) + local motion = reskate.board.motion() + assert(motion == nil or typeof(motion.velocity) == "vector") +end) +t.check("sounds come from the package, as waves", function() + h.refused(reskate.audio.play, "icons/ptest.png") + h.refused(reskate.audio.play, "sounds/beep.wav", {volume = 5}) +end) +t.check("the outfit's slots read", function() + local slots = reskate.outfit.slots() + assert(type(slots) == "table" and (#slots == 0 or type(slots[1].slot) == "string")) +end) +t.check("the board's bones read, and bones 0 and 1 are not the plugin's", function() + local bones = reskate.board.bones() + assert(bones == nil or (bones[0] and bones[15] and typeof(bones[2].position) == "vector")) + h.refused(reskate.board.set, 1, {position = vector.create(0, 0, 0)}) + h.refused(reskate.board.hide, 16) +end) + +local function said(name, ok, why) + reskate.console.print("ptest " .. name .. ": " .. (ok and "done" or ("refused: " .. tostring(why)))) +end +-- A free camera for 15 s: WASD, Q/E, Shift and the mouse (right button) fly it; the controller is +-- the plugin's meanwhile (input.capture), and the view rolls with A and D. +local flying = nil +reskate.on("tick", function(dt) + if not flying then return end + local f = flying + if reskate.time.now() > f.until_ms then + flying = nil + reskate.camera.release() + reskate.input.capture(false) + return reskate.console.print("ptest fly: done") + end + reskate.input.capture(true) + local input = reskate.input.flight() + local seconds = (dt or 16) / 1000 + if input then + f.yaw -= input.look_x * 0.003 + f.pitch = math.clamp(f.pitch - input.look_y * 0.003, -1.4, 1.4) + f.roll += ((input.right * 0.5) - f.roll) * math.min(1, seconds * 4) + local forward = vector.create(math.sin(f.yaw) * math.cos(f.pitch), math.sin(f.pitch), math.cos(f.yaw) * math.cos(f.pitch)) + local right = vector.create(-math.cos(f.yaw), 0, math.sin(f.yaw)) -- facing +Z, the view's right is -X + local speed = input.boost and 20 or 6 + f.at += (forward * input.forward + right * input.right + vector.create(0, 1, 0) * input.up) * speed * seconds + f.forward = forward + end + local up = vector.create(0, 1, 0) * math.cos(f.roll) + vector.create(-math.cos(f.yaw), 0, math.sin(f.yaw)) * math.sin(f.roll) + reskate.camera.set(f.at, f.forward, up, 75) +end) +reskate.console.command("ptest fly", "Flies a camera for 15 s (WASD, Q/E, mouse with the right button)", function() + local s = reskate.skater.get() + if not s then return reskate.console.print("ptest fly: no skater") end + flying = {at = s.position + vector.create(0, 2, 0), yaw = s.heading, pitch = 0, roll = 0, + forward = vector.create(math.sin(s.heading), 0, math.cos(s.heading)), until_ms = reskate.time.now() + 15000} + reskate.console.print("ptest fly: flying for 15 s") +end) +reskate.console.command("ptest pad", "Says the gamepad's sticks and the radio's axes for 5 s", function() + local id + local until_ms = reskate.time.now() + 5000 + id = reskate.time.every(500, function() + local pad, stick = reskate.input.gamepad(), reskate.input.joystick() + reskate.console.print(string.format("ptest pad: pad %s, radio %s", + pad and string.format("L %.2f %.2f R %.2f %.2f A %s", pad.left_x, pad.left_y, pad.right_x, pad.right_y, tostring(pad.buttons.a)) or "none", + stick and string.format("%s %.2f %.2f %.2f %.2f", stick.name, stick.axes[1], stick.axes[2], stick.axes[3], stick.axes[4]) or "none")) + if reskate.time.now() > until_ms then reskate.time.cancel(id) end + end) +end) +reskate.console.command("ptest sound", "A beep at the skater, then a motor 5 m ahead revving for 3 s", function() + local beep, why = reskate.audio.play("sounds/beep.wav", {volume = 1}) + said("sound beep", beep, why) + local s = reskate.skater.get() + if not s then return end + local ahead = s.position + vector.create(math.sin(s.heading), 1, math.cos(s.heading)) * 5 + local motor, why2 = reskate.audio.play("motor", {volume = 1, throttle = 0.2, position = ahead, loop = true}) + said("sound motor", motor, why2) + if not motor then return end + for i = 1, 6 do + reskate.time.after(i * 500, function() reskate.audio.set(motor, {throttle = (i % 2 == 0) and 0.2 or 0.9}) end) + end + reskate.time.after(3200, function() reskate.audio.stop(motor) end) +end) +local physics_until = 0 +reskate.on("board_landed", function(e) + if reskate.time.now() < physics_until then + reskate.console.print(string.format("ptest physics: landed at %.1f m/s down onto %s", e.down_speed, e.onto)) + end +end) +reskate.on("body_hit", function(e) + if reskate.time.now() < physics_until then reskate.console.print(string.format("ptest physics: %s hit at %.1f m/s", e.bone, e.impact)) end +end) +reskate.on("bail_end", function(e) + if reskate.time.now() < physics_until then + reskate.console.print(string.format("ptest physics: a fall of %.1f s, left %.0f, right %.0f", e.seconds, e.left, e.right)) + end +end) +reskate.on("bail", function(e) + if reskate.time.now() < physics_until then + reskate.console.print("ptest physics: bail, force " .. tostring(e.force) .. ", cause " .. tostring(e.cause)) + end +end) +reskate.console.command("ptest physics", "For 30 s, says the board's landings, the body's hits and falls", function() + physics_until = reskate.time.now() + 30000 + reskate.console.print("ptest physics: jump, grind and bail") +end) +reskate.console.command("ptest wipeout", "Throws the skater (gently)", function() said("wipeout", reskate.skater.wipeout(1.5)) end) +reskate.console.command("ptest outfit", "Lists the outfit's slots; ptest outfit wears one; ptest outfit off", function(args) + if args[1] == "off" then + reskate.outfit.clear() + return reskate.console.print("ptest outfit: off (" .. reskate.outfit.status() .. ")") + end + if args[1] and args[2] then + local item = reskate.outfit.find(args[2]) or args[2] + return said("outfit " .. args[1] .. " " .. item, reskate.outfit.set(args[1], item, {covers = true})) + end + for _, slot in reskate.outfit.slots() do + reskate.console.print("ptest outfit: " .. slot.slot .. " = " .. (slot.item ~= "" and slot.item or "-")) + end +end) +reskate.console.command("ptest board", "Hides the deck for 3 s, then throws the front truck off for 5 s", function() + said("board hide deck", reskate.board.hide(3)) + reskate.time.after(3000, function() + reskate.board.clear() + local s = reskate.skater.get() + local ground = s and s.position.y or 0 + said("board toss truck", reskate.board.toss({8}, {velocity = vector.create(1, 3, 1), spin = vector.create(4, 2, 0), ground = ground})) + reskate.time.after(5000, function() + reskate.board.clear() + reskate.console.print("ptest board: whole again") + end) + end) +end) diff --git a/contrib/plugins/reskate-test/plugin/main.lua b/contrib/plugins/reskate-test/plugin/main.lua index 7803bdee..cbeb4e88 100644 --- a/contrib/plugins/reskate-test/plugin/main.lua +++ b/contrib/plugins/reskate-test/plugin/main.lua @@ -1,8 +1,15 @@ -- reskate-test: calls every plugin API and says what worked. Run `ptest all` in the console, or -- `ptest ` for one (`ptest list` names them). Each API ReSkate adds gets a module here. +-- The server copy (on a lobby's host, or a dedicated server) is the session's authority: it only +-- answers `ptest server`. +if reskate.side == "server" then + require("server") + return +end + local harness = require("harness") -for _, name in {"base", "time", "level", "package", "json", "require", "console", "storage", "sandbox", "skater", "tricks", "overlay", "menu", "audio", "mods", "session", "wider"} do +for _, name in {"base", "time", "level", "package", "json", "require", "console", "storage", "sandbox", "skater", "tricks", "overlay", "menu", "audio", "mods", "session", "wider", "more", "ports"} do require("checks/" .. name) end diff --git a/contrib/plugins/reskate-test/plugin/server.lua b/contrib/plugins/reskate-test/plugin/server.lua new file mode 100644 index 00000000..a92037a0 --- /dev/null +++ b/contrib/plugins/reskate-test/plugin/server.lua @@ -0,0 +1,11 @@ +-- reskate-test's server copy: on a lobby's host and on a dedicated server, the session's authority. +-- It answers `ptest server` with a pong to everyone and says so in chat. +local pings = 0 +reskate.on("message", function(from, text) + if text ~= "ping" then return end + pings += 1 + reskate.net.send("all", "pong: ping " .. pings .. " from " .. from.name) + reskate.server.say("reskate-test heard ping " .. pings .. " from " .. from.name) + reskate.log("server copy: ping " .. pings .. " from " .. from.name) +end) +reskate.log("reskate-test server copy loaded") diff --git a/contrib/plugins/reskate-test/reskate-plugin.json b/contrib/plugins/reskate-test/reskate-plugin.json index 8b8c3a33..a51b34b4 100644 --- a/contrib/plugins/reskate-test/reskate-plugin.json +++ b/contrib/plugins/reskate-test/reskate-plugin.json @@ -1,12 +1,12 @@ { "api": 1, "entry": "plugin/main.lua", - "side": "client", + "side": "both", "permissions": ["console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera", "world.raycast", "world.objects", "world.environment", "world.ai", "travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http", - "voice.control", "chat.read", "chat.send", "throwdowns", "replay", "music", - "progression.read", "settings.read", "audio.ui", "mods.read"], + "voice.control", "chat.read", "chat.send", "chat.local", "server", "throwdowns", "replay", "music", + "progression.read", "settings.read", "audio.ui", "mods.read", "audio", "outfit", "board"], "domains": ["api.github.com"] } diff --git a/contrib/plugins/reskate-test/sounds/beep.wav b/contrib/plugins/reskate-test/sounds/beep.wav new file mode 100644 index 00000000..9fb9db1f Binary files /dev/null and b/contrib/plugins/reskate-test/sounds/beep.wav differ diff --git a/contrib/plugins/road-rash/manifest.json b/contrib/plugins/road-rash/manifest.json new file mode 100644 index 00000000..0524d8e5 --- /dev/null +++ b/contrib/plugins/road-rash/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Road_Rash", + "version_number": "0.1.0", + "website_url": "https://github.com/Dingo-Shenanigans/ReSkate", + "description": "Bails leave marks on your skater that build up: dust, scrapes, bruises, wounds. Needs the Road Rash items.", + "dependencies": [] +} diff --git a/contrib/plugins/road-rash/plugin/main.lua b/contrib/plugins/road-rash/plugin/main.lua new file mode 100644 index 00000000..ad61cf91 --- /dev/null +++ b/contrib/plugins/road-rash/plugin/main.lua @@ -0,0 +1,192 @@ +-- Road Rash: bails leave marks on the local skater that build up over a session. After ten bails +-- the leg and then the arm of the side that took more of them show road dust and a graze, later +-- real scrapes and bruises, and from twenty raw wounds and a bruised face; the other side follows +-- a few bails behind. A mark never gets smaller until the skater is healed (`roadrash heal`) or +-- the game is closed. +-- +-- The marks are cosmetic items of a content mod (rules.lua has their names), worn over the outfit +-- with reskate.outfit: ReSkate gives the skater back to the game while one of its menus is up and +-- puts them on again after. A slot that holds one of the player's own tattoos or moles keeps it, +-- unless the player lets the wounds cover it. +local rules = require("rules") + +local function setting(key, default) + local value = reskate.storage.get(key) + if type(value) == "boolean" then return value end + return default +end +local settings = {on = setting("on", true), blood = setting("blood", true), covers = setting("covers", false)} + +local tally = rules.tally() +local designs = {} -- which design each part wears: drawn afresh whenever the skater is clean +local worn = {} -- by part: what reskate.outfit.set last took for it +local hold_until = 0 -- nothing goes on before then, so the skater is the game's for a moment +local retry_at = 0 -- after a refusal +local last_fall = -math.huge +local status = "nothing to show" +local missing_told = false + +math.randomseed(math.floor(reskate.time.now()) % 1000003) +local function draw_designs() + for _, part in rules.parts do designs[part] = math.random(1, #rules.designs) end +end +draw_designs() + +-- Takes every mark off. With `again` they go back on a moment later, worked out afresh over the +-- outfit as it is by then: the player's own tattoos come back first when wounds no longer cover them. +local function take_off(again) + reskate.outfit.clear() + table.clear(worn) + if again then hold_until = reskate.time.now() + 1500 end +end +local function heal() + tally = rules.tally() + draw_designs() + missing_told = false + take_off(false) +end + +-- Puts on the item each part's level wants, a few at a time: ReSkate takes 4 changes a second. +local function wear() + local now = reskate.time.now() + if now < hold_until or now < retry_at then return end + if not settings.on then + status = "off" + return + end + local shown, missing = 0, false + for _, part in rules.parts do + local level = tally.levels[part] + if level == 0 then continue end + local item, at = rules.wanted(part, level, designs[part], settings.blood, reskate.outfit.find) + if not item then + missing = true + continue + end + local key = item .. (settings.covers and " covers" or "") + if worn[part] ~= key then + local placement = part ~= "face" and rules.placement(part, at) or nil + local ok, why = reskate.outfit.set(rules.slots[part], item, {placement = placement, covers = settings.covers}) + if not ok then + status = "waiting: " .. tostring(why) + retry_at = now + (tostring(why):find("in a second", 1, true) and 300 or 2000) + return + end + worn[part] = key + end + shown += 1 + end + -- Items are only found while there is a skater: without one, nothing is missing yet. + if missing and shown == 0 then + if not missing_told and #reskate.outfit.slots() > 0 then + missing_told = true + reskate.log("the skater has marks to show, but the Road Rash items are not installed (a content mod holds them)") + end + status = missing_told and "the Road Rash items are not installed (a content mod holds them)" or "waiting for the skater" + else + status = shown > 0 and string.format("%d mark(s) worn", shown) or "nothing to show" + end +end +reskate.time.every(250, wear) + +-- A fall's side from what the limbs of each side hit. +reskate.on("bail_end", function(fall) + if not settings.on then return end + local started = reskate.time.now() - (tonumber(fall.seconds) or 0) * 1000 + -- One slam can enter the wipeout more than once. + if started - last_fall < 1500 then return end + last_fall = started + rules.count(tally, rules.side(tonumber(fall.left) or 0, tonumber(fall.right) or 0)) +end) + +-- ---- the switches -------------------------------------------------------------------------- + +local function save(key, value) + settings[key] = value + reskate.storage.set(key, value) +end +local function set_on(on) + if on == settings.on then return end + save("on", on) + -- Off: the marks go, and so does the count. + if not on then heal() end +end +local function set_blood(blood) + if blood == settings.blood then return end + save("blood", blood) + take_off(true) +end +local function set_covers(covers) + if covers == settings.covers then return end + save("covers", covers) + take_off(true) +end +-- Counts as if the skater had bailed this often, half of it on each side, to look at a stage. +local function set_bails(bails) + heal() + tally = rules.counted(math.min(bails, 1000)) +end + +local function word(on) return on and "on" or "off" end +local function switch(text) + if text == "1" or text == "on" or text == "true" then return true end + if text == "0" or text == "off" or text == "false" then return false end + return nil +end + +-- ---- the console --------------------------------------------------------------------------- + +local function answer(name) + return function(args) + local function say(text) reskate.console.print(name .. ": " .. text) end + local what = args[1] + if what == nil then + say(string.format("%s, %d bail(s) counted (%s); blood %s, covers tattoos %s; %s (outfit: %s)", word(settings.on), tally.bails, + rules.describe(tally), word(settings.blood), word(settings.covers), status, reskate.outfit.status())) + elseif switch(what) ~= nil then + set_on(switch(what)) + say(word(settings.on)) + elseif what == "heal" then + heal() + say("healed") + elseif what == "bails" then + local count = tonumber(args[2]) + if not count or count < 0 or count ~= math.floor(count) then return say("bails needs a count (10, 14 and 20 are the steps)") end + if not settings.on then return say("it is off: " .. name .. " 1 switches it on") end + set_bails(count) + say("counted " .. tally.bails .. " (" .. rules.describe(tally) .. ")") + elseif what == "blood" or what == "tattoos" then + local value = switch(args[2]) + if value ~= nil then + if what == "blood" then set_blood(value) else set_covers(value) end + end + say(what .. " " .. word(if what == "blood" then settings.blood else settings.covers)) + elseif what == "test" then + local passed, failures = require("tests").run() + for _, failure in failures do say("FAIL " .. failure) end + say(string.format("%d passed, %d failed", passed, #failures)) + else + say("say " .. name .. " 0|1, heal, bails , blood 0|1 or tattoos 0|1") + end + end +end +local help = "Bails leave marks that build up (needs the Road Rash items): 0|1, heal, bails , blood 0|1, tattoos 0|1" +-- While ReSkate has a Road Rash of its own, roadrash is its command: this one answers to road rash. +local name = "roadrash" +if not pcall(reskate.console.command, name, help, answer(name)) then + name = "road rash" + reskate.console.command(name, help, answer(name)) +end + +-- ---- the menu ------------------------------------------------------------------------------ + +reskate.menu.panel("Road Rash", function(ui) + set_on(ui:toggle("on", "Road Rash", settings.on)) + set_blood(ui:toggle("blood", "Fresh blood on the worst wounds", settings.blood)) + set_covers(ui:toggle("covers", "Wounds cover your own tattoos and moles", settings.covers)) + ui:label(string.format("%d bail(s) counted: %s", tally.bails, rules.describe(tally))) + ui:label(status) + if ui:button("heal", "HEAL") then heal() end +end) + +reskate.log(string.format("Road Rash %s (blood %s): %s heal takes the marks off", word(settings.on), word(settings.blood), name)) diff --git a/contrib/plugins/road-rash/plugin/rules.lua b/contrib/plugins/road-rash/plugin/rules.lua new file mode 100644 index 00000000..e260d6b9 --- /dev/null +++ b/contrib/plugins/road-rash/plugin/rules.lua @@ -0,0 +1,151 @@ +-- Road Rash's rules, apart from the game: which side of the body a fall hit, how many bails it +-- takes for a part's marks to show and to get worse, what the items that draw them are called and +-- where on a limb they sit. Plain data and pure functions (tests.lua checks them). +local rules = {} + +-- Where marks can sit: the skater has one tattoo layer per arm and leg, and one moles layer on the face. +rules.parts = {"left_leg", "left_arm", "right_leg", "right_arm", "face"} +rules.names = {left_leg = "left leg", left_arm = "left arm", right_leg = "right leg", right_arm = "right arm", face = "face"} +-- The recipe slot each part's item goes into. +rules.slots = {left_leg = "tattoo_legl", left_arm = "tattoo_arml", right_leg = "tattoo_legr", right_arm = "tattoo_armr", face = "moles"} + +local function leg(part) return part == "left_leg" or part == "right_leg" end +local function left(part) return part == "left_leg" or part == "left_arm" end + +-- A limb's marks have three levels, the face's two. +local worst_limb, worst_face = 3, 2 +function rules.worst(part) return part == "face" and worst_face or worst_limb end +-- The bails it takes for the first marks, the bigger ones and the worst ones. +rules.steps = {10, 14, 20} +-- The face follows the worst level of the limbs, and gets worse four bails later. +local face_later = 4 +-- How many bails after the leg of the side that took more falls a limb follows: that side's arm, +-- then the other side's leg and arm. +local arm_later, other_side_later = 1, 4 + +-- ---- a fall -------------------------------------------------------------------------------- + +-- The side that took a fall, from how hard the limbs of each side hit something (bail_end's left +-- and right): the one whose limbs took three fifths of it or more. A fall the limbs hardly felt, +-- or felt alike, is on both. +function rules.side(left_hits, right_hits) + local total = left_hits + right_hits + if not (total >= 2) then return "both" end + local share = left_hits / total + return share >= 0.6 and "left" or share <= 0.4 and "right" or "both" +end + +-- ---- the session --------------------------------------------------------------------------- + +-- The level `bails` give a part, when the left (or the right) side took more of them. +function rules.level_for(part, bails, left_first) + local steps = rules.steps + if part == "face" then + return bails >= steps[3] + face_later and 2 or bails >= steps[3] and 1 or 0 + end + local later = (left(part) == left_first and 0 or other_side_later) + (leg(part) and 0 or arm_later) + if bails < later then return 0 end + local count = bails - later + return count >= steps[3] and 3 or count >= steps[2] and 2 or count >= steps[1] and 1 or 0 +end + +-- Bails counted since the skater was last clean, and the marks they left. A mark never gets +-- smaller: only healing takes it off. +function rules.tally() + local levels = {} + for _, part in rules.parts do levels[part] = 0 end + -- left, right: falls on each side; one on both counts half for each. last_left: the side of + -- the last one-sided fall, which breaks a tie. + return {bails = 0, left = 0, right = 0, last_left = false, levels = levels} +end +function rules.left_first(tally) + return tally.left > tally.right or (tally.left == tally.right and tally.last_left) +end +-- Raises each part to the level the count gives it; true when one changed. +function rules.grow(tally) + local first, changed = rules.left_first(tally), false + for _, part in rules.parts do + local level = math.max(tally.levels[part], rules.level_for(part, tally.bails, first)) + changed = changed or level ~= tally.levels[part] + tally.levels[part] = level + end + return changed +end +-- One more bail, on "left", "right" or "both"; true when a mark changed. +function rules.count(tally, side) + tally.bails += 1 + if side == "both" then + tally.left += 0.5 + tally.right += 0.5 + else + if side == "left" then tally.left += 1 else tally.right += 1 end + tally.last_left = side == "left" + end + return rules.grow(tally) +end +-- A tally as if the skater had bailed `bails` times, half of them on each side. +function rules.counted(bails) + local tally = rules.tally() + tally.bails = bails + tally.left, tally.right = bails / 2, bails / 2 + rules.grow(tally) + return tally +end +-- "left leg 1, left arm 0, ..." +function rules.describe(tally) + local out = {} + for _, part in rules.parts do table.insert(out, rules.names[part] .. " " .. tally.levels[part]) end + return table.concat(out, ", ") +end + +-- ---- the items ----------------------------------------------------------------------------- + +-- The marks are cosmetic items of a content mod: tattoo items for the limbs, moles items for the +-- face, found by the end of their names: RoadRash__[_][_Dry|_Blood]. +-- Set Leg, Arm, Face; LegR is the left leg's set mirrored, for the right leg: a decal sits +-- on a limb like a sticker read from the front, so only a mirrored one puts the marks +-- of the left leg's outer side on the right leg's outer side. +-- design nothing for the first, B_ and C_ for the others: each part wears one design for as +-- long as its marks last. +-- level 1 .. the part's worst; the worst comes dry (scabs) and with fresh blood. +rules.designs = {"", "b_", "c_"} +-- In lower case. `mirrored` asks for the right leg's own set; `design` is 1 to 3. +function rules.item_name(part, mirrored, design, level, blood) + local set = part == "face" and "face_" or not leg(part) and "arm_" or mirrored and "legr_" or "leg_" + local name = "roadrash_" .. set .. (rules.designs[design] or "") .. level + if level >= rules.worst(part) then name ..= blood and "_blood" or "_dry" end + return name +end +-- The item a part wears at a level: of its own design or else the first, from its own set or else +-- the unmirrored one, at that level or else the nearest lower one that is installed. `find` turns +-- a name's ending into the installed item, or nil. Returns the item and the level it is of. +function rules.wanted(part, level, design, blood, find) + for at = level, 1, -1 do + for _, d in {design, 1} do + for _, mirrored in {part == "right_leg", false} do + local found = find(rules.item_name(part, mirrored, d, at, blood)) + if found then return found, at end + end + end + end + return nil, 0 +end + +-- ---- where a limb's decal goes ------------------------------------------------------------- + +-- A tattoo slot places its item with five numbers: x across and y up the limb's piece of the +-- body's tattoo layout (0..1), its size, its turn (0.5 is upright) and one unused. +-- The middle of the kneecap or the point of the elbow on a left limb (the right one mirrors x), +-- how much y changes per centimetre down the limb, and how many centimetres a size of 1 covers. +local leg_spot = {x = 0.37, y = 0.59, y_per_cm = 0.01073, cm_per_size = 76} +local arm_spot = {x = 0.635, y = 0.505, y_per_cm = 0.01192, cm_per_size = 51} +-- A level's picture covers `side` centimetres with its centre `down` centimetres below that point. +local leg_cuts = {{side = 20, down = 0}, {side = 27, down = 2}, {side = 38, down = 7}} +local arm_cuts = {{side = 16, down = 0}, {side = 24, down = 3}, {side = 46, down = 16}} +function rules.placement(part, level) + local spot = leg(part) and leg_spot or arm_spot + local cut = (leg(part) and leg_cuts or arm_cuts)[math.clamp(level, 1, worst_limb)] + return {left(part) and spot.x or 1 - spot.x, spot.y - cut.down * spot.y_per_cm, cut.side / spot.cm_per_size, 0.5, 0.5} +end + +return rules diff --git a/contrib/plugins/road-rash/plugin/tests.lua b/contrib/plugins/road-rash/plugin/tests.lua new file mode 100644 index 00000000..0c2d20fa --- /dev/null +++ b/contrib/plugins/road-rash/plugin/tests.lua @@ -0,0 +1,112 @@ +-- Checks of Road Rash's rules, run by `roadrash test` (and by dingosdk_plugin_tests, without the game). +local rules = require("rules") + +local checks = {} +local function check(title, fn) table.insert(checks, {title = title, fn = fn}) end +local function near(a, b) return math.abs(a - b) < 1e-5 end +local function levels(tally) + local out = {} + for _, part in rules.parts do table.insert(out, tally.levels[part]) end + return table.concat(out, " ") +end + +check("a fall is on the side whose limbs took three fifths of it", function() + assert(rules.side(10, 2) == "left" and rules.side(2, 10) == "right") + assert(rules.side(6, 4) == "left" and rules.side(4, 6) == "right") + assert(rules.side(5, 5) == "both" and rules.side(5.5, 4.5) == "both") +end) +check("a fall the limbs hardly felt is on both", function() + assert(rules.side(1.5, 0) == "both" and rules.side(0, 0) == "both") + assert(rules.side(0 / 0, 1) == "both", "not a number") +end) +check("the leg of the side that took more marks at 10, 14 and 20 bails", function() + for bails, level in {[9] = 0, [10] = 1, [13] = 1, [14] = 2, [19] = 2, [20] = 3, [100] = 3} do + assert(rules.level_for("left_leg", bails, true) == level, bails .. " bails") + assert(rules.level_for("right_leg", bails, false) == level, bails .. " bails on the right") + end +end) +check("its arm one bail behind, the other side four behind", function() + assert(rules.level_for("left_arm", 10, true) == 0 and rules.level_for("left_arm", 11, true) == 1) + assert(rules.level_for("left_arm", 15, true) == 2 and rules.level_for("left_arm", 21, true) == 3) + assert(rules.level_for("right_leg", 13, true) == 0 and rules.level_for("right_leg", 14, true) == 1) + assert(rules.level_for("right_leg", 24, true) == 3) + assert(rules.level_for("right_arm", 14, true) == 0 and rules.level_for("right_arm", 15, true) == 1) + assert(rules.level_for("right_arm", 25, true) == 3 and rules.level_for("left_arm", 25, false) == 3) +end) +check("the face from 20 and 24", function() + for bails, level in {[19] = 0, [20] = 1, [23] = 1, [24] = 2, [60] = 2} do + assert(rules.level_for("face", bails, true) == level and rules.level_for("face", bails, false) == level, bails .. " bails") + end +end) +check("bails are counted on their side, and a mark never gets smaller", function() + local tally = rules.tally() + for _ = 1, 9 do assert(not rules.count(tally, "right")) end + assert(rules.count(tally, "right"), "the tenth marks the right leg") + assert(levels(tally) == "0 0 1 0 0", levels(tally)) + -- Now the left takes more: its leg is marked, and the right keeps what it has. + for _ = 1, 12 do rules.count(tally, "left") end + assert(tally.left == 12 and tally.right == 10 and rules.left_first(tally)) + assert(levels(tally) == "3 3 2 2 1", levels(tally)) +end) +check("a fall on both counts half on each side, and the last one-sided fall breaks a tie", function() + local tally = rules.tally() + rules.count(tally, "left") + rules.count(tally, "right") + rules.count(tally, "both") + assert(tally.left == 1.5 and tally.right == 1.5 and not rules.left_first(tally), "the right was last") + rules.count(tally, "left") + rules.count(tally, "right") + rules.count(tally, "left") + rules.count(tally, "right") + assert(not rules.left_first(tally)) + rules.count(tally, "both") + assert(rules.left_first(tally) == false) +end) +check("bails 20 puts the right side first, half on each", function() + local tally = rules.counted(20) + assert(tally.bails == 20 and tally.left == 10 and tally.right == 10) + assert(levels(tally) == "2 2 3 2 1", levels(tally)) + assert(levels(rules.counted(0)) == "0 0 0 0 0") +end) +check("items are roadrash__, the worst dry or with blood", function() + assert(rules.item_name("left_leg", false, 1, 1, true) == "roadrash_leg_1") + assert(rules.item_name("right_leg", true, 2, 3, true) == "roadrash_legr_b_3_blood") + assert(rules.item_name("right_leg", false, 2, 2, true) == "roadrash_leg_b_2") + assert(rules.item_name("left_arm", false, 1, 3, false) == "roadrash_arm_3_dry") + assert(rules.item_name("face", false, 3, 2, false) == "roadrash_face_c_2_dry") + assert(rules.item_name("face", false, 1, 1, true) == "roadrash_face_1") +end) +check("a missing item falls back to the first design, the unmirrored set, then a lower level", function() + local installed = {roadrash_leg_2 = true, roadrash_leg_b_1 = true, roadrash_face_2_dry = true} + local function find(name) return installed[name] and ("Pack/" .. name) or nil end + local item, at = rules.wanted("right_leg", 3, 3, true, find) + assert(item == "Pack/roadrash_leg_2" and at == 2, tostring(item)) + item, at = rules.wanted("left_leg", 1, 2, true, find) + assert(item == "Pack/roadrash_leg_b_1" and at == 1, tostring(item)) + assert(rules.wanted("face", 2, 1, false, find) == "Pack/roadrash_face_2_dry") + assert(rules.wanted("face", 2, 1, true, find) == nil, "no bloody face installed, and level 1 is missing too") + assert(rules.wanted("left_arm", 3, 1, true, find) == nil) +end) +check("a limb's decal sits at its kneecap or elbow, mirrored on the right", function() + local p = rules.placement("left_leg", 1) + assert(#p == 5 and near(p[1], 0.37) and near(p[2], 0.59) and near(p[3], 20 / 76) and p[4] == 0.5 and p[5] == 0.5) + p = rules.placement("right_leg", 3) + assert(near(p[1], 0.63) and near(p[2], 0.59 - 7 * 0.01073) and near(p[3], 38 / 76)) + p = rules.placement("left_arm", 2) + assert(near(p[1], 0.635) and near(p[2], 0.505 - 3 * 0.01192) and near(p[3], 24 / 51)) + p = rules.placement("right_arm", 3) + assert(near(p[1], 0.365) and near(p[2], 0.505 - 16 * 0.01192) and near(p[3], 46 / 51)) + assert(near(rules.placement("left_leg", 9)[3], 38 / 76), "levels past the worst are the worst") +end) + +local tests = {} +-- The number that passed, and a line for each that failed. +function tests.run() + local passed, failures = 0, {} + for _, c in checks do + local ok, why = pcall(c.fn) + if ok then passed += 1 else table.insert(failures, c.title .. ": " .. tostring(why)) end + end + return passed, failures +end +return tests diff --git a/contrib/plugins/road-rash/reskate-plugin.json b/contrib/plugins/road-rash/reskate-plugin.json new file mode 100644 index 00000000..ed86759a --- /dev/null +++ b/contrib/plugins/road-rash/reskate-plugin.json @@ -0,0 +1,6 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["events.skater", "outfit", "storage", "menu", "console"] +} diff --git a/contrib/plugins/tools/build_fpv_drone_images.py b/contrib/plugins/tools/build_fpv_drone_images.py new file mode 100644 index 00000000..052031f1 --- /dev/null +++ b/contrib/plugins/tools/build_fpv_drone_images.py @@ -0,0 +1,132 @@ +"""Builds the FPV Drone plugin's OSD pictures (contrib/plugins/fpv-drone/images). + +The plugin draws its goggle display with pictures and source rectangles (d:image's uv) rather +than a rectangle per font pixel: + + font.png / font.json the 5x7 pixel font the C++ drone drew (Extension/Skater/drone_font.h), + each glyph once filled and once grown by half a pixel for its outline + static.png grey specks on clear, for analogue static and snow + scanlines.png a dark line every other row + vignette.png dark edges fading to clear, as the C++ drone's four bands + +Run it from the repository root: python contrib/plugins/tools/build_fpv_drone_images.py +""" +import json +import random +from pathlib import Path + +from PIL import Image + +OUT = Path(__file__).resolve().parents[1] / "fpv-drone" / "images" + +# Each glyph is 7 rows; in a row, bit 4 is the leftmost pixel (drone_font.h). +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], + "(": [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], ")": [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], +} +ORDER = "".join(GLYPHS) + +TEXEL = 6 # texels a font pixel +CELL_W, CELL_H = 6 * TEXEL, 8 * TEXEL # a glyph's 5 x 7 with half a pixel all round for the outline +PAD = 1 # clear texels round each cell, so filtering never reads the next one +COLUMNS = 13 + + +def font(): + cells = 2 * len(ORDER) + rows = (cells + COLUMNS - 1) // COLUMNS + pitch_w, pitch_h = CELL_W + 2 * PAD, CELL_H + 2 * PAD + image = Image.new("RGBA", (COLUMNS * pitch_w, rows * pitch_h), (255, 255, 255, 0)) + pixels = image.load() + half = TEXEL // 2 + for outline in (False, True): + for index, ch in enumerate(ORDER): + cell = index + (len(ORDER) if outline else 0) + x0 = (cell % COLUMNS) * pitch_w + PAD + y0 = (cell // COLUMNS) * pitch_h + PAD + for r, bits in enumerate(GLYPHS[ch]): + for c in range(5): + if not bits & (1 << (4 - c)): + continue + grow = half if outline else 0 + left, top = x0 + half + c * TEXEL - grow, y0 + half + r * TEXEL - grow + for y in range(top, top + TEXEL + 2 * grow): + for x in range(left, left + TEXEL + 2 * grow): + pixels[x, y] = (255, 255, 255, 255) + image.save(OUT / "font.png", optimize=True) + w, h = image.size + (OUT / "font.json").write_text(json.dumps({ + "order": ORDER, "columns": COLUMNS, "cell": [CELL_W, CELL_H], "pad": PAD, "size": [w, h], + "outline_offset": len(ORDER), + }, indent=2) + "\n") + + +def static(): + rng = random.Random(7) + size = 512 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + pixels = image.load() + for y in range(size): + for x in range(size): + if rng.random() < 0.012: + v = 150 + int(rng.random() * 105) + pixels[x, y] = (v, v, v, 90 + int(rng.random() * 165)) + image.save(OUT / "static.png", optimize=True) + + +def scanlines(): + image = Image.new("RGBA", (4, 512), (0, 0, 0, 0)) + pixels = image.load() + for y in range(0, 512, 2): + for x in range(4): + pixels[x, y] = (0, 0, 0, 255) + image.save(OUT / "scanlines.png", optimize=True) + + +def vignette(): + size = 256 + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + pixels = image.load() + for y in range(size): + for x in range(size): + u, v = (x + 0.5) / size, (y + 0.5) / size + # The C++ drone's bands: 20% in from the sides and 24% from the top and bottom. + bands = [max(0.0, 1 - u / 0.20), max(0.0, 1 - (1 - u) / 0.20), max(0.0, 1 - v / 0.24), max(0.0, 1 - (1 - v) / 0.24)] + clear = 1.0 + for b in bands: + clear *= 1 - b + pixels[x, y] = (0, 0, 0, int(round((1 - clear) * 255))) + image.save(OUT / "vignette.png", optimize=True) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + font() + static() + scanlines() + vignette() + print("wrote", ", ".join(sorted(p.name for p in OUT.iterdir()))) + + +if __name__ == "__main__": + main() diff --git a/contrib/road-rash.md b/contrib/road-rash.md index 60b13cde..cbc20ff5 100644 --- a/contrib/road-rash.md +++ b/contrib/road-rash.md @@ -2,13 +2,13 @@ Bails leave marks on the local skater that build up over a session: road dust and a graze first, then scrapes and bruises, then raw wounds, a bruised face and (if the player leaves it on) blood. -Built into ReSkate; the pictures themselves come from a content mod. +A plugin, [road-rash](plugins/road-rash); the pictures themselves come from a content mod. It was +built into ReSkate until 2026-10; what follows is how that worked, and how the plugin still works. -Code: [road_rash.cpp](../Extension/RoadRash/road_rash.cpp) (client thread), -[road_rash_model.h](../Extension/RoadRash/road_rash_model.h) (the rules on their own, with -[tests](../Extension/RoadRash/Test/road_rash_tests.cpp)), -[skater_items.h](../Extension/RoadRash/skater_items.h) (reading and changing the skater's recipe), -[road_rash_commands.cpp](../Extension/RoadRash/road_rash_commands.cpp) (console). +Code: the plugin's Lua in [plugins/road-rash/plugin](plugins/road-rash/plugin), and the core +service it wears its marks through, reskate.outfit: +[outfit_overlay.cpp](../Extension/Skater/outfit_overlay.cpp) and +[skater_items.h](../Extension/Skater/skater_items.h) (reading and changing the skater's recipe). ## What the player gets