Plugins: touching other players, hiding one on your screen, and a raycast that hits players

reskate.session.touch, touching, hide, hidden and raycast, and the touch and touch_end events.
The session publishes every skater's body as capsules while a plugin reads them; a hidden
player goes through the same path a throwdown's hidden players do.
This commit is contained in:
AyeZeeBB committed 2026-10-20 18:06:30 -05:00
1 parent 41b5973896
commit 55d5f70d4f
11 files changed
+603 -12

No files matched your search

+53 -9
View File
@@ -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<plugin_feed::Body> local_body;
static std::array<float, 3> local_body_root{};
if (plugin_feed::bodies_wanted()) {
std::vector<plugin_feed::Body> bodies;
std::vector<anim_studio::Joint> 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<std::array<float, 3>> {
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::pair<std::array<float, 3>, std::array<float, 4>>> 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),
+92
View File
@@ -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<PeerSkater> peer_skaters() override { return {{7, {1, 2, 3}, 0.5f, 40}}; }
std::vector<Body> body_list; // what bodies() reads
std::set<std::pair<std::string, std::uint64_t>> hidden_list;
std::vector<Body> 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<std::uint64_t> hidden_players(std::string_view plugin) override {
std::vector<std::uint64_t> 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<std::array<float, 3>> {
if (joint == 103) return std::array<float, 3>{0, 1.6f, 0};
if (joint == 101) return std::array<float, 3>{0, 1.5f, 0};
return std::array<float, 3>{0, static_cast<float>(joint) / 1000, 0};
}, std::pair{std::array<float, 3>{0, 0, 0}, std::array<float, 4>{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<std::array<float, 3>> { return std::array<float, 3>{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();
+2 -2
View File
@@ -17,7 +17,7 @@ namespace {
struct Event {
std::string_view name, permission;
};
constexpr std::array<Event, 26> events{{
constexpr std::array<Event, 28> 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<Event, 26> 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;
+108 -1
View File
@@ -1,5 +1,6 @@
#include "plugin_api.h"
#include "plugin_game.h"
#include "plugin_bodies.h"
#include "lua.h"
#include "lualib.h"
#include <algorithm>
@@ -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<float>(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<int>(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<int>(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<int>(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<int>(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},
+122
View File
@@ -0,0 +1,122 @@
#pragma once
#include "plugin_game.h"
#include <algorithm>
#include <cmath>
#include <optional>
// 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<Vec3, Vec3> 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<float> 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<float> enter_capsule(const Vec3& from, const Vec3& d, const BodyCapsule& c) {
std::optional<float> best;
const auto keep = [&](std::optional<float> 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> nearest(const Body& mine, const Body& theirs) {
std::optional<Nearest> 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<RayHit> ray_hit(const std::vector<Body>& 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<RayHit> 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;
}
}
+19
View File
@@ -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<BodyCapsule> capsules;
};
inline constexpr std::array<const char*, 7> 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<PeerSkater> 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<Body> 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<std::uint64_t> 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.
+14
View File
@@ -323,6 +323,19 @@ public:
}
return result;
}
std::vector<Body> bodies() override {
std::vector<Body> 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<std::uint64_t> 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, {});
+9
View File
@@ -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<std::vector<Body>> 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.
+116
View File
@@ -1,10 +1,12 @@
#pragma once
#include <array>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
@@ -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<float, 3> a{}, b{};
float radius{};
};
struct Body {
std::uint64_t player{}; // 0: the local skater
std::vector<Capsule> capsules;
};
inline constexpr std::array<const char*, 7> 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<Bone, 9> 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 <class Place>
Body body_of(std::uint64_t player, Place&& place, const std::optional<std::pair<std::array<float, 3>, std::array<float, 4>>>& board) {
Body body{player, {}};
for (const auto& bone : body_bones) {
const std::optional<std::array<float, 3>> a = place(bone.from), b = place(bone.to);
if (a && b) body.capsules.push_back({bone.part, *a, *b, bone.radius});
}
const std::optional<std::array<float, 3>> head = place(head_joint), neck = place(neck_joint);
if (head && neck) {
std::array<float, 3> 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<float, 3> 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<float, 3> 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<std::uint16_t> body_joints() {
std::vector<std::uint16_t> 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<Peer> peers;
inline std::vector<Body> bodies; // every other player's, then the local skater's
inline std::chrono::steady_clock::time_point bodies_asked{};
inline std::set<std::pair<std::string, std::uint64_t>> hidden; // (plugin, player)
inline std::map<std::pair<std::string, std::uint64_t>, Tag> tags; // (plugin, player)
// (player, joint): when it was last asked for, and where it is.
inline std::map<std::pair<std::uint64_t, std::uint16_t>, std::chrono::steady_clock::time_point> joints_asked;
@@ -64,6 +138,48 @@ inline void publish_joints(std::map<std::pair<std::uint64_t, std::uint16_t>, 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<Body> 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<Body> 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<std::uint64_t> hidden_by(const std::string& plugin) {
std::lock_guard lock(detail::mutex);
std::vector<std::uint64_t> 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<Peer> peers) {
std::lock_guard lock(detail::mutex);
detail::peers = std::move(peers);
+55
View File
@@ -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<SlamNews> slam) {
});
}
void Plugin::touch_tick(const std::vector<Body>& 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<std::uint64_t> 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) {
+13
View File
@@ -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<float> touch_reach() const noexcept { return touch_reach_; }
const std::map<std::uint64_t, Contact>& contacts() const noexcept { return contacts_; }
void touch_tick(const std::vector<Body>& 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<float> touch_reach_;
std::map<std::uint64_t, Contact> contacts_;
double teleport_second_{};
int teleports_{};
std::vector<plugin_ui::Shape> shapes_;