diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 0d1958ea..9c55eaf2 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -268,10 +268,14 @@ struct FakeGame final : Game { } 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)); + std::string play_clip(std::string_view plugin, ClipRef clip, float speed, float blend, bool loop, float at, bool paused) override { + calls.push_back(std::format("play {} {} frames {:.1f} {:.2f} {}", plugin, clip->times.size(), speed, blend, loop) + + (at > 0 || paused ? std::format(" at {:.2f}{}", at, paused ? " paused" : "") : std::string{})); return {}; } + void seek_clip(std::string_view plugin, float seconds) override { calls.push_back(std::format("seek {} {:.2f}", plugin, seconds)); } + void pause_clip(std::string_view plugin, bool paused) override { calls.push_back(std::format("pause {} {}", plugin, paused)); } + std::optional clip_time(std::string_view) override { return 0.25f; } 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")); @@ -283,6 +287,8 @@ struct FakeGame final : Game { clip->joints = dingosdk::anim_studio::skeleton_joints; clip->times = {0, 0.25f, 0.5f}; clip->frames.resize(clip->times.size() * clip->joints); + clip->takeoff = 0.1f; + clip->landing = 0.4f; recorded = std::move(clip); return {}; } @@ -1155,6 +1161,65 @@ 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()); } +// A clip read, sampled, cut, keyed, saved and read back from the data folder, and played held. +void plugins_edit_clips() { + TestEnvironment environment; + auto& game = environment.fake; + Host host(environment, Side::client); + const std::string main = R"( + assert(reskate.animation.record(2)) + local rec = reskate.animation.recorded() + local i = reskate.animation.info(rec) + assert(i.frames == 3 and i.duration == 0.5 and i.joints == 395 and not i.board, "a recording's info") + assert(math.abs(i.takeoff - 0.1) < 1e-6 and math.abs(i.landing - 0.4) < 1e-6, "with its takeoff and landing") + local cut = reskate.animation.trim(rec, 0.2, 0.5) + local c = reskate.animation.info(cut) + assert(cut == "clip:2" and c.frames == 2 and math.abs(c.duration - 0.25) < 1e-6, "a trim from its first frame") + assert(c.takeoff == nil and math.abs(c.landing - 0.15) < 1e-6, "its marks moved with it") + local work = reskate.animation.edit(rec, {{joint = "hips", keys = {{time = 0, angles = vector.create(0, 0, 90)}, + {time = 0.5, angles = vector.create(0, 0, 90), offset = vector.create(0, 0.5, 0), ease = "linear"}}}}, {name = "work"}) + assert(work == "edit:work", work) + local s = reskate.animation.sample(work, 7, 0.5) + assert(math.abs(s.rotation[3] - 0.70711) < 1e-3 and math.abs(s.rotation[4] - 0.70711) < 1e-3, "the hips turned 90 degrees") + assert(math.abs(s.position.y - 0.5) < 1e-4 and math.abs(reskate.animation.sample(work, "hips", 0.25).position.y - 0.25) < 1e-4, "and moved, blended") + assert(reskate.animation.sample(rec, 7, 0.5).rotation[4] == 1, "the recording stays as it was") + assert(reskate.animation.edit(rec, {}, {name = "work"}) == "edit:work", "the same name again") + assert(reskate.animation.sample("edit:work", 7, 0.5).rotation[4] == 1, "replaces it") + local list = reskate.animation.joints() + assert(#list == 25 and list[1].joint == "board" and list[2].joint == 7 and list[2].label == "Hips" and list[2].moves, "the editable joints") + assert(reskate.animation.save(work, "anims/work.fas")) + local keyed = reskate.animation.edit(rec, {{joint = 7, keys = {{time = 0.5, offset = vector.create(0, 0.5, 0)}}}}) + assert(reskate.animation.save(keyed, "anims/keyed.fas")) + local back = reskate.animation.info("data:anims/keyed.fas") + assert(back.frames == 3 and math.abs(reskate.animation.sample("data:anims/keyed.fas", "hips", 0.5).position.y - 0.5) < 1e-4, "saved and read back") + assert(reskate.animation.play(work, {at = 0.25, paused = true})) + reskate.animation.seek(0.4) + reskate.animation.pause(false) + assert(reskate.animation.time() == 0.25) + assert(not pcall(reskate.animation.edit, rec, {{joint = 1, keys = {}}}), "the placement joint") + assert(not pcall(reskate.animation.edit, rec, {{joint = 7, keys = {{time = 0, ease = "bouncy"}}}}), "an ease") + assert(not pcall(reskate.animation.edit, rec, {{joint = 7, keys = {}}, {joint = "hips", keys = {}}}), "a joint once") + assert(not pcall(reskate.animation.edit, rec, {}, {name = "../x"}), "a name") + assert(not pcall(reskate.animation.trim, rec, 0.3, 0.2) and not pcall(reskate.animation.trim, rec, 0.26, 0.3), "a trim keeps two frames") + assert(not pcall(reskate.animation.info, "data:none.fas") and not pcall(reskate.animation.info, "edit:none"), "clips there are not") + assert(not pcall(reskate.animation.play, work, {at = 9}), "at is within the clip") + reskate.log("edited") + )"; + host.apply({package("editor", {{manifest_file, manifest(R"("skater.animation", "files")")}, {"plugin/main.lua", main}})}); + ticks(host, environment, 1); + running(host, "editor", environment); + check(environment.logged("[editor] edited"), "A plugin edits clips\n" + environment.all()); + check(game.called("play editor 3 frames 1.0 0.15 false at 0.25 paused") && game.called("seek editor 0.40") && game.called("pause editor false"), + "and plays one held, moved and let run\n" + environment.all()); + host.apply({package("sneaky", {{manifest_file, manifest(R"("skater.animation")")}, {"plugin/main.lua", R"( + assert(reskate.animation.record(2)) + local rec = reskate.animation.recorded() + reskate.log(tostring(pcall(reskate.animation.save, rec, "x.fas")) .. " " .. tostring(pcall(reskate.animation.info, "data:x.fas"))) + )"}})}); + ticks(host, environment, 1); + check(environment.logged("[sneaky] false false"), "files is needed for data clips\n" + environment.all()); +} + void plugins_draw_on_the_overlay() { TestEnvironment environment; Host host(environment, Side::client); @@ -2068,6 +2133,43 @@ void dual_stick_edits_the_companions_settings() { "Goofy picked on the page is written:\n" + after + environment.all()); } +// Animator Studio as a mod (contrib/plugins/animator-studio): it records a trick, opens on it, keys a +// joint, saves it to play in game, and puts it back in game when it starts again. +void animator_studio_records_and_saves() { + TestEnvironment environment; + auto& game = environment.fake; + game.files.clear(); + Host host(environment, Side::client); + host.apply({contributed("animator-studio")}); + running(host, "animator-studio", environment); + check(host.run_console("studio status") && environment.printed("studio: closed; nothing recorded; 0 saved"), + "It starts closed:\n" + printed_lines(environment) + environment.all()); + environment.local_skater = SkaterState{{1, 0, 2}, 0, 0, 0, false, 0}; + check(host.run_console("studio record") && game.called("record animator-studio 10.0"), "It records\n" + environment.all()); + ticks(host, environment, 3); + environment.lines.clear(); + check(host.run_console("studio status") && environment.printed("studio: open; kickflip, 0.50 s; 0 saved"), + "and opens on the trick:\n" + printed_lines(environment) + environment.all()); + ticks(host, environment, 2); + check(game.called("play animator-studio 3 frames 1.0 0.00 true at 0.00 paused"), "held on the skater\n" + environment.all()); + // Saved to play in game: a clip file and its keys, and the trick replaced. + environment.lines.clear(); + check(host.run_console("studio ingame on") && host.run_console("studio save Kick One") && environment.printed("studio: Saved Kick One"), + "It saves:\n" + printed_lines(environment) + environment.all()); + check(game.files.contains("animator-studio/anims/Kick One.fas") && game.files.contains("animator-studio/anims/Kick One.json"), + "a clip file and its keys in the data folder\n" + environment.all()); + check(game.called("replace animator-studio kickflip any clip"), "and plays in game\n" + environment.all()); + // Started again, it puts it back in game. + game.calls.clear(); + host.apply({}); + host.apply({contributed("animator-studio")}); + running(host, "animator-studio", environment); + check(game.called("replace animator-studio kickflip any clip") && environment.logged("[animator-studio] 1 saved animation(s) play in game"), + "Started again, the saved trick plays in game\n" + environment.all()); + environment.lines.clear(); + check(host.run_console("studio list") && environment.printed("studio: Kick One"), "and lists it:\n" + printed_lines(environment)); +} + // Rock to Fakie as a mod (contrib/plugins/rock-to-fakie), on reskate.tricks.label and record_as: off // until switched on; then a held stall shows the label, B pressed in it asks for fakie_rock, and // leaving the stall gives the label back. @@ -2866,6 +2968,8 @@ int main() { plugins_place_world_entities(); plugins_reach_cruise_and_read_the_mouse(); plugins_read_joints_and_tune_physics(); + plugins_edit_clips(); + animator_studio_records_and_saves(); plugins_server_copies_are_the_authority(); plugins_animate_the_skater(); plugins_play_their_own_sounds(); diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index 91d6002f..90e26494 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -52,6 +52,9 @@ Plugin& plugin_of(lua_State* L); void need(lua_State* L, Plugin& plugin, const char* permission, const char* what); // A string argument of at most `most` bytes. std::string checked_text(lua_State* L, int index, std::size_t most, const char* what); +// A path in the plugin's data folder (reskate.files): relative, inside it, no name Windows keeps +// for a device. `folder`: nil or "" is the folder itself, and a "/" at the end is fine. +std::string data_path(lua_State* L, int index, bool folder); // A vector argument, finite and within 100 km of the map's centre. Vec3 position_of(lua_State* L, int index, const char* what); void push_position(lua_State* L, const Vec3& v); diff --git a/Extension/Plugins/plugin_api_throwdowns.cpp b/Extension/Plugins/plugin_api_throwdowns.cpp index 7571c323..d5b6ef2e 100644 --- a/Extension/Plugins/plugin_api_throwdowns.cpp +++ b/Extension/Plugins/plugin_api_throwdowns.cpp @@ -69,6 +69,7 @@ std::string own_mode(lua_State* L, int index) { if (id.empty() || id.find('/') != std::string::npos) luaL_argerror(L, index, "a mode id of 1 to 32 characters, without /"); return id; } +} // namespace // A path in the plugin's data folder: relative, inside it, and no name Windows keeps for a device. std::string data_path(lua_State* L, int index, bool folder) { if (folder && lua_isnoneornil(L, index)) return {}; @@ -88,6 +89,7 @@ std::string data_path(lua_State* L, int index, bool folder) { } return path; } +namespace { } // namespace struct KitApi { diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index f97cb0c3..3ba5d323 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -667,13 +667,22 @@ struct WorldApi { } // ---- 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:". + // A clip by its path in the package (an Animation Studio clip file, FAS1 to FAS3), "data:" in + // the plugin's data folder (with files), or one the plugin made: "rec:", "clip:", "edit:". 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 (name.starts_with("rec:") || name.starts_with("clip:") || name.starts_with("edit:")) luaL_error(L, "there is no clip %s", name.c_str()); + std::string error, bytes; + if (name.starts_with("data:")) { + need(L, plugin, "files", "a data: clip"); + lua_pushlstring(L, name.data() + 5, name.size() - 5); + const auto path = data_path(L, lua_gettop(L), false); + lua_pop(L, 1); + if (const auto why = game(plugin).files_read_bytes(plugin.id(), path, bytes, 8u << 20); !why.empty()) error = why; + } else { + 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) { @@ -726,9 +735,10 @@ struct WorldApi { 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"); + const bool loop = flag_field(L, 2, "loop"), paused = flag_field(L, 2, "paused"); + const float at = number_field(L, 2, "at", 0, 0, clip->duration()); 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)); + return result(L, game(plugin).play_clip(plugin.id(), std::move(clip), speed, blend, loop, at, paused)); } static int animation_stop(lua_State* L) { auto& plugin = animating(L, "reskate.animation.stop"); @@ -794,6 +804,250 @@ struct WorldApi { } return 0; } + // seek(seconds), pause(on), time(): the plugin's playing clip moved, held or let run, and where it is. + static int animation_seek(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.seek"); + const double t = luaL_checknumber(L, 1); + if (!std::isfinite(t) || t < 0) luaL_argerror(L, 1, "seconds into the clip, 0 or more"); + game(plugin).seek_clip(plugin.id(), static_cast(t)); + return 0; + } + static int animation_pause(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.pause"); + game(plugin).pause_clip(plugin.id(), lua_isnone(L, 1) || lua_toboolean(L, 1) != 0); + return 0; + } + static int animation_time(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.time"); + if (const auto t = game(plugin).clip_time(plugin.id())) lua_pushnumber(L, *t); + else lua_pushnil(L); + return 1; + } + // A clip's joint, or "board" (the board's mount, relative to the skater's placement). + static std::uint16_t clip_joint(lua_State* L, int index) { + if (lua_type(L, index) == LUA_TSTRING && std::string_view(lua_tostring(L, index)) == "board") return anim_studio::board_joint; + return joint_of(L, index); + } + static void push_marks(lua_State* L, float takeoff, float landing) { + if (takeoff >= 0) { + lua_pushnumber(L, takeoff); + lua_setfield(L, -2, "takeoff"); + } + if (landing >= 0) { + lua_pushnumber(L, landing); + lua_setfield(L, -2, "landing"); + } + } + // info(clip): {duration, frames, joints, board, takeoff, landing}. + static int animation_info(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.info"); + const auto clip = clip_at(L, plugin, 1); + lua_createtable(L, 0, 6); + lua_pushnumber(L, clip->duration()); + lua_setfield(L, -2, "duration"); + lua_pushinteger(L, static_cast(clip->times.size())); + lua_setfield(L, -2, "frames"); + lua_pushinteger(L, static_cast(clip->joints)); + lua_setfield(L, -2, "joints"); + lua_pushboolean(L, clip->board.size() == clip->times.size() && !clip->board.empty()); + lua_setfield(L, -2, "board"); + push_marks(L, clip->takeoff, clip->landing); + return 1; + } + // sample(clip, joint, seconds): the joint in its parent's frame at that time, {position, rotation, scale}. + static int animation_sample(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.sample"); + const auto clip = clip_at(L, plugin, 1); + const auto joint = clip_joint(L, 2); + const double t = luaL_checknumber(L, 3); + if (!std::isfinite(t)) luaL_argerror(L, 3, "seconds into the clip"); + anim_studio::Joint out; + if (joint == anim_studio::board_joint) { + if (!clip->sample_board(static_cast(t), out)) { + lua_pushnil(L); + return 1; + } + } else { + if (joint >= clip->joints) { + lua_pushnil(L); + return 1; + } + thread_local std::vector pose; + pose.resize(clip->joints); + clip->sample(static_cast(t), pose); + out = pose[joint]; + } + lua_createtable(L, 0, 3); + push_position(L, out.position); + lua_setfield(L, -2, "position"); + push_quaternion(L, out.rotation); + lua_setfield(L, -2, "rotation"); + lua_pushnumber(L, (out.scale[0] + out.scale[1] + out.scale[2]) / 3); + lua_setfield(L, -2, "scale"); + return 1; + } + // joints(): the joints an animation editor offers, in order: {joint, label, moves}. + static int animation_joints(lua_State* L) { + (void)animating(L, "reskate.animation.joints"); + const auto list = anim_studio::editable_joints(); + lua_createtable(L, static_cast(list.size()), 0); + for (std::size_t i = 0; i < list.size(); ++i) { + lua_createtable(L, 0, 3); + if (list[i].index == anim_studio::board_joint) lua_pushstring(L, "board"); + else lua_pushinteger(L, list[i].index); + lua_setfield(L, -2, "joint"); + lua_pushlstring(L, list[i].label.data(), list[i].label.size()); + lua_setfield(L, -2, "label"); + lua_pushboolean(L, list[i].moves); + lua_setfield(L, -2, "moves"); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + } + // A clip the plugin made, kept under `name` (one it held under that name before is let go). + static void keep_clip(lua_State* L, Plugin& plugin, const std::string& name, std::shared_ptr 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); + } + if (plugin.clip_bytes_ + pose_bytes(*clip) > 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)); + plugin.clip_bytes_ += pose_bytes(*clip); + plugin.clips_.emplace(name, std::move(clip)); + lua_pushlstring(L, name.data(), name.size()); + } + // trim(clip, from, to): the frames between those seconds as a new clip ("clip:"), from 0. + static int animation_trim(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.trim"); + const auto source = clip_at(L, plugin, 1); + const double from = luaL_checknumber(L, 2), to = luaL_checknumber(L, 3); + if (!(from >= 0 && to > from)) luaL_error(L, "a trim is from 0 or more to later than that"); + auto clip = std::make_shared(); + clip->joints = source->joints; + const bool board = source->board.size() == source->times.size(); + float first = -1; + for (std::size_t i = 0; i < source->times.size(); ++i) { + const float t = source->times[i]; + if (t < from || t > to) continue; + if (first < 0) first = t; + clip->times.push_back(t - first); + const auto frame = source->frame(i); + clip->frames.insert(clip->frames.end(), frame.begin(), frame.end()); + if (board) clip->board.push_back(source->board[i]); + } + if (clip->times.size() < 2) luaL_error(L, "a trim keeps at least two frames"); + const auto mark = [&](float t) { return t >= first && t - first <= clip->times.back() ? t - first : -1.0f; }; + clip->takeoff = source->takeoff >= 0 ? mark(source->takeoff) : -1; + clip->landing = source->landing >= 0 ? mark(source->landing) : -1; + keep_clip(L, plugin, "clip:" + std::to_string(++plugin.recordings_), std::move(clip)); + return 1; + } + // edit(clip, tracks, {name, takeoff, landing}): the clip with keyframed edits on its joints (each + // turned in its own frame and moved in its parent's), baked into a new clip: "edit:", kept in + // place of the last one of that name, or "clip:". + static int animation_edit(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.edit"); + const auto source = clip_at(L, plugin, 1); + luaL_checktype(L, 2, LUA_TTABLE); + anim_studio::Animation edits; + const float length = source->duration(); + std::size_t keys = 0; + const int tracks = static_cast(lua_objlen(L, 2)); + if (tracks > 128) luaL_error(L, "at most 128 tracks"); + for (int t = 1; t <= tracks; ++t) { + lua_rawgeti(L, 2, t); + luaL_checktype(L, -1, LUA_TTABLE); + const int track = lua_gettop(L); + lua_getfield(L, track, "joint"); + const auto joint = clip_joint(L, lua_gettop(L)); + lua_pop(L, 1); + if (joint == anim_studio::placement_joint || joint == 0) luaL_error(L, "joints 0 and 1 place the skater: they are not edited"); + if (edits.track(joint)) luaL_error(L, "joint %d has two tracks", static_cast(joint)); + edits.tracks.push_back({joint, {}, false, false}); + auto& out = edits.tracks.back(); + lua_getfield(L, track, "keys"); + luaL_checktype(L, -1, LUA_TTABLE); + const int list = lua_gettop(L); + const int count = static_cast(lua_objlen(L, list)); + for (int k = 1; k <= count; ++k) { + if (++keys > 4096) luaL_error(L, "at most 4096 keys"); + lua_rawgeti(L, list, k); + luaL_checktype(L, -1, LUA_TTABLE); + const int at = lua_gettop(L); + anim_studio::Key key; + lua_getfield(L, at, "time"); + const double time = luaL_checknumber(L, -1); + if (!std::isfinite(time)) luaL_error(L, "a key's time is a number"); + key.time = std::clamp(static_cast(time), 0.0f, length); + lua_pop(L, 1); + lua_getfield(L, at, "rotation"); + if (!lua_isnil(L, -1)) key.rotation = quaternion_at(L, lua_gettop(L)); + lua_pop(L, 1); + lua_getfield(L, at, "angles"); + if (!lua_isnil(L, -1)) { + const auto a = position_of(L, lua_gettop(L), "a key's angles"); + key.rotation = anim_studio::from_euler({a[0], a[1], a[2]}); + } + lua_pop(L, 1); + lua_getfield(L, at, "offset"); + if (!lua_isnil(L, -1)) { + key.offset = position_of(L, lua_gettop(L), "a key's offset"); + for (const auto v : key.offset) + if (std::abs(v) > 10) luaL_error(L, "a key's offset is at most 10 m on each axis"); + } + lua_pop(L, 1); + lua_getfield(L, at, "ease"); + if (!lua_isnil(L, -1)) { + const std::string ease = luaL_checkstring(L, -1); + if (ease == "linear") key.ease = anim_studio::Ease::linear; + else if (ease == "step") key.ease = anim_studio::Ease::step; + else if (ease != "smooth") luaL_error(L, "a key's ease is \"smooth\", \"linear\" or \"step\""); + } + lua_pop(L, 1); + const auto place = std::upper_bound(out.keys.begin(), out.keys.end(), key.time, [](float v, const anim_studio::Key& e) { return v < e.time; }); + out.keys.insert(place, key); + lua_pop(L, 1); + } + lua_pop(L, 2); + } + std::string name; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "name"); + if (!lua_isnil(L, -1)) { + name = checked_text(L, lua_gettop(L), 48, "an edit's name"); + if (name.empty() || anim_studio::file_safe(name) != name) luaL_error(L, "an edit's name is letters, digits, space, - and _"); + } + lua_pop(L, 1); + } + auto clip = std::make_shared(*source); + clip->takeoff = number_field(L, 3, "takeoff", source->takeoff, -1, length); + clip->landing = number_field(L, 3, "landing", source->landing, -1, length); + const bool board = clip->board.size() == clip->times.size(); + const auto* board_track = edits.track(anim_studio::board_joint); + for (std::size_t i = 0; i < clip->times.size(); ++i) { + const float t = clip->times[i]; + anim_studio::apply_edits(edits, t, std::span(clip->frames.data() + i * clip->joints, clip->joints)); + if (board && board_track && !board_track->keys.empty() && !board_track->hidden) { + const auto e = anim_studio::evaluate(*board_track, t); + auto& b = clip->board[i]; + b.rotation = anim_studio::normalised(anim_studio::multiply(b.rotation, e.rotation)); + for (std::size_t a = 0; a < 3; ++a) b.position[a] += e.offset[a]; + } + } + keep_clip(L, plugin, name.empty() ? "clip:" + std::to_string(++plugin.recordings_) : "edit:" + name, std::move(clip)); + return 1; + } + // save(clip, path): the clip as a clip file (FAS3) in the plugin's data folder, at most 8 MB. + static int animation_save(lua_State* L) { + auto& plugin = animating(L, "reskate.animation.save"); + need(L, plugin, "files", "reskate.animation.save"); + const auto clip = clip_at(L, plugin, 1); + const auto path = data_path(L, 2, false); + const auto bytes = anim_studio::clip_bytes(*clip); + if (bytes.size() > (8u << 20)) return result(L, "a clip file is at most 8 MB"); + return result(L, game(plugin).files_write_bytes(plugin.id(), path, std::string_view(reinterpret_cast(bytes.data()), bytes.size()), 8u << 20)); + } static int animation_trick(lua_State* L) { auto& plugin = animating(L, "reskate.animation.trick"); const auto name = game(plugin).current_trick(); @@ -1802,11 +2056,56 @@ struct WorldApi { if (plugin.screens_.size() >= 4) luaL_error(L, "at most 4 screens"); for (const auto& entry : plugin.screens_) if (entry.title == title) luaL_error(L, "the screen \"%s\" is there already", title.c_str()); + // {dock = "left" | "right", width = 0.2 to 0.6}: beside the game instead of over it. + int dock = 0; + float width = 0.3f; + if (!lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "dock"); + if (!lua_isnil(L, -1)) { + const std::string side = luaL_checkstring(L, -1); + if (side == "left") dock = 1; + else if (side == "right") dock = 2; + else luaL_error(L, "a screen docks \"left\" or \"right\""); + } + lua_pop(L, 1); + lua_getfield(L, 3, "width"); + if (!lua_isnil(L, -1)) { + const double w = luaL_checknumber(L, -1); + if (!(w >= 0.2 && w <= 0.6)) luaL_error(L, "a docked screen's width is 0.2 to 0.6 of the window"); + width = static_cast(w); + } + lua_pop(L, 1); + } lua_pushvalue(L, 2); - plugin.screens_.push_back({title, lua_ref(L, -1)}); + plugin.screens_.push_back({title, lua_ref(L, -1), dock, width}); lua_pop(L, 1); return 0; } + // input.cursor(): the mouse over the game window while this plugin's screen is open, or nil. + static int input_cursor(lua_State* L) { + auto& plugin = plugin_of(L); + need(L, plugin, "input", "reskate.input.cursor"); + const auto c = plugin.open_screen_.empty() ? std::nullopt : game(plugin).cursor(); + if (!c) { + lua_pushnil(L); + return 1; + } + lua_createtable(L, 0, 7); + lua_pushvector(L, c->x, c->y, 0); + lua_setfield(L, -2, "position"); + lua_pushboolean(L, c->left); + lua_setfield(L, -2, "left"); + lua_pushboolean(L, c->right); + lua_setfield(L, -2, "right"); + lua_pushboolean(L, c->middle); + lua_setfield(L, -2, "middle"); + lua_pushnumber(L, c->wheel); + lua_setfield(L, -2, "wheel"); + lua_pushboolean(L, c->over_screen); + lua_setfield(L, -2, "over_screen"); + return 1; + } static int open(lua_State* L) { auto& plugin = plugin_of(L); need(L, plugin, "ui.screen", "reskate.ui.open"); @@ -1864,7 +2163,10 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { 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}, - {"record", animation_record}, {"recorded", animation_recorded}, {"forget", animation_forget}, {"trick", animation_trick}}); + {"record", animation_record}, {"recorded", animation_recorded}, {"forget", animation_forget}, {"trick", animation_trick}, + {"seek", animation_seek}, {"pause", animation_pause}, {"time", animation_time}, {"info", animation_info}, + {"sample", animation_sample}, {"joints", animation_joints}, {"trim", animation_trim}, {"edit", animation_edit}, + {"save", animation_save}}); add_functions(L, "outfit", {{"set", outfit_set}, {"clear", outfit_clear}, {"find", outfit_find}, {"slots", outfit_slots}, {"status", outfit_status}}); add_functions(L, "audio", {{"play", audio_play}, {"set", audio_set}, {"stop", audio_stop}, {"playing", audio_playing}}); add_functions(L, "server", {{"kick", server_kick}, {"say", server_say}, {"tell", server_tell}, {"move", server_move}, @@ -1876,7 +2178,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) { add_functions(L, "progression", {{"get", progress}}); add_functions(L, "settings", {{"game", setting}, {"names", setting_names}, {"set", setting_set}}); add_functions(L, "input", {{"bind", bind}, {"flight", input_flight}, {"gamepad", input_gamepad}, {"joystick", input_joystick}, {"capture", input_capture}, - {"hide", input_hide}}); + {"hide", input_hide}, {"cursor", input_cursor}}); add_functions(L, "ui", {{"screen", screen}, {"open", open}, {"close", close}}); add_functions(L, "screen", {{"look", screen_look}, {"slap", screen_slap}}); } diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index f2871a15..88babdca 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -339,6 +339,14 @@ public: double x{}, y{}, wheel{}; }; virtual std::optional mouse_motion(std::string_view /*plugin*/) { return std::nullopt; } + // The mouse over the game window while a plugin's screen is open (reskate.input.cursor): where + // (pixels from the top left), its buttons, the wheel's notches since the last read, and whether + // it is over the screen's own panel. + struct Cursor { + float x{}, y{}, wheel{}; + bool left{}, right{}, middle{}, over_screen{}; + }; + virtual std::optional cursor() { return std::nullopt; } // Game buttons kept from the game while asked (overlay::hide_game_buttons/hold_game_buttons), // and all game input paused for half a second at a time. virtual void game_buttons(std::string_view /*plugin*/, std::uint16_t /*hidden*/, std::uint16_t /*held*/, bool /*paused*/) {} @@ -401,6 +409,13 @@ public: virtual std::string files_read(std::string_view /*plugin*/, std::string_view /*path*/, std::string& /*text*/) { return unavailable; } virtual std::string files_write(std::string_view /*plugin*/, std::string_view /*path*/, std::string_view /*text*/) { return unavailable; } virtual std::string files_remove(std::string_view /*plugin*/, std::string_view /*path*/) { return unavailable; } + // The same, for a file up to `most` bytes (an animation clip's, say). + virtual std::string files_read_bytes(std::string_view plugin, std::string_view path, std::string& out, std::size_t /*most*/) { + return files_read(plugin, path, out); + } + virtual std::string files_write_bytes(std::string_view plugin, std::string_view path, std::string_view bytes, std::size_t /*most*/) { + return files_write(plugin, path, bytes); + } // ---- ui.dialog ----------------------------------------------------------------------------------- // One dialog at a time over the game; the same id again updates it (what was typed is kept). virtual std::string dialog_open(std::string_view /*plugin*/, const DialogSpec&) { return unavailable; } @@ -462,8 +477,14 @@ public: // 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; } + // `at`: seconds into the clip it starts from; `paused`: held there (seek and pause move it). + virtual std::string play_clip(std::string_view /*plugin*/, ClipRef, float /*speed*/, float /*blend*/, bool /*loop*/, float /*at*/, bool /*paused*/) { + return unavailable; + } virtual void stop_clip(std::string_view /*plugin*/) {} + virtual void seek_clip(std::string_view /*plugin*/, float /*seconds*/) {} + virtual void pause_clip(std::string_view /*plugin*/, bool /*paused*/) {} + virtual std::optional clip_time(std::string_view /*plugin*/) { return std::nullopt; } virtual bool clip_playing(std::string_view /*plugin*/) { return false; } // An arm of the local skater reaching (Extension/Skater/arm_reach.h): its hand joint to `at` in the // world, turned to `rotation` when given, `weight` of the way; held 0.3 s, set every tick. One diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 71f11e3b..ab5adf75 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -630,20 +630,26 @@ public: return {}; } std::string files_read(std::string_view plugin, std::string_view path, std::string& text) override { + return files_read_bytes(plugin, path, text, most_file_bytes); + } + std::string files_read_bytes(std::string_view plugin, std::string_view path, std::string& text, std::size_t most) override { std::filesystem::path root, file; if (auto why = data_file(plugin, path, root, file); !why.empty()) return why; std::error_code error; if (!std::filesystem::is_regular_file(file, error)) return "not found"; const auto size = std::filesystem::file_size(file, error); if (error) return "the file cannot be read"; - if (size > most_file_bytes) return "the file is larger than 1 MB"; + if (size > most) return std::format("the file is larger than {} MB", most >> 20); std::ifstream in(file, std::ios::binary); text.resize(static_cast(size)); if (!in || !in.read(text.data(), static_cast(size))) return "the file cannot be read"; return {}; } std::string files_write(std::string_view plugin, std::string_view path, std::string_view text) override { - if (text.size() > most_file_bytes) return "a file is at most 1 MB"; + return files_write_bytes(plugin, path, text, most_file_bytes); + } + std::string files_write_bytes(std::string_view plugin, std::string_view path, std::string_view text, std::size_t most) override { + if (text.size() > most) return std::format("a file is at most {} MB", most >> 20); std::filesystem::path root, file; if (auto why = data_file(plugin, path, root, file); !why.empty()) return why; std::error_code error; @@ -820,13 +826,23 @@ public: 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 { + std::string play_clip(std::string_view plugin, ClipRef clip, float speed, float blend, bool loop, float at, bool paused) 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}); + skater_animation::play(skater_animation::Play{std::move(clip), speed, blend, loop, at, paused}); return {}; } + void seek_clip(std::string_view plugin, float seconds) override { + if (player_ == plugin) skater_animation::seek(seconds); + } + void pause_clip(std::string_view plugin, bool paused) override { + if (player_ == plugin) skater_animation::pause(paused); + } + std::optional clip_time(std::string_view plugin) override { + if (player_ != plugin || !skater_animation::playing()) return std::nullopt; + return skater_animation::play_time(); + } void stop_clip(std::string_view plugin) override { if (player_ != plugin) return; skater_animation::play(std::nullopt); @@ -1010,6 +1026,11 @@ public: int mouse_wheel() override { return overlay::take_mouse_wheel(); } // One read of the overlay's mouse a call, shared out: every plugin that read it in the last two // seconds gets the movement, so two plugins never take it from each other. + std::optional cursor() override { + overlay::PluginCursor c; + if (!overlay::read_plugin_cursor(c)) return std::nullopt; + return Cursor{c.x, c.y, c.wheel, c.left, c.right, c.middle, c.over_screen}; + } std::optional mouse_motion(std::string_view plugin) override { overlay::MouseLook look; const bool live = overlay::read_mouse_look(look); diff --git a/Extension/Plugins/plugin_ui.h b/Extension/Plugins/plugin_ui.h index 708eb2d0..d5dd186e 100644 --- a/Extension/Plugins/plugin_ui.h +++ b/Extension/Plugins/plugin_ui.h @@ -82,6 +82,8 @@ struct Panel { std::string plugin, title; std::vector widgets; bool screen{}; // a full-screen screen (reskate.ui.screen) + int dock{}; // a screen docked to a side, the game showing beside it: 1 left, 2 right; 0 full screen + float width{}; // a docked screen's share of the window's width bool page{}; // the plugin's page (reskate.menu.page); else a panel, a card on that page }; // A plugin with a page in the menu's PLUGINS section: its id, and the page's name there (its page's diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index 8a691602..8ace1271 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -663,6 +663,8 @@ std::optional Plugin::build_screen(const std::vector panels_; std::optional page_; // reskate.menu.page plugin_ui::Panel* building_{}; // the panel a build is filling diff --git a/Extension/Skater/skater_animation.cpp b/Extension/Skater/skater_animation.cpp index a075ccce..007f88e3 100644 --- a/Extension/Skater/skater_animation.cpp +++ b/Extension/Skater/skater_animation.cpp @@ -170,6 +170,8 @@ std::string pop_of(float air) { struct Plan { std::optional play; double started{}; + float from{}; // the clip's seconds at `started` + std::optional held; // paused at these seconds of the clip std::uint64_t serial{}; std::vector replacements; bool allowed{}; @@ -351,12 +353,12 @@ void on_animation(std::uintptr_t component) noexcept { 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 run = snap->held ? *snap->held : snap->from + 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) { + if (!p.loop && !snap->held && 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); @@ -638,9 +640,43 @@ std::uint64_t serial{}; void play(std::optional clip) { plan_draft.play = std::move(clip); plan_draft.started = now_seconds(); + plan_draft.from = plan_draft.play ? plan_draft.play->at : 0; + plan_draft.held = plan_draft.play && plan_draft.play->paused ? std::optional(plan_draft.from) : std::nullopt; plan_draft.serial = ++serial; publish(); } +std::optional play_time() { + if (!plan_draft.play || !plan_draft.play->clip) return std::nullopt; + if (plan_draft.held) return plan_draft.held; + const auto& p = *plan_draft.play; + const float run = plan_draft.from + static_cast(now_seconds() - plan_draft.started) * std::max(p.speed, 0.05f); + const float length = p.clip->duration(); + if (p.loop && length > 0) return std::fmod(run, length); + return std::min(run, length); +} +void seek(float seconds) { + if (!plan_draft.play || !plan_draft.play->clip) return; + const float t = std::clamp(seconds, 0.0f, plan_draft.play->clip->duration()); + if (plan_draft.held) plan_draft.held = t; + else { + // Playing on from there, already blended in. + plan_draft.from = t; + plan_draft.started = now_seconds() - std::max(plan_draft.play->blend, 0.0f); + if (plan_draft.play->speed > 0) plan_draft.from -= std::max(plan_draft.play->blend, 0.0f) * plan_draft.play->speed; + } + publish(); +} +void pause(bool paused) { + if (!plan_draft.play || !plan_draft.play->clip || paused == plan_draft.held.has_value()) return; + if (paused) plan_draft.held = play_time(); + else { + const float t = *plan_draft.held; + plan_draft.held.reset(); + seek(t); + return; + } + publish(); +} bool playing() noexcept { return plan_draft.play.has_value() && finished_serial.load(std::memory_order_acquire) != plan_draft.serial; } diff --git a/Extension/Skater/skater_animation.h b/Extension/Skater/skater_animation.h index 91d96770..5ef75733 100644 --- a/Extension/Skater/skater_animation.h +++ b/Extension/Skater/skater_animation.h @@ -24,6 +24,8 @@ struct Play { std::shared_ptr clip; float speed{1}, blend{0.15f}; bool loop{}; + float at{}; // seconds into the clip it starts from + bool paused{}; // held at `at` until resumed (seek, pause) }; // 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", @@ -37,6 +39,11 @@ struct Replacement { // Client thread throughout. void play(std::optional clip); bool playing() noexcept; // a clip of play() has not finished +// The clip of play(), moved to `seconds` (clamped to it), held there or playing on from it. +void seek(float seconds); +void pause(bool paused); +// Where in its clip play() is now, in seconds; empty without one. +std::optional play_time(); 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); diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 4464452f..3516e940 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -46,6 +46,14 @@ struct MouseLook { long x = 0, y = 0, wheel = 0; }; bool read_mouse_look(MouseLook& look) noexcept; +// The mouse while a plugin's screen is open (plugin_overlay.cpp): where it is in the window's pixels, +// its buttons, the wheel's notches since the last read, and whether it is over the screen's panel. +// False while no plugin screen is open. +struct PluginCursor { + float x{}, y{}, wheel{}; + bool left{}, right{}, middle{}, over_screen{}; +}; +bool read_plugin_cursor(PluginCursor& cursor) noexcept; // reskate.ui.dialog (plugin_dialog.cpp): one dialog at a time over the game, input captured while it // is open. The same plugin and id again updates it and keeps what was typed. Empty when shown, else // why not. Answers reach the plugin as throwdown kit events (action "dialog", throwdown_kit.h). diff --git a/Extension/UI/Overlay/plugin_overlay.cpp b/Extension/UI/Overlay/plugin_overlay.cpp index 919ecf89..bb03f9bf 100644 --- a/Extension/UI/Overlay/plugin_overlay.cpp +++ b/Extension/UI/Overlay/plugin_overlay.cpp @@ -4,9 +4,11 @@ #include "Extension/UI/Overlay/game_images.h" #include "Extension/UI/Overlay/overlay_internal.h" #include "Extension/UI/Overlay/xray_overlay.h" +#include "Extension/UI/Overlay/overlay.h" #include #include #include +#include namespace dingosdk::overlay { namespace { @@ -38,6 +40,42 @@ void circle(ImDrawList* draw, ImVec2 centre, float radius, const plugin_ui::Styl } } +// The mouse while a plugin's screen is open, for reskate.input.cursor (render thread writes it). +struct CursorState { + std::mutex mutex; + PluginCursor now; + bool open{}; +}; +static CursorState& cursor_state() { + static auto* value = new CursorState; + return *value; +} +bool read_plugin_cursor(PluginCursor& out) noexcept { + auto& c = cursor_state(); + std::lock_guard lock(c.mutex); + if (!c.open) return false; + out = c.now; + c.now.wheel = 0; + return true; +} +static void note_cursor(bool open, bool over_screen) { + auto& c = cursor_state(); + std::lock_guard lock(c.mutex); + c.open = open; + if (!open) { + c.now = {}; + return; + } + const auto& io = ImGui::GetIO(); + c.now.x = io.MousePos.x; + c.now.y = io.MousePos.y; + c.now.left = io.MouseDown[0]; + c.now.right = io.MouseDown[1]; + c.now.middle = io.MouseDown[2]; + c.now.wheel += io.MouseWheel; + c.now.over_screen = over_screen; +} + // The screen Esc closed, until its plugin's next build no longer has it open. static std::string& closed_screen() { static auto* value = new std::string; @@ -71,16 +109,27 @@ bool plugins_pending() { void draw_plugin_screen() { const auto* screen = open_screen(); - if (!screen) return; + if (!screen) { + note_cursor(false, false); + return; + } const auto display = ImGui::GetIO().DisplaySize; const float k = std::max(1.0f, display.y / 1080.0f); auto& s = detail::state(); - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(display); - ImGui::SetNextWindowBgAlpha(0.94f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(64 * k, 48 * k)); + // Docked: a panel down one side, the game showing beside it. + const bool docked = screen->dock != 0; + const float panel = docked ? std::round(display.x * std::clamp(screen->width, 0.2f, 0.6f)) : display.x; + ImGui::SetNextWindowPos(ImVec2(screen->dock == 2 ? display.x - panel : 0.0f, 0)); + ImGui::SetNextWindowSize(ImVec2(panel, display.y)); + ImGui::SetNextWindowBgAlpha(docked ? 0.88f : 0.94f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, docked ? ImVec2(24 * k, 24 * k) : ImVec2(64 * k, 48 * k)); const auto flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings; + const auto& io = ImGui::GetIO(); + const float left = screen->dock == 2 ? display.x - panel : 0.0f; + note_cursor(true, io.MousePos.x >= left && io.MousePos.x < left + panel); if (ImGui::Begin("##plugin-screen", nullptr, flags)) { + const float wide = docked ? ImGui::GetContentRegionAvail().x * 0.6f : 480 * k; + const float button = docked ? ImGui::GetContentRegionAvail().x : 320 * k; ImGui::SetWindowFontScale(1.25f * k); if (s.menu.heading) ImGui::PushFont(s.menu.heading); ImGui::TextUnformatted(screen->title.c_str()); @@ -126,9 +175,9 @@ void draw_plugin_screen() { } break; case Kind::bar: - ImGui::ProgressBar((w.value - w.minimum) / (w.maximum - w.minimum), ImVec2(480 * k, 0), w.text.c_str()); + ImGui::ProgressBar((w.value - w.minimum) / (w.maximum - w.minimum), ImVec2(wide, 0), w.text.c_str()); break; - case Kind::button: acted = ImGui::Button(w.text.c_str(), ImVec2(320 * k, 0)); break; + case Kind::button: acted = ImGui::Button(w.text.c_str(), ImVec2(button, 0)); break; case Kind::toggle: { bool on = w.on; if (ImGui::Checkbox(w.text.c_str(), &on)) { @@ -139,7 +188,7 @@ void draw_plugin_screen() { } case Kind::slider: { float value = w.value; - ImGui::SetNextItemWidth(480 * k); + ImGui::SetNextItemWidth(wide); if (ImGui::SliderFloat(w.text.c_str(), &value, w.minimum, w.maximum, "%.2f", ImGuiSliderFlags_AlwaysClamp)) { action.value = value; acted = true; @@ -148,7 +197,7 @@ void draw_plugin_screen() { } case Kind::dropdown: { int selected = std::clamp(w.chosen - 1, 0, static_cast(w.choices.size()) - 1); - ImGui::SetNextItemWidth(480 * k); + ImGui::SetNextItemWidth(wide); if (ImGui::BeginCombo(w.text.c_str(), w.choices.empty() ? "" : w.choices[static_cast(selected)].c_str())) { for (int i = 0; i < static_cast(w.choices.size()); ++i) if (ImGui::Selectable(w.choices[static_cast(i)].c_str(), i == selected)) { diff --git a/contrib/plugins/animator-studio/README.md b/contrib/plugins/animator-studio/README.md new file mode 100644 index 00000000..13fe57d9 --- /dev/null +++ b/contrib/plugins/animator-studio/README.md @@ -0,0 +1,63 @@ +# Animator Studio (plugin) + +The Animator Studio of 22sh, Fourth Studio and Norms (pull request 297) as a sandboxed Luau plugin. +Record a trick, change the body and the board on a timeline, save it, and from then on doing that +trick in game plays your version. Every other trick stays the game's own, and no game data is +changed. + +## How to use it + +1. Press **F8** (or **L3 + R3** on a pad), or open it from its page in the menu's PLUGINS section, or + type `studio`. The studio is a panel down the right of the screen; the skater is held still, and + the camera turns round him: hold the right mouse button and drag to turn, roll the wheel to come + closer. +2. **TRICKS > Record a trick**: the studio steps out. Skate and land the trick within 10 seconds, and + you are back in the studio with it, cut from a little before its takeoff to a little after its + landing. +3. **JOINTS**: pick a joint in the list, or click its dot on the skater. Turn it with the X, Y and Z + sliders (the hips and the board move as well). With **Key as you change it** on, each change keys + the joint at the timeline's time; **Key it here** keys it as it is. Move along the timeline with + its slider or a frame at a time; **Play** plays it. A key's ease (smooth, linear or step) says how + it goes into the next one. **Undo** and **Redo** step through the changes. +4. **PLAYBACK**: which pop it is for (any, low, mid or high), its speed, and **Play it in game**. + **Copy to low, mid and high pop** saves a copy for each pop, each playing in game. +5. **LIBRARY > Save**. With **Play it in game** on, doing the trick plays it, and keeps doing so every + time the game starts. **Open** brings a saved one back to change it. + +Console: `studio open|close|record|save |ingame on|off|list|status`. + +## How it works + +It is built on general plugin APIs, so another plugin can do the same: + +- `reskate.animation.record` records the skater as the game draws it, with the trick's takeoff and + landing marked; `reskate.animation.trim` cuts it down to the trick. +- The keys are the plugin's own. `reskate.animation.edit` bakes them onto the recording: each key + turns a joint in its own frame and moves it in its parent's, eased into the next key. +- The studio shows the baked clip on the skater, held at the timeline's time + (`reskate.animation.play` with `paused`, then `seek`, `pause` and `time`). +- The panel is a docked `reskate.ui.screen`; `reskate.input.cursor` is the mouse beside it, and the + joints' dots come from `reskate.skater.joint`. +- A saved animation is its recording as a clip file and its keys as JSON, in the plugin's data folder + (`Mods/.reskate_plugin_data/animator-studio/anims/.fas` and `.json`), saved with + `reskate.animation.save` and read back as `"data:anims/.fas"`. +- Playing in game is `reskate.animation.replace` on the game's trick, for its pop. + +## Install + +Copy this folder into the game's `Mods` folder. + +## Not here from the pull request + +- The studio room: the PR walks the game menu to the Skaters page's lit character room and draws the + skater there. The plugin opens the studio where the skater is. +- The loading screen over that walk, and the gizmo for dragging a joint round on the skater: here + joints turn with sliders. +- The game's own tricks as a library to start from (`make_trick_library.py`). + +## Limits + +- A trick played in game is also what other players see, as for any plugin's clip. +- Clips do not play while a throwdown or a game of S.K.A.T.E. runs; the studio shows nothing then. +- A plugin's clips hold at most 48 MB of poses: each saved animation that plays in game holds its + clip while the plugin runs. diff --git a/contrib/plugins/animator-studio/plugin/main.lua b/contrib/plugins/animator-studio/plugin/main.lua new file mode 100644 index 00000000..d421dfb2 --- /dev/null +++ b/contrib/plugins/animator-studio/plugin/main.lua @@ -0,0 +1,561 @@ +-- Animator Studio, by 22sh, Fourth Studio and Norms (pull request 297), as a plugin. Record a trick, +-- change the body and the board on a timeline, save it, and from then on doing that trick in game +-- plays your version (reskate.animation.replace); every other trick stays the game's own. +-- - A recording is the skater as the game draws it (reskate.animation.record), cut down to the +-- trick: from a little before its takeoff to a little after its landing (trim). +-- - Keys turn a joint in its own frame and move it in its parent's (the hips, the board), on top of +-- the recording; the studio bakes them into a clip (reskate.animation.edit) and shows it on the +-- skater, held at the timeline's time (play paused, seek). +-- - The studio is a panel down the right of the screen (reskate.ui.screen docked) with an orbit +-- camera round the skater, held still; the joints are dots on the skater, picked with the mouse. +-- - Saved animations are a clip file and the keys beside it in the plugin's data folder +-- (anims/.fas and .json); those set to play in game are put back at every start. +-- The PR's studio room (the game menu's character room) and its loading screen are not here: the +-- studio opens where the skater is. +local title = "Animator Studio" +local pops = {"any", "low", "mid", "high"} +local frame = 1 / 30 +local joints = reskate.animation.joints() -- {joint, label, moves}, the board first + +-- ---- the animation being edited -------------------------------------------------------------------- +local anim = { + name = "", trick = "", pop = 1, speed = 1, in_game = false, + source = nil, -- the clip the keys go on + tracks = {}, -- by tostring(joint): {joint = 7, keys = {{t, a = {x, y, z} degrees, o = {x, y, z} metres, e}}} +} +local info = nil -- reskate.animation.info(anim.source) +local studio = {open = false, tab = 1, joint = 2, time = 0, playing = false, auto_key = true, status = ""} +local undo, redo = {}, {} +local last_change = 0 +local preview_due, last_preview = false, -1e9 +local orbit = {yaw = 0.6, pitch = 0.18, distance = 3.2, anchor = nil} +local cursor_was = nil +local screen_points = {} -- joint index into `joints` -> screen point, from the last draw +local recording = nil -- {trick} while a recording is going on + +local function say(text) + studio.status = text + reskate.log(text) +end +local function safe(name) + local s = string.gsub(name, "[^%w%-_ ]", "") + s = string.gsub(s, "^%s+", "") + s = string.gsub(s, "%s+$", "") + return string.sub(s, 1, 40) +end +local function pop_name() return if anim.pop == 1 then nil else pops[anim.pop] end + +-- ---- keys -------------------------------------------------------------------------------------------- +local function joint_id() return joints[studio.joint].joint end +local function track_of(joint, make) + local key = tostring(joint) + if not anim.tracks[key] and make then anim.tracks[key] = {joint = joint, keys = {}} end + return anim.tracks[key] +end +local function lerp3(a, b, u) return {a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u, a[3] + (b[3] - a[3]) * u} end +-- The joint's edit at `t` as the sliders show it (angles blended straight; the baked clip blends the +-- turns themselves). +local function value_at(joint, t) + local track = track_of(joint, false) + if not track or #track.keys == 0 then return {0, 0, 0}, {0, 0, 0} end + local keys = track.keys + if t <= keys[1].t then return keys[1].a, keys[1].o end + if t >= keys[#keys].t then return keys[#keys].a, keys[#keys].o end + for i = 1, #keys - 1 do + local a, b = keys[i], keys[i + 1] + if t >= a.t and t <= b.t then + if a.e == "step" then return a.a, a.o end + local u = if b.t > a.t then (t - a.t) / (b.t - a.t) else 0 + if a.e ~= "linear" then u = u * u * (3 - 2 * u) end + return lerp3(a.a, b.a, u), lerp3(a.o, b.o, u) + end + end + return {0, 0, 0}, {0, 0, 0} +end +local function key_at(joint, t) + local track = track_of(joint, false) + if not track then return nil end + for i, k in track.keys do + if math.abs(k.t - t) < frame * 0.5 then return k, i end + end + return nil +end +-- Before a change: what it was, for undo (one entry a gesture: changes within 0.6 s are one). +local function changing() + local now = reskate.time.now() + if now - last_change > 600 then + table.insert(undo, reskate.json.encode(anim.tracks)) + if #undo > 50 then table.remove(undo, 1) end + redo = {} + end + last_change = now + preview_due = true +end +local function set_key(joint, t, angles, offset) + changing() + local existing = key_at(joint, t) + if existing then + existing.a, existing.o = angles, offset + return + end + local track = track_of(joint, true) + local key = {t = t, a = angles, o = offset, e = "smooth"} + local at = #track.keys + 1 + for i, k in track.keys do + if k.t > t then at = i break end + end + table.insert(track.keys, at, key) +end +local function step_history(from, to) + if #from == 0 then return end + table.insert(to, reskate.json.encode(anim.tracks)) + anim.tracks = reskate.json.decode(table.remove(from)) + preview_due = true +end + +-- ---- the clip on the skater -------------------------------------------------------------------------- +local function tracks_for_edit() + local list = {} + for _, track in anim.tracks do + if #track.keys > 0 then + local keys = {} + for _, k in track.keys do + table.insert(keys, {time = k.t, angles = vector.create(k.a[1], k.a[2], k.a[3]), + offset = vector.create(k.o[1], k.o[2], k.o[3]), ease = k.e}) + end + table.insert(list, {joint = track.joint, keys = keys}) + end + end + return list +end +local function bake(name) + if not anim.source then return nil end + local ok, clip = pcall(reskate.animation.edit, anim.source, tracks_for_edit(), {name = name}) + if not ok then + say("The keys could not be put on the clip: " .. tostring(clip)) + return nil + end + return clip +end +local function show_preview() + local clip = bake("studio") + if not clip then return end + local ok, why = reskate.animation.play(clip, {paused = not studio.playing, at = studio.time, loop = true, blend = 0, speed = anim.speed}) + if ok then + preview_due = false + last_preview = reskate.time.now() + elseif why ~= "asked more than 4 times in a second" then + say("The studio cannot show it: " .. why) + preview_due = false + end +end +local function use_source(clip, trick) + if anim.source and anim.source ~= clip then reskate.animation.forget(anim.source) end + anim.source = clip + info = reskate.animation.info(clip) + if trick then anim.trick = trick end + studio.time = math.clamp(studio.time, 0, info.duration) + preview_due = true +end + +-- ---- playing in game --------------------------------------------------------------------------------- +-- The saved animation `file` (its profile `p`) on the game's trick, or taken off it. +local function put_in_game(file, p, source) + if not p.trick or p.trick == "" then return false, "it has no trick" end + local pop = if p.pop and p.pop ~= "any" then p.pop else nil + if not p.in_game then return reskate.animation.replace(p.trick, nil, {pop = pop}) end + local saved_tracks = anim.tracks + anim.tracks = p.tracks or {} + local ok, clip = pcall(reskate.animation.edit, source, tracks_for_edit(), {name = "game " .. file}) + anim.tracks = saved_tracks + if not ok then return false, tostring(clip) end + return reskate.animation.replace(p.trick, clip, {pop = pop, speed = p.speed or 1}) +end +local function profile() + return {format = "animator-studio-plugin", version = 1, name = anim.name, trick = anim.trick, pop = pops[anim.pop], + speed = anim.speed, in_game = anim.in_game, tracks = anim.tracks} +end + +-- ---- the library ------------------------------------------------------------------------------------- +local function library() + local names = {} + local list = reskate.files.list("anims") or {} + for _, entry in list do + local base = string.match(entry, "^(.+)%.json$") + if base then table.insert(names, base) end + end + table.sort(names) + return names +end +local function save_as(name) + local file = safe(name) + if file == "" then return say("A name is letters, digits, spaces, - and _.") end + if not anim.source then return say("Record a trick first.") end + anim.name = name + local ok, why = reskate.animation.save(anim.source, "anims/" .. file .. ".fas") + if not ok then return say("Not saved: " .. why) end + ok, why = reskate.files.write("anims/" .. file .. ".json", reskate.json.encode(profile())) + if not ok then return say("Not saved: " .. why) end + local data = "data:anims/" .. file .. ".fas" + reskate.animation.forget(data) + local placed, refused = put_in_game(file, profile(), anim.source) + say("Saved " .. file .. (if anim.in_game and not placed then " (not in game: " .. tostring(refused) .. ")" else "")) +end +local function load_saved(file) + local text = reskate.files.read("anims/" .. file .. ".json") + if not text then return say("No animation is called " .. file) end + local ok, p = pcall(reskate.json.decode, text) + if not ok or type(p) ~= "table" then return say(file .. " is not an animation") end + local data = "data:anims/" .. file .. ".fas" + reskate.animation.forget(data) + local read, why = pcall(reskate.animation.info, data) + if not read then return say(file .. "'s clip cannot be read: " .. tostring(why)) end + anim.name, anim.trick, anim.speed, anim.in_game = p.name or file, p.trick or "", p.speed or 1, p.in_game == true + anim.pop = table.find(pops, p.pop or "any") or 1 + anim.tracks = p.tracks or {} + undo, redo = {}, {} + use_source(data) + say("Loaded " .. file) +end +local function remove_saved(file) + local text = reskate.files.read("anims/" .. file .. ".json") + if text then + local ok, p = pcall(reskate.json.decode, text) + if ok and type(p) == "table" then + p.in_game = false + put_in_game(file, p, nil) + end + end + reskate.files.remove("anims/" .. file .. ".json") + reskate.files.remove("anims/" .. file .. ".fas") + reskate.animation.forget("data:anims/" .. file .. ".fas") + say("Removed " .. file) +end +-- A low, a mid and a high copy of it, each playing in game for its pop. +local function copy_to_pops() + if anim.trick == "" then return say("Set the trick first.") end + local name, pop, in_game = anim.name, anim.pop, anim.in_game + local base = safe(if name ~= "" then name else anim.trick) + for i = 2, 4 do + anim.pop, anim.in_game = i, true + save_as(base .. " " .. pops[i]) + end + anim.name, anim.pop, anim.in_game = name, pop, in_game + say("Copied to low, mid and high pop") +end + +-- ---- opening, closing, recording --------------------------------------------------------------------- +local function open_studio() + local s = reskate.skater.get() + if not s then return say("The studio needs a skater.") end + orbit.anchor = s.position + vector.create(0, 0.9, 0) + orbit.yaw = s.heading + math.pi * 0.8 + reskate.skater.freeze(true) + reskate.hud.hide(true) + reskate.ui.open(title) + studio.open = true + preview_due = true +end +local function close_studio() + if not studio.open then return end + studio.open = false + reskate.ui.close() + reskate.animation.stop() + reskate.camera.release() + reskate.hud.hide(false) + reskate.skater.freeze(false) +end +local function record_trick() + close_studio() + local ok, why = reskate.animation.record(10) + if not ok then return say("Cannot record: " .. why) end + recording = {trick = nil} + reskate.ui.toast("Recording: skate and land a trick in the next 10 seconds", "info") +end +local function recording_done(clip) + local trick = recording.trick + recording = nil + local i = reskate.animation.info(clip) + if not i.takeoff or not i.landing then + reskate.animation.forget(clip) + say("No trick was landed while it recorded: record again.") + return open_studio() + end + local cut = reskate.animation.trim(clip, math.max(0, i.takeoff - 0.6), math.min(i.duration, i.landing + 0.8)) + reskate.animation.forget(clip) + anim.tracks, undo, redo = {}, {}, {} + anim.name = trick or "" + studio.time = 0 + use_source(cut, trick) + say("Recorded " .. (trick or "a trick")) + open_studio() +end + +-- ---- the panel --------------------------------------------------------------------------------------- +local function joint_labels() + local labels = {} + for _, j in joints do table.insert(labels, j.label) end + return labels +end +local labels = joint_labels() + +local function build_timeline(ui) + if not info then return end + ui:info("Time", string.format("%.2f of %.2f s", studio.time, info.duration)) + local t = ui:slider("time", "Timeline", studio.time, 0, info.duration, "%.2f s") + if math.abs(t - studio.time) > 1e-4 then + studio.time = t + reskate.animation.seek(t) + end + if ui:button("play", if studio.playing then "Pause" else "Play", true) then + studio.playing = not studio.playing + reskate.animation.pause(not studio.playing) + end + if ui:button("back", "One frame back") then + studio.time = math.max(0, studio.time - frame) + reskate.animation.seek(studio.time) + end + if ui:button("ahead", "One frame on") then + studio.time = math.min(info.duration, studio.time + frame) + reskate.animation.seek(studio.time) + end +end + +local function build_joints(ui) + if not anim.source then + ui:note("Record a trick first (TRICKS).") + return + end + studio.joint = ui:dropdown("joint", "Joint", labels, studio.joint, "Or click a dot on the skater.") + build_timeline(ui) + local j = joints[studio.joint] + local angles, offset = value_at(j.joint, studio.time) + ui:card(string.upper(j.label), if key_at(j.joint, studio.time) then "A key here" else "No key here") + local a = {ui:slider("ax", "Turn X", angles[1], -180, 180, "%.0f deg"), ui:slider("ay", "Turn Y", angles[2], -180, 180, "%.0f deg"), + ui:slider("az", "Turn Z", angles[3], -180, 180, "%.0f deg")} + local o = offset + if j.moves then + local reach = if j.joint == "board" then 1 else 0.5 + o = {ui:slider("ox", "Move X", offset[1], -reach, reach, "%.2f m"), ui:slider("oy", "Move Y", offset[2], -reach, reach, "%.2f m"), + ui:slider("oz", "Move Z", offset[3], -reach, reach, "%.2f m")} + end + local moved = false + for i = 1, 3 do + if math.abs(a[i] - angles[i]) > 1e-3 or math.abs(o[i] - offset[i]) > 1e-5 then moved = true end + end + if moved and studio.auto_key then set_key(j.joint, studio.time, a, o) end + studio.auto_key = ui:toggle("auto", "Key as you change it", studio.auto_key) + if ui:button("key", "Key it here") then set_key(j.joint, studio.time, a, o) end + local key, index = key_at(j.joint, studio.time) + if key then + local eases = {"smooth", "linear", "step"} + local e = ui:dropdown("ease", "Into the next key", eases, table.find(eases, key.e) or 1) + if eases[e] ~= key.e then + changing() + key.e = eases[e] + end + if ui:button("unkey", "Take this key off") then + changing() + table.remove(track_of(j.joint, false).keys, index) + end + end + local track = track_of(j.joint, false) + if track and #track.keys > 0 then + local times = {} + for _, k in track.keys do table.insert(times, string.format("%.2f", k.t)) end + ui:note("Keys at " .. table.concat(times, ", ") .. " s") + if ui:button("previous", "Previous key") then + for i = #track.keys, 1, -1 do + if track.keys[i].t < studio.time - 1e-3 then studio.time = track.keys[i].t break end + end + reskate.animation.seek(studio.time) + end + if ui:button("next", "Next key") then + for _, k in track.keys do + if k.t > studio.time + 1e-3 then studio.time = k.t break end + end + reskate.animation.seek(studio.time) + end + if ui:button("reset", "Take all its keys off") then + changing() + anim.tracks[tostring(j.joint)] = nil + end + end + if #undo > 0 and ui:button("undo", "Undo") then step_history(undo, redo) end + if #redo > 0 and ui:button("redo", "Redo") then step_history(redo, undo) end +end + +local function build_tricks(ui) + ui:card("TRICKS", if anim.source then (if anim.trick ~= "" then anim.trick else "a trick") else "Nothing recorded yet") + if ui:button("record", "Record a trick", true) then record_trick() end + ui:note("The studio steps out: skate and land the trick within 10 seconds, and you are back with it.") + if anim.source then + ui:info("Trick", if anim.trick ~= "" then anim.trick else "unknown") + ui:info("Length", string.format("%.2f s", info.duration)) + if info.takeoff then ui:info("Air", string.format("%.2f to %.2f s", info.takeoff, info.landing or info.duration)) end + end +end + +local function build_playback(ui) + if not anim.source then + ui:note("Record a trick first (TRICKS).") + return + end + ui:card("PLAYBACK", "How it plays in game") + anim.pop = ui:dropdown("pop", "Pop", {"Any pop", "Low pop", "Mid pop", "High pop"}, anim.pop) + local speed = ui:slider("speed", "Speed", anim.speed, 0.1, 4, "x%.2f") + if math.abs(speed - anim.speed) > 1e-4 then + anim.speed = speed + preview_due = true + end + anim.in_game = ui:toggle("in_game", "Play it in game", anim.in_game, "When saved, doing the trick plays this.") + if ui:button("pops", "Copy to low, mid and high pop") then copy_to_pops() end +end + +local function build_library(ui) + ui:card("LIBRARY", if anim.name ~= "" then anim.name else "Not saved") + if ui:button("save", "Save", true) then + if anim.name ~= "" then save_as(anim.name) + else + reskate.ui.dialog("save", {title = "SAVE ANIMATION", field = {text = anim.trick, max = 40}, buttons = {{id = "save", label = "Save"}, "Cancel"}}) + end + end + if anim.source and ui:button("save_as", "Save as...") then + reskate.ui.dialog("save", {title = "SAVE ANIMATION", field = {text = anim.name, max = 40}, buttons = {{id = "save", label = "Save"}, "Cancel"}}) + end + for i, name in library() do + if i > 24 then break end + if ui:button("load" .. i, "Open " .. name) then load_saved(name) end + end + if anim.name ~= "" and ui:button("remove", "Remove " .. anim.name) then remove_saved(safe(anim.name)) end +end + +reskate.ui.screen(title, function(ui) + studio.tab = ui:tabs("tabs", {"TRICKS", "JOINTS", "PLAYBACK", "LIBRARY"}, studio.tab) + if studio.tab == 1 then build_tricks(ui) + elseif studio.tab == 2 then build_joints(ui) + elseif studio.tab == 3 then build_playback(ui) + else build_library(ui) end + ui:separator() + if studio.status ~= "" then ui:note(studio.status) end + if ui:button("close", "Close the studio") then close_studio() end +end, {dock = "right", width = 0.3}) + +reskate.on("screen_closed", function(screen) + if screen ~= title or not studio.open then return end + studio.open = true -- Esc shut the screen: the rest goes too + close_studio() +end) +reskate.on("throwdown_ui", function(e) + if e.action ~= "dialog" or e.args[1] ~= "save" then return end + reskate.ui.close_dialog("save") + if e.args[2] == "save" then save_as(e.args[3]) end +end) + +-- ---- every tick: the camera, the mouse, the preview, a recording ------------------------------------- +reskate.on("tick", function() + if recording then + local trick = reskate.animation.trick() + if trick and not recording.trick then recording.trick = trick end + local clip = reskate.animation.recorded() + if clip then recording_done(clip) end + return + end + if not studio.open then return end + -- The mouse beside the panel: right drag turns round the skater, the wheel comes closer, a left + -- click picks the nearest dot. + local c = reskate.input.cursor() + if c and not c.over_screen then + if c.right and cursor_was and cursor_was.right then + orbit.yaw = orbit.yaw - (c.position.x - cursor_was.position.x) * 0.008 + orbit.pitch = math.clamp(orbit.pitch + (c.position.y - cursor_was.position.y) * 0.006, -1.2, 1.3) + end + if c.wheel ~= 0 then orbit.distance = math.clamp(orbit.distance - c.wheel * 0.25, 1, 8) end + if c.left and not (cursor_was and cursor_was.left) then + local best, best_distance = nil, 28 + for index, point in screen_points do + local d = math.sqrt((point.x - c.position.x) ^ 2 + (point.y - c.position.y) ^ 2) + if d < best_distance then best, best_distance = index, d end + end + if best then + studio.joint = best + studio.tab = 2 + end + end + end + cursor_was = c + if orbit.anchor then + local forward = vector.create(math.sin(orbit.yaw) * math.cos(orbit.pitch), -math.sin(orbit.pitch), math.cos(orbit.yaw) * math.cos(orbit.pitch)) + reskate.camera.set(orbit.anchor - forward * orbit.distance, forward, vector.create(0, 1, 0), 55) + end + if studio.playing then studio.time = reskate.animation.time() or studio.time end + if preview_due and reskate.time.now() - last_preview > 300 then show_preview() end +end) + +-- The joints as dots on the skater, the one picked larger. +reskate.on("draw", function(d) + if not studio.open then return end + screen_points = {} + for index, j in joints do + if j.joint ~= "board" then + local at = reskate.skater.joint(j.joint) + if at then + local on = d:world_to_screen(at.position) + if on then screen_points[index] = on end + local picked = index == studio.joint + d:world_circle(at.position, if picked then 0.04 else 0.025, {colour = if picked then "#ffb000" else "#40c0ff", fill = true}) + end + end + end +end) + +-- ---- at start: what plays in game -------------------------------------------------------------------- +local function put_saved_in_game() + local count = 0 + for _, file in library() do + local text = reskate.files.read("anims/" .. file .. ".json") + local ok, p = pcall(reskate.json.decode, text or "") + if ok and type(p) == "table" and p.in_game then + local data = "data:anims/" .. file .. ".fas" + local placed = pcall(put_in_game, file, p, data) + if placed then count += 1 end + end + end + return count +end +local in_game = put_saved_in_game() +if in_game > 0 then reskate.log(string.format("%d saved animation(s) play in game", in_game)) end + +reskate.input.bind("studio", "F8") +reskate.input.bind("studio pad", "Pad.L3+Pad.R3") +reskate.on("action", function(name) + if name == "studio" or name == "studio pad" then + if studio.open then close_studio() else open_studio() end + end +end) +reskate.console.command("studio", "Animator Studio: open, close, record, save , ingame on|off, list, status", function(args) + local word = args[1] or "open" + if word == "open" then open_studio() + elseif word == "save" then + local name = table.concat(args, " ", 2) + save_as(if name ~= "" then name else anim.name) + reskate.console.print("studio: " .. studio.status) + elseif word == "ingame" then + anim.in_game = args[2] == "on" + reskate.console.print("studio: " .. (if anim.in_game then "plays in game once saved" else "stays in the studio")) + elseif word == "close" then close_studio() + elseif word == "record" then record_trick() + elseif word == "list" then reskate.console.print("studio: " .. table.concat(library(), ", ")) + elseif word == "status" then + reskate.console.print(string.format("studio: %s; %s; %d saved", if studio.open then "open" else "closed", + if anim.source then (anim.trick ~= "" and anim.trick or "a trick") .. string.format(", %.2f s", info.duration) else "nothing recorded", + #library())) + else reskate.console.print("studio: say open, close, record, save , ingame on|off, list or status") end +end) +reskate.menu.page(title, function(ui) + ui:card("ANIMATOR STUDIO", "Change the game's trick animations") + ui:note("F8 (or L3 + R3) opens the studio. Record a trick, key the joints on the timeline, save it, and doing that trick plays your version.") + if ui:button("open", "Open the studio", true) then open_studio() end + ui:info("Saved", tostring(#library())) +end) +reskate.on("unload", function() close_studio() end) diff --git a/contrib/plugins/animator-studio/reskate-plugin.json b/contrib/plugins/animator-studio/reskate-plugin.json new file mode 100644 index 00000000..950dd99b --- /dev/null +++ b/contrib/plugins/animator-studio/reskate-plugin.json @@ -0,0 +1,7 @@ +{ + "api": 1, + "entry": "plugin/main.lua", + "side": "client", + "permissions": ["skater.animation", "skater.read", "skater.control", "camera", "hud", "input", "ui.screen", "throwdowns", + "files", "overlay", "console", "menu"] +}