diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 5649f61d..6ab4bf37 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -616,6 +616,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui nearest_distance(p, local, view) <= s.player_distance * (p.visible ? 1.15f : 1.f); } } + // Hidden on this screen: by the running throwdown's match, or by a plugin (reskate.session.hide). + const auto hidden_here = [](std::uint64_t id) { return throwdowns::kit::hidden(id) || plugin_feed::hidden(id); }; each_active_peer(s, [&](Peer &p) { // A dedicated server has no skater to show. if (!p.member.id || (dedicated_host(s) && p.member.id == s.host_id)) @@ -629,9 +631,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui update_developer_board(s.base, remote_board_entity(), developer_id(p), remote_skater_generation(), p.developer_board, mark_styles(p)); present_audio(p); - update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); - if(throwdowns::kit::hidden(p.member.id)) {update_player_ui(s.base,nullptr,{});p.ui_visible=false;} - if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); + update_party_position(hidden_here(p.member.id)?nullptr:&p.render_pose); + if(hidden_here(p.member.id)) {update_player_ui(s.base,nullptr,{});p.ui_visible=false;} + if (labels && !hidden_here(p.member.id)) label(p); return; } p.visible = false; @@ -656,7 +658,7 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui native_pass = true; p.applied_cosmetics = 0; // the new actor wears no recipe yet } - const bool hidden = throwdown_relay_hides(p.member.id) || throwdowns::kit::hidden(p.member.id); + const bool hidden = throwdown_relay_hides(p.member.id) || hidden_here(p.member.id); const auto spot = hidden ? std::nullopt : throwdown_relay_celebration_offset(p.member.id, p.render_pose.root.position); p.visible = show_remote(s.base, client, local, @@ -691,9 +693,9 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui apply_cosmetics(p, spawning); // The spectate camera follows this every frame; the map and // indicators below only need network-rate updates. - update_party_position(throwdowns::kit::hidden(p.member.id)?nullptr:&p.render_pose); - if (labels && !throwdowns::kit::hidden(p.member.id)) label(p); - if(throwdowns::kit::hidden(p.member.id)) { + update_party_position(hidden_here(p.member.id)?nullptr:&p.render_pose); + if (labels && !hidden_here(p.member.id)) label(p); + if(hidden_here(p.member.id)) { update_player_ui(s.base,nullptr,{});p.ui_visible=false; } else if (!p.ui_visible || now >= p.next_ui_update) { update_player_ui(s.base, &p.render_pose, p.member.name); @@ -714,8 +716,8 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui : "Waiting for player poses."; // No skater for them, but still their name or dot where they are, and where the // Social menu's teleport and spectate find them. - if (!p.shown_wanted && sampled) update_party_position(&p.render_pose); - if (!p.shown_wanted && sampled && labels) label(p); + if (!p.shown_wanted && sampled && !hidden_here(p.member.id)) update_party_position(&p.render_pose); + if (!p.shown_wanted && sampled && labels && !hidden_here(p.member.id)) label(p); } } update_developer_hoodie(s.base, p.visible ? remote_skater_entity() : 0, developer_id(p), @@ -773,6 +775,48 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui } } plugin_feed::publish_joints(std::move(joints)); + // Everyone's body as drawn, while a plugin reads them (touching, reskate.session.raycast). The + // local skater's skeleton is read only when a pose is sent: between those, its body moves with + // its root. + static std::optional local_body; + static std::array local_body_root{}; + if (plugin_feed::bodies_wanted()) { + std::vector bodies; + std::vector pose; + const auto body = [&](std::uint64_t player, const Pose &from) { + pose.clear(); + for (const auto &t : from.skater) pose.push_back({t.scale, t.rotation, t.position}); + const auto place = [&](std::uint16_t joint) -> std::optional> { + if (joint >= pose.size()) return std::nullopt; + const auto w = anim_studio::world(pose, joint); + if (!anim_studio::finite(w)) return std::nullopt; + return w.position; + }; + std::optional, std::array>> board; + if (!from.board.empty()) board = std::pair{from.board.front().position, from.board.front().rotation}; + return plugin_feed::body_of(player, place, board); + }; + for (const auto &p : active_peers(s)) + if (p.member.id && p.placed && !p.render_pose.skater.empty() && + !(dedicated_host(s) && p.member.id == s.host_id)) + bodies.push_back(body(p.member.id, p.render_pose)); + if (!local.ready) local_body.reset(); + else if (!local.pose.skater.empty()) { + local_body = body(0, local.pose); + local_body_root = local.pose.root.position; + } + if (local_body) { + auto moved = *local_body; + for (auto &c : moved.capsules) + for (std::size_t i = 0; i < 3; ++i) { + const float d = local.pose.root.position[i] - local_body_root[i]; + c.a[i] += d; + c.b[i] += d; + } + bodies.push_back(std::move(moved)); + } + plugin_feed::publish_bodies(std::move(bodies)); + } else local_body.reset(); } if (labels) publish_custom_nametags(s.base, std::move(nametags), diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index e6ee0e06..b2efcd93 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -3,6 +3,7 @@ #include "Extension/Plugins/plugin_discovery.h" #include "Extension/Plugins/plugin_host.h" #include "Extension/Plugins/plugin_manifest.h" +#include "Extension/Plugins/plugin_session_feed.h" #include "Extension/Plugins/plugin_wire.h" #include "Extension/Throwdowns/challenge_registry.h" #include "Extension/Throwdowns/custom_modes.h" @@ -265,6 +266,21 @@ struct FakeGame final : Game { std::string ai(std::string_view op, int count) override { calls.push_back(std::format("ai {} {}", op, count)); return {}; } int ai_count() override { return 3; } std::vector peer_skaters() override { return {{7, {1, 2, 3}, 0.5f, 40}}; } + std::vector body_list; // what bodies() reads + std::set> hidden_list; + std::vector bodies() override { return body_list; } + std::string hide_player(std::string_view plugin, std::uint64_t player, bool hidden) override { + calls.push_back(std::format("hide {} {} {}", plugin, player, hidden ? "on" : "off")); + if (hidden) hidden_list.insert({std::string(plugin), player}); + else hidden_list.erase({std::string(plugin), player}); + return {}; + } + std::vector hidden_players(std::string_view plugin) override { + std::vector out; + for (const auto& [owner, who] : hidden_list) + if (owner == plugin) out.push_back(who); + return out; + } void set_nametag(std::string_view plugin, std::uint64_t player, std::string text, std::uint32_t colour) override { calls.push_back(std::format("tag {} {} {} {:08x}", plugin, player, text, colour)); } @@ -1174,6 +1190,81 @@ void plugins_read_joints_and_tune_physics() { check(environment.logged("[sneaky] false false false"), "skater.control, session.skaters and settings.write are needed\n" + environment.all()); } +// Touching another player, hiding one on this screen, and a ray at players. +void plugins_touch_hide_and_ray_at_players() { + // A body from its joints: the limbs between theirs, the head on from the neck, the board along its Z. + const auto body = dingosdk::plugin_feed::body_of(9, [](std::uint16_t joint) -> std::optional> { + if (joint == 103) return std::array{0, 1.6f, 0}; + if (joint == 101) return std::array{0, 1.5f, 0}; + return std::array{0, static_cast(joint) / 1000, 0}; + }, std::pair{std::array{0, 0, 0}, std::array{0, 0.7071068f, 0, 0.7071068f}}); + const auto head = std::find_if(body.capsules.begin(), body.capsules.end(), [](const auto& c) { return c.part == 0; }); + const auto deck = std::find_if(body.capsules.begin(), body.capsules.end(), [](const auto& c) { return c.part == 6; }); + check(body.capsules.size() == 11 && head != body.capsules.end() && std::abs(head->b[1] - 1.76f) < 1e-4f && deck != body.capsules.end() && + std::abs(deck->a[0] + 0.3f) < 1e-4f && std::abs(deck->b[0] - 0.3f) < 1e-4f && std::abs(deck->a[1] - 0.08f) < 1e-4f, + "A body: nine limb capsules, the head 16 cm on from the neck, the deck along its length turned 90 degrees"); + const auto gone = dingosdk::plugin_feed::body_of(9, [](std::uint16_t) -> std::optional> { return std::array{0, NAN, 0}; }, + std::nullopt); + check(gone.capsules.empty(), "No body from a pose that is not finite"); + + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const auto upright = [](std::uint64_t player, float x, float y) { + return Body{player, {{1, {x, y, 0}, {x, y + 1, 0}, 0.16f}, {6, {x, y, -0.3f}, {x, y, 0.3f}, 0.12f}}}; + }; + game.body_list = {upright(0, 0, 0), upright(9, 1, 0), upright(12, 5, 0.5f), upright(13, 8, -3)}; + const std::string main = R"( + local hit = reskate.session.raycast(vector.create(0, 1, 0), vector.create(10, 1, 0)) + assert(hit and hit.player == "9" and hit.part == "body" and math.abs(hit.distance - 0.84) < 1e-4 and math.abs(hit.position.x - 0.84) < 1e-4, + "the nearest player, at their body's surface: " .. tostring(hit and hit.distance)) + assert(reskate.session.raycast(vector.create(0, 3, 2), vector.create(10, 3, 2)) == nil, "over everyone") + assert(reskate.session.raycast(vector.create(8, 3, 0), vector.create(8, -5, 0)) == nil, "the ground in the way") + local under = reskate.session.raycast(vector.create(8, 3, 0), vector.create(8, -5, 0), {world = false}) + assert(under and under.player == "13", "through the world") + assert(not pcall(reskate.session.raycast, vector.create(0, 0, 0), vector.create(2000, 0, 0)), "too long") + assert(reskate.session.hide("12") and reskate.session.hidden()[1] == "12") + assert(reskate.session.hide({id = "12"}, false) and #reskate.session.hidden() == 0) + assert(not pcall(reskate.session.touch, true, 2), "a reach is 0 to 1 m") + reskate.session.touch(true) + reskate.on("touch", function(e) + reskate.log(string.format("touch %s %s %s %.2f %d", e.player, e.part, e.their_part, e.distance, #reskate.session.touching())) + end) + reskate.on("touch_end", function(e) reskate.log("apart " .. e.player .. " " .. #reskate.session.touching()) end) + reskate.log("rayed") + )"; + host.apply({package("tag", {{manifest_file, manifest(R"("session.skaters")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 2); + running(host, "tag", environment); + check(environment.logged("[tag] rayed") && game.called("hide tag 12 on") && game.called("hide tag 12 off"), + "Rays at players and a player hidden and shown\n" + environment.all()); + check(!environment.logged("[tag] touch "), "Nobody within reach\n" + environment.all()); + const auto count = [&](std::string_view part) { + return std::count_if(environment.logs.begin(), environment.logs.end(), [&](const auto& line) { return line.find(part) != std::string::npos; }); + }; + game.body_list[1] = upright(9, 0.3f, 0); + ticks(host, environment, 2); + check(environment.logged("[tag] touch 9 body body 0.00 1") && count("[tag] touch ") == 1, "Touching: once, as they come into contact\n" + environment.all()); + game.body_list[1] = upright(9, 0.4f, 0); + ticks(host, environment, 2); + check(count("[tag] touch ") == 1 && !environment.logged("[tag] apart"), "Still touching a little further apart\n" + environment.all()); + game.body_list[1] = upright(9, 0.6f, 0); + ticks(host, environment, 1); + check(environment.logged("[tag] apart 9 0"), "Apart once 10 cm further than the reach\n" + environment.all()); + game.body_list[1] = upright(9, 0.3f, 0); + ticks(host, environment, 1); + game.body_list.erase(game.body_list.begin() + 1); + ticks(host, environment, 1); + check(count("[tag] touch ") == 2 && count("[tag] apart 9") == 2, "Touching again, and apart when they leave\n" + environment.all()); + host.apply({package("sneaky", {{manifest_file, manifest(R"("skater.read")")}, {"plugin/main.lua", R"( + reskate.log(tostring(pcall(reskate.session.touch, true)) .. " " .. tostring(pcall(reskate.session.hide, "9")) .. " " .. + tostring(pcall(reskate.session.raycast, vector.create(0, 1, 0), vector.create(1, 1, 0))) .. " " .. + tostring(pcall(reskate.on, "touch", function() end))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false false false false"), "session.skaters is needed\n" + environment.all()); +} + // Typing into a field a plugin draws (only while its screen is open), and the character room. void plugins_type_and_use_the_room() { TestEnvironment environment; @@ -3047,6 +3138,7 @@ int main() { plugins_reach_cruise_and_read_the_mouse(); plugins_read_joints_and_tune_physics(); plugins_type_and_use_the_room(); + plugins_touch_hide_and_ray_at_players(); plugins_edit_clips(); animator_studio_records_and_saves(); plugins_server_copies_are_the_authority(); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index 17327475..f0b8b216 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -17,7 +17,7 @@ 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"}, @@ -25,7 +25,7 @@ constexpr std::array events{{ {"throwdown", "throwdowns"}, {"action", "input"}, {"screen_closed", "ui.screen"}, {"board_landed", "events.skater"}, {"body_hit", "events.skater"}, {"bail_end", "events.skater"}, {"physics_step", "events.physics"}, {"grind_exit", "events.skater"}, {"trick_notes", "events.tricks"}, - {"throwdown_ui", "throwdowns"}, + {"throwdown_ui", "throwdowns"}, {"touch", "session.skaters"}, {"touch_end", "session.skaters"}, }}; constexpr std::size_t maximum_key = 128; constexpr std::size_t maximum_command = 48; diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index b03e2e9a..7618988a 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -1,5 +1,6 @@ #include "plugin_api.h" #include "plugin_game.h" +#include "plugin_bodies.h" #include "lua.h" #include "lualib.h" #include @@ -1201,6 +1202,110 @@ struct WorldApi { const auto joint = joint_of(L, 2); return push_joint(L, game(plugin).skater_joint(player, joint), joint); } + // session.touch(on, reach): "touch" and "touch_end" events while on, for contact within `reach` + // metres (0 to 1, 0.05 when not given) between the local skater's body and another player's. + static int session_touch(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.touch"); + if (!lua_isnone(L, 1) && !lua_toboolean(L, 1)) { + plugin.touch_reach_.reset(); + plugin.contacts_.clear(); + return 0; + } + const double reach = luaL_optnumber(L, 2, 0.05); + if (!(reach >= 0 && reach <= 1)) luaL_argerror(L, 2, "a reach is 0 to 1 m"); + plugin.touch_reach_ = static_cast(reach); + (void)game(plugin).bodies(); // they are read from now on + return 0; + } + static void push_part(lua_State* L, std::uint8_t part, const char* field) { + lua_pushstring(L, body_part_names[part]); + lua_setfield(L, -2, field); + } + // session.touching(): the players touched now, {player, part, their_part, distance, position} each. + static int session_touching(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.touching"); + lua_createtable(L, static_cast(plugin.contacts_.size()), 0); + int n = 0; + for (const auto& [player, c] : plugin.contacts_) { + lua_createtable(L, 0, 5); + const auto id = std::to_string(player); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "player"); + push_part(L, c.part, "part"); + push_part(L, c.their_part, "their_part"); + lua_pushnumber(L, std::max(c.gap, 0.f)); + lua_setfield(L, -2, "distance"); + push_position(L, c.at); + lua_setfield(L, -2, "position"); + lua_rawseti(L, -2, ++n); + } + return 1; + } + // session.hide(player, hidden): that player's skater not drawn on this screen (true or nothing), or + // drawn again (false). + static int session_hide(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.hide"); + const auto player = player_of(L, 1); + return result(L, game(plugin).hide_player(plugin.id(), player, lua_isnone(L, 2) || lua_toboolean(L, 2))); + } + static int session_hidden(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.hidden"); + const auto list = game(plugin).hidden_players(plugin.id()); + lua_createtable(L, static_cast(list.size()), 0); + for (std::size_t i = 0; i < list.size(); ++i) { + const auto id = std::to_string(list[i]); + lua_pushlstring(L, id.data(), id.size()); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + // session.raycast(from, to, {world = true}): the first other player's body the ray enters, + // {player, part, position, distance}, or nil; with `world`, nil when the world is in the way. + static int session_raycast(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "session.skaters", "reskate.session.raycast"); + const auto from = position_of(L, 1, "the ray's start"); + const auto to = position_of(L, 2, "the ray's end"); + bool world = true; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "world"); + if (!lua_isnil(L, -1)) world = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + } + const float dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2]; + if (std::sqrt(dx * dx + dy * dy + dz * dz) > plugin.limits_.raycast_metres) + luaL_error(L, "a ray is at most %d m long", static_cast(plugin.limits_.raycast_metres)); + if (++plugin.raycasts_ > plugin.limits_.raycasts_per_tick) + luaL_error(L, "more than %d raycasts in one tick", plugin.limits_.raycasts_per_tick); + const auto hit = bodies::ray_hit(game(plugin).bodies(), from, to); + // The world in the way: anything it hits more than 0.4 m before the player (their own + // collision capsule, when players are solid, stands about that far out from their body). + bool blocked = false; + if (hit && world) + if (const auto wall = plugin.services_.raycast(from, hit->position)) { + const float wx = (*wall)[0] - from[0], wy = (*wall)[1] - from[1], wz = (*wall)[2] - from[2]; + blocked = std::sqrt(wx * wx + wy * wy + wz * wz) < hit->distance - 0.4f; + } + if (!hit || blocked) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 4); + const auto id = std::to_string(hit->player); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "player"); + push_part(L, hit->part, "part"); + push_position(L, hit->position); + lua_setfield(L, -2, "position"); + lua_pushnumber(L, hit->distance); + lua_setfield(L, -2, "distance"); + return 1; + } // skater.hand(side): {position, rotation} of that hand joint in the world as last drawn, or nil. static int hand(lua_State* L) { auto& plugin = plugin_of(L); @@ -2214,7 +2319,9 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}}); add_functions(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}}); add_functions(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}}); - add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}}); + add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}, {"touch", session_touch}, + {"touching", session_touching}, {"hide", session_hide}, {"hidden", session_hidden}, + {"raycast", session_raycast}}); add_functions(L, "voice", {{"set_volume", voice_volume}}); add_functions(L, "chat", {{"send", chat_send}, {"notice", chat_local}}); add_functions(L, "animation", {{"play", animation_play}, {"stop", animation_stop}, {"playing", animation_playing}, {"replace", animation_replace}, diff --git a/Extension/Plugins/plugin_bodies.h b/Extension/Plugins/plugin_bodies.h new file mode 100644 index 00000000..b56d5328 --- /dev/null +++ b/Extension/Plugins/plugin_bodies.h @@ -0,0 +1,122 @@ +#pragma once +#include "plugin_game.h" +#include +#include +#include + +// Touching and the raycast at players (reskate.session.touch, reskate.session.raycast), on the +// skaters' bodies as capsules (Game::bodies). Header only. +namespace dingosdk::plugins::bodies { +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 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 float dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +// The closest points of segments p1-q1 and p2-q2 (Ericson, Real-Time Collision Detection 5.1.9). +inline std::pair closest(const Vec3& p1, const Vec3& q1, const Vec3& p2, const Vec3& q2) { + const auto d1 = sub(q1, p1), d2 = sub(q2, p2), r = sub(p1, p2); + const float a = dot(d1, d1), e = dot(d2, d2), f = dot(d2, r); + constexpr float tiny = 1e-8f; + float s{}, t{}; + if (a <= tiny && e <= tiny) return {p1, p2}; + if (a <= tiny) t = std::clamp(f / e, 0.f, 1.f); + else { + const float c = dot(d1, r); + if (e <= tiny) s = std::clamp(-c / a, 0.f, 1.f); + else { + const float b = dot(d1, d2), denom = a * e - b * b; + s = denom > tiny ? std::clamp((b * f - c * e) / denom, 0.f, 1.f) : 0.f; + t = (b * s + f) / e; + if (t < 0) { + t = 0; + s = std::clamp(-c / a, 0.f, 1.f); + } else if (t > 1) { + t = 1; + s = std::clamp((b - c) / a, 0.f, 1.f); + } + } + } + return {add(p1, scale(d1, s)), add(p2, scale(d2, t))}; +} +// Where along from + t * d (t 0..1) the ray first enters a sphere, or empty. +inline std::optional enter_sphere(const Vec3& from, const Vec3& d, const Vec3& centre, float radius) { + const auto m = sub(from, centre); + const float a = dot(d, d), b = dot(m, d), c = dot(m, m) - radius * radius; + if (c <= 0) return 0.f; // starts inside + if (a <= 1e-12f || b > 0) return std::nullopt; + const float disc = b * b - a * c; + if (disc < 0) return std::nullopt; + const float t = (-b - std::sqrt(disc)) / a; + if (t > 1) return std::nullopt; + return std::max(t, 0.f); +} +// The same for a capsule: its two end spheres and the cylinder between them. +inline std::optional enter_capsule(const Vec3& from, const Vec3& d, const BodyCapsule& c) { + std::optional best; + const auto keep = [&](std::optional t) { + if (t && (!best || *t < *best)) best = t; + }; + keep(enter_sphere(from, d, c.a, c.radius)); + keep(enter_sphere(from, d, c.b, c.radius)); + const auto axis = sub(c.b, c.a); + const float length2 = dot(axis, axis); + if (length2 > 1e-8f) { + // The ray and the axis with the axis taken out: a circle in the plane across it. + const auto m = sub(from, c.a); + const auto across = [&](const Vec3& v) { return sub(v, scale(axis, dot(v, axis) / length2)); }; + const auto dp = across(d), mp = across(m); + const float a = dot(dp, dp), b = dot(mp, dp), k = dot(mp, mp) - c.radius * c.radius; + if (a > 1e-12f) { + const float disc = b * b - a * k; + if (disc >= 0) { + const float t = std::max((-b - std::sqrt(disc)) / a, 0.f); + if (t <= 1) { + const float along = dot(add(m, scale(d, t)), axis) / length2; + if (along >= 0 && along <= 1 && (k > 0 || t == 0)) keep(t); + } + } + } + } + return best; +} +} + +// The nearest two capsules of two bodies: the gap between their surfaces (negative where they +// overlap) and the point halfway across it. +struct Nearest { + std::uint8_t mine{}, theirs{}; + float gap{}; + Vec3 at{}; +}; +inline std::optional nearest(const Body& mine, const Body& theirs) { + std::optional best; + for (const auto& a : mine.capsules) + for (const auto& b : theirs.capsules) { + const auto [pa, pb] = detail::closest(a.a, a.b, b.a, b.b); + const auto apart = detail::sub(pb, pa); + const float gap = std::sqrt(detail::dot(apart, apart)) - a.radius - b.radius; + if (!best || gap < best->gap) best = Nearest{a.part, b.part, gap, detail::scale(detail::add(pa, pb), 0.5f)}; + } + return best; +} + +// The first body part of another player (not player 0) a ray from `from` to `to` enters. +struct RayHit { + std::uint64_t player{}; + std::uint8_t part{}; + Vec3 position{}; + float distance{}; +}; +inline std::optional ray_hit(const std::vector& list, const Vec3& from, const Vec3& to) { + const auto d = detail::sub(to, from); + const float length = std::sqrt(detail::dot(d, d)); + std::optional best; + for (const auto& body : list) { + if (!body.player) continue; + for (const auto& c : body.capsules) + if (const auto t = detail::enter_capsule(from, d, c); t && (!best || *t * length < best->distance)) + best = RayHit{body.player, c.part, detail::add(from, detail::scale(d, *t)), *t * length}; + } + return best; +} +} diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 45642c46..97754965 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -165,6 +165,17 @@ struct LevelInfo { std::string asset, name; bool can_load{}; }; +// A skater's body as capsules: `part` indexes body_part_names; a and b are its ends in the world. +struct BodyCapsule { + std::uint8_t part{}; + Vec3 a{}, b{}; + float radius{}; +}; +struct Body { + std::uint64_t player{}; // 0: the local skater + std::vector capsules; +}; +inline constexpr std::array body_part_names{"head", "body", "left_arm", "right_arm", "left_leg", "right_leg", "board"}; // Another player's skater as the session last heard it. struct PeerSkater { std::uint64_t id{}; @@ -295,6 +306,14 @@ public: virtual int ai_count() { return 0; } // ---- the session ---------------------------------------------------------------------------- virtual std::vector peer_skaters() { return {}; } + // Every skater's body in the session as this game drew it last (plugin_session_feed.h body_of): + // the other players' (hidden ones too) and the local skater's, as player 0. Asking keeps them read + // for two seconds, so the first ask comes back empty. + virtual std::vector bodies() { return {}; } + // Another player's skater, board, sound, nametag and map marker not shown on this screen while + // any plugin hides them (at most 256 between them). + virtual std::string hide_player(std::string_view /*plugin*/, std::uint64_t, bool /*hidden*/) { return unavailable; } + virtual std::vector hidden_players(std::string_view /*plugin*/) { return {}; } // A short tag beside a player's nametag, the plugin's own (empty text takes it away). virtual void set_nametag(std::string_view /*plugin*/, std::uint64_t, std::string, std::uint32_t) {} // 0 mutes, 1 is as heard, up to 10. The local player's ears only. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 7428b0e2..440033a1 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -323,6 +323,19 @@ public: } return result; } + std::vector bodies() override { + std::vector result; + for (const auto& b : plugin_feed::latest_bodies()) { + Body body{b.player, {}}; + for (const auto& c : b.capsules) body.capsules.push_back({c.part, c.a, c.b, c.radius}); + result.push_back(std::move(body)); + } + return result; + } + std::string hide_player(std::string_view plugin, std::uint64_t player, bool hidden) override { + return plugin_feed::hide(std::string(plugin), player, hidden) ? std::string{} : "at most 256 players are hidden"; + } + std::vector hidden_players(std::string_view plugin) override { return plugin_feed::hidden_by(std::string(plugin)); } void set_nametag(std::string_view plugin, std::uint64_t player, std::string text, std::uint32_t colour) override { plugin_feed::set_tag(std::string(plugin), player, std::move(text), colour); } @@ -1326,6 +1339,7 @@ public: bool frozen() const noexcept { return !freezers_.empty(); } void release(std::string_view plugin) override { if (hold_owner_ == plugin) (void)skater_hold(plugin, false); + plugin_feed::release_hidden(std::string(plugin)); world_entities::clear(plugin); throwdown_flag_leave(plugin); dialog_close(plugin, {}); diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 007cb243..a79530ad 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -327,6 +327,15 @@ void Host::follow_game() { plugin.game_event("grind_exit", "events.skater", data); } }); + // Touching (reskate.session.touch): the bodies read once for every plugin that has it on. + { + std::optional> list; + each([&](Plugin& plugin, const std::string&) { + if (!plugin.touch_reach()) return; + if (!list) list = game.bodies(); + plugin.touch_tick(*list); + }); + } if (wants("chat.read")) { auto lines = game.chat_since(chat_seen_); // Lines from before the first plugin listened are not news. diff --git a/Extension/Plugins/plugin_session_feed.h b/Extension/Plugins/plugin_session_feed.h index 027725ed..c97f0df5 100644 --- a/Extension/Plugins/plugin_session_feed.h +++ b/Extension/Plugins/plugin_session_feed.h @@ -1,10 +1,12 @@ #pragma once #include #include +#include #include #include #include #include +#include #include #include #include @@ -30,10 +32,82 @@ struct Tag { std::string text; std::uint32_t colour{0xffffffffu}; // IM_COL32 layout }; +// A skater's body as drawn, as capsules (a sphere when both ends meet), for plugins' touching and +// their raycast at players. `part` indexes body_parts. +struct Capsule { + std::uint8_t part{}; + std::array a{}, b{}; + float radius{}; +}; +struct Body { + std::uint64_t player{}; // 0: the local skater + std::vector capsules; +}; +inline constexpr std::array body_parts{"head", "body", "left_arm", "right_arm", "left_leg", "right_leg", "board"}; +// The joints each limb's capsules run between (Extension/Skater/skater_skeleton.h): hips to neck, +// shoulder to elbow to hand, hip to knee to foot. +struct Bone { + std::uint8_t part; + std::uint16_t from, to; + float radius; +}; +inline constexpr std::array body_bones{{{1, 7, 101, 0.16f}, + {2, 276, 277, 0.06f}, {2, 277, 278, 0.06f}, + {3, 47, 48, 0.06f}, {3, 48, 49, 0.06f}, + {4, 341, 342, 0.08f}, {4, 342, 343, 0.08f}, + {5, 8, 9, 0.08f}, {5, 9, 10, 0.08f}}}; +inline constexpr std::uint16_t head_joint = 103, neck_joint = 101; +// The body from where its joints are in the world (`place(joint)`, empty when unknown) and where its +// board is (its entity's position and rotation): the head from the head joint 0.16 m on away from the +// neck; the board along its length (its local Z), as the players' collision has it (remote_collision.cpp). +template +Body body_of(std::uint64_t player, Place&& place, const std::optional, std::array>>& board) { + Body body{player, {}}; + for (const auto& bone : body_bones) { + const std::optional> a = place(bone.from), b = place(bone.to); + if (a && b) body.capsules.push_back({bone.part, *a, *b, bone.radius}); + } + const std::optional> head = place(head_joint), neck = place(neck_joint); + if (head && neck) { + std::array up{(*head)[0] - (*neck)[0], (*head)[1] - (*neck)[1], (*head)[2] - (*neck)[2]}; + const float length = std::sqrt(up[0] * up[0] + up[1] * up[1] + up[2] * up[2]); + if (length > 1e-4f) + for (auto& v : up) v *= 0.16f / length; + body.capsules.push_back({0, *head, {(*head)[0] + up[0], (*head)[1] + up[1], (*head)[2] + up[2]}, 0.11f}); + } + if (board) { + const auto& [at, q] = *board; + // The deck's local Z turned into the world: q * (0, 0, 1) and the 0.08 m above it, q * (0, 1, 0). + const std::array z{2 * (q[0] * q[2] + q[3] * q[1]), 2 * (q[1] * q[2] - q[3] * q[0]), 1 - 2 * (q[0] * q[0] + q[1] * q[1])}; + const std::array y{2 * (q[0] * q[1] - q[3] * q[2]), 1 - 2 * (q[0] * q[0] + q[2] * q[2]), 2 * (q[1] * q[2] + q[3] * q[0])}; + Capsule deck{6, {}, {}, 0.12f}; + for (std::size_t i = 0; i < 3; ++i) { + deck.a[i] = at[i] + 0.08f * y[i] - 0.3f * z[i]; + deck.b[i] = at[i] + 0.08f * y[i] + 0.3f * z[i]; + } + body.capsules.push_back(deck); + } + for (const auto& c : body.capsules) + for (std::size_t i = 0; i < 3; ++i) + if (!std::isfinite(c.a[i]) || !std::isfinite(c.b[i])) return {player, {}}; + return body; +} +// The joints body_of reads. +inline std::vector body_joints() { + std::vector joints{head_joint, neck_joint}; + for (const auto& bone : body_bones) { + joints.push_back(bone.from); + joints.push_back(bone.to); + } + return joints; +} namespace detail { inline std::mutex mutex; inline std::vector peers; +inline std::vector bodies; // every other player's, then the local skater's +inline std::chrono::steady_clock::time_point bodies_asked{}; +inline std::set> hidden; // (plugin, player) inline std::map, Tag> tags; // (plugin, player) // (player, joint): when it was last asked for, and where it is. inline std::map, std::chrono::steady_clock::time_point> joints_asked; @@ -64,6 +138,48 @@ inline void publish_joints(std::map, Joi detail::joints = std::move(joints); } +// The bodies of the skaters in the session (body_of), asked for by the plugins' side: asking keeps +// them published for two seconds, so the first ask comes back empty. +inline std::vector latest_bodies() { + std::lock_guard lock(detail::mutex); + detail::bodies_asked = std::chrono::steady_clock::now(); + return detail::bodies; +} +inline bool bodies_wanted() { + std::lock_guard lock(detail::mutex); + return std::chrono::steady_clock::now() - detail::bodies_asked < std::chrono::seconds(2); +} +inline void publish_bodies(std::vector bodies) { + std::lock_guard lock(detail::mutex); + detail::bodies = std::move(bodies); +} +// Another player's skater, board, sound, nametag and map marker not shown on this screen while any +// plugin hides them; at most 256 are kept. +inline bool hide(const std::string& plugin, std::uint64_t player, bool on) { + std::lock_guard lock(detail::mutex); + if (!on) detail::hidden.erase({plugin, player}); + else if (detail::hidden.size() < 256 || detail::hidden.contains({plugin, player})) detail::hidden.insert({plugin, player}); + else return false; + return true; +} +inline bool hidden(std::uint64_t player) { + std::lock_guard lock(detail::mutex); + for (const auto& [owner, who] : detail::hidden) + if (who == player) return true; + return false; +} +inline std::vector hidden_by(const std::string& plugin) { + std::lock_guard lock(detail::mutex); + std::vector out; + for (const auto& [owner, who] : detail::hidden) + if (owner == plugin) out.push_back(who); + return out; +} +inline void release_hidden(const std::string& plugin) { + std::lock_guard lock(detail::mutex); + std::erase_if(detail::hidden, [&](const auto& item) { return item.first == plugin; }); +} + inline void publish_peers(std::vector peers) { std::lock_guard lock(detail::mutex); detail::peers = std::move(peers); diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index 8ace1271..ab23b289 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -1,6 +1,7 @@ #include "plugin_vm.h" #include "plugin_api.h" #include "plugin_wire.h" +#include "plugin_bodies.h" #include "lua.h" #include "luacode.h" #include "lualib.h" @@ -607,6 +608,60 @@ void Plugin::bailed(std::uint64_t wipeouts, std::optional slam) { }); } +void Plugin::touch_tick(const std::vector& list) { + if (state_ != State::running || !has("session.skaters") || !touch_reach_) return; + const auto tell = [&](std::string_view event, std::uint64_t player, const Contact& c) { + fire_with(event, [&](lua_State* L) { + lua_createtable(L, 0, 5); + const auto id = std::to_string(player); + lua_pushlstring(L, id.data(), id.size()); + lua_setfield(L, -2, "player"); + lua_pushstring(L, body_part_names[c.part]); + lua_setfield(L, -2, "part"); + lua_pushstring(L, body_part_names[c.their_part]); + lua_setfield(L, -2, "their_part"); + lua_pushnumber(L, std::max(c.gap, 0.f)); + lua_setfield(L, -2, "distance"); + lua_pushvector(L, c.at[0], c.at[1], c.at[2]); + lua_setfield(L, -2, "position"); + return 1; + }); + }; + const Body* mine = nullptr; + for (const auto& body : list) + if (!body.player) mine = &body; + std::set seen; + for (const auto& body : list) { + if (!body.player || !mine) continue; + const auto near = bodies::nearest(*mine, body); + if (!near) continue; + seen.insert(body.player); + const Contact now{near->mine, near->theirs, near->gap, near->at}; + const auto was = contacts_.find(body.player); + if (was == contacts_.end()) { + if (near->gap > *touch_reach_ || contacts_.size() >= 64) continue; + contacts_[body.player] = now; + tell("touch", body.player, now); + } else if (near->gap <= *touch_reach_ + 0.1f) { + was->second = now; + } else { + contacts_.erase(was); + tell("touch_end", body.player, now); + } + if (state_ != State::running || !touch_reach_) return; // a handler stopped it, or touching + } + for (auto it = contacts_.begin(); it != contacts_.end();) { + if (seen.contains(it->first)) { + ++it; + continue; + } + const auto [player, last] = *it; + it = contacts_.erase(it); + tell("touch_end", player, last); + if (state_ != State::running || !touch_reach_) return; + } +} + void Plugin::chat_line(const ChatLine& line) { if (state_ != State::running || !has("chat.read")) return; fire_with("chat", [&](lua_State* L) { diff --git a/Extension/Plugins/plugin_vm.h b/Extension/Plugins/plugin_vm.h index 0bdf30fc..5a52a752 100644 --- a/Extension/Plugins/plugin_vm.h +++ b/Extension/Plugins/plugin_vm.h @@ -220,6 +220,17 @@ public: // 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); + // session.skaters: touching (reskate.session.touch). How near counts as touching while it is on, + // and the players its skater touches now; touch_tick fires "touch" as one comes within reach and + // "touch_end" once the two are 10 cm further apart than that (or the player is gone). + struct Contact { + std::uint8_t part{}, their_part{}; + float gap{}; + Vec3 at{}; + }; + std::optional touch_reach() const noexcept { return touch_reach_; } + const std::map& contacts() const noexcept { return contacts_; } + void touch_tick(const std::vector& bodies); void track_changed(const Track& track); void throwdown_phase(const char* phase); void action(const std::string& name); @@ -303,6 +314,8 @@ private: double log_second_{}; int log_lines_{}; int raycasts_{}; // since the last tick + std::optional touch_reach_; + std::map contacts_; double teleport_second_{}; int teleports_{}; std::vector shapes_;