Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
+266
-49
@@ -1,5 +1,6 @@
|
||||
#include "metin2_anim.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "gr2_bridge.h"
|
||||
#include "metin2_model.h"
|
||||
|
||||
@@ -13,14 +14,9 @@
|
||||
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
||||
#include <godot_cpp/classes/os.hpp>
|
||||
#include <godot_cpp/classes/skin.hpp>
|
||||
|
||||
static double _tdiff(const godot::Transform3D &a, const godot::Transform3D &b) {
|
||||
double d = 0.0;
|
||||
for (int r = 0; r < 3; ++r)
|
||||
for (int c = 0; c < 3; ++c)
|
||||
d = godot::Math::max(d, (double)godot::Math::abs(a.basis[r][c] - b.basis[r][c]));
|
||||
return godot::Math::max(d, (double)(a.origin - b.origin).length());
|
||||
}
|
||||
#include <godot_cpp/classes/file_access.hpp>
|
||||
#include <godot_cpp/variant/array.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
@@ -36,25 +32,65 @@ void Metin2AnimPlayer::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
|
||||
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
|
||||
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
|
||||
ClassDB::bind_method(D_METHOD("set_loop", "v"), &Metin2AnimPlayer::set_loop);
|
||||
ClassDB::bind_method(D_METHOD("get_loop"), &Metin2AnimPlayer::get_loop);
|
||||
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
|
||||
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
|
||||
ClassDB::bind_method(D_METHOD("set_blend_time", "s"), &Metin2AnimPlayer::set_blend_time);
|
||||
ClassDB::bind_method(D_METHOD("get_blend_time"), &Metin2AnimPlayer::get_blend_time);
|
||||
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
|
||||
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
|
||||
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
|
||||
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
|
||||
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
|
||||
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
|
||||
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
|
||||
|
||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
|
||||
// Fired when playback time crosses a .msa MotionEventData entry.
|
||||
ADD_SIGNAL(MethodInfo("motion_event",
|
||||
PropertyInfo(Variant::INT, "type"),
|
||||
PropertyInfo(Variant::STRING, "effect"),
|
||||
PropertyInfo(Variant::STRING, "sound"),
|
||||
PropertyInfo(Variant::VECTOR3, "pos")));
|
||||
ADD_SIGNAL(MethodInfo("playback_finished"));
|
||||
|
||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
|
||||
"set_anim_path", "get_anim_path");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
|
||||
"set_model_path", "get_model_path");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "loop"), "set_loop", "get_loop");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
|
||||
"set_time_scale", "get_time_scale");
|
||||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "blend_time", PROPERTY_HINT_RANGE, "0,1,0.01"),
|
||||
"set_blend_time", "get_blend_time");
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::set_anim_path(const String &p) {
|
||||
if (p == anim_path) {
|
||||
return;
|
||||
}
|
||||
// Start a crossfade from the clip that is currently playing.
|
||||
if (is_inside_tree() && anim_file && blend_time > 0.0) {
|
||||
prev_anim_file = std::move(anim_file); // anim_file now empty; reload() refills it
|
||||
prev_anim_duration = duration;
|
||||
prev_anim_loop = loop;
|
||||
double pt = time;
|
||||
if (prev_anim_duration > 0.0) {
|
||||
if (prev_anim_loop) {
|
||||
pt = std::fmod(pt, prev_anim_duration);
|
||||
if (pt < 0.0) {
|
||||
pt += prev_anim_duration;
|
||||
}
|
||||
} else {
|
||||
pt = std::fmax(0.0, std::fmin(pt, prev_anim_duration));
|
||||
}
|
||||
}
|
||||
prev_anim_time = pt;
|
||||
blend_elapsed = 0.0;
|
||||
}
|
||||
anim_path = p;
|
||||
if (is_inside_tree()) {
|
||||
reload();
|
||||
@@ -64,7 +100,8 @@ void Metin2AnimPlayer::set_model_path(const NodePath &p) {
|
||||
model_path = p;
|
||||
}
|
||||
void Metin2AnimPlayer::set_time(double t) {
|
||||
time = t;
|
||||
time = (!loop && duration > 0.0) ? std::fmax(0.0, std::fmin(t, duration)) : t;
|
||||
prev_time = time; // scrubbing must not replay every event since the old cursor
|
||||
if (is_inside_tree()) {
|
||||
apply_pose(time);
|
||||
}
|
||||
@@ -78,6 +115,78 @@ Metin2Model *Metin2AnimPlayer::resolve_model() const {
|
||||
return Object::cast_to<Metin2Model>(n);
|
||||
}
|
||||
|
||||
// "d:\Ymir Work\pc\warrior\action\dance.gr2" -> "<root>/PC/ymir work/pc/warrior/action/dance.gr2"
|
||||
// where <root> is found by walking up from the .msa's own dir past "ymir work".
|
||||
// Falls back to <msa_dir>/<basename> (the anim gr2 is usually right next to it).
|
||||
static String strip_dpath(const String &raw) {
|
||||
String p = raw.replace("\\", "/");
|
||||
int k = p.to_lower().find("ymir work");
|
||||
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
|
||||
}
|
||||
|
||||
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
|
||||
String low = spec.to_lower();
|
||||
if (!low.ends_with(".msa")) {
|
||||
return spec; // already a .gr2 path
|
||||
}
|
||||
fmt::Msa m;
|
||||
std::string e;
|
||||
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
|
||||
return spec;
|
||||
}
|
||||
const String motion = String(m.motion_gr2.c_str());
|
||||
const String base = motion.replace("\\", "/").get_file();
|
||||
const String msa_dir = spec.get_base_dir();
|
||||
|
||||
// 1. sibling of the .msa
|
||||
String cand = msa_dir.path_join(base);
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
// 2. <root>/PC/<ymir-work tail>, root = ancestor of the .msa above "ymir work"
|
||||
String tail = strip_dpath(motion); // "ymir work/pc/.../x.gr2"
|
||||
int ky = msa_dir.to_lower().find("ymir work");
|
||||
if (ky > 0) {
|
||||
String root = spec.substr(0, ky); // ".../assets/PC/"
|
||||
cand = root.path_join(tail.substr(String("ymir work/").length()));
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
cand = root.get_base_dir().path_join(tail); // ".../assets/" + "ymir work/..."
|
||||
if (FileAccess::file_exists(cand)) {
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
UtilityFunctions::push_warning(String("[Metin2AnimPlayer] .msa motion gr2 not found: ") + motion +
|
||||
" (tried " + msa_dir.path_join(base) + ")");
|
||||
return cand;
|
||||
}
|
||||
|
||||
Array Metin2AnimPlayer::get_events() const {
|
||||
Array a;
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
Dictionary d;
|
||||
d["type"] = ev.type;
|
||||
d["start_time"] = ev.start_time;
|
||||
d["effect"] = String(ev.effect_file.c_str());
|
||||
d["sound"] = String(ev.sound_file.c_str());
|
||||
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
|
||||
a.push_back(d);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_loop_data() const {
|
||||
Dictionary d;
|
||||
d["present"] = msa_metadata.has_loop_data;
|
||||
d["count"] = msa_metadata.motion_loop_count;
|
||||
d["cancel_enable"] = msa_metadata.loop_cancel_enable;
|
||||
d["start_time"] = msa_metadata.loop_start_time;
|
||||
d["end_time"] = msa_metadata.loop_end_time;
|
||||
return d;
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::_ready() {
|
||||
set_process(true);
|
||||
reload();
|
||||
@@ -87,13 +196,38 @@ void Metin2AnimPlayer::reload() {
|
||||
anim_file = std::nullopt;
|
||||
duration = 0.0;
|
||||
time = 0.0;
|
||||
prev_time = 0.0;
|
||||
next_event = 0;
|
||||
accumulation = Vector3();
|
||||
events.clear();
|
||||
msa_metadata = fmt::Msa{};
|
||||
last_info = "";
|
||||
if (anim_path.is_empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
|
||||
String gr2_spec = anim_path;
|
||||
if (anim_path.to_lower().ends_with(".msa")) {
|
||||
fmt::Msa m;
|
||||
std::string e;
|
||||
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
|
||||
msa_metadata = m;
|
||||
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
|
||||
events = m.events;
|
||||
gr2_spec = resolve_anim_gr2(anim_path);
|
||||
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
|
||||
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
|
||||
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
|
||||
m.loop_start_time, m.loop_end_time) : String("none")));
|
||||
} else {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa: ") + String(e.c_str()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
gr2::LoadError err;
|
||||
const std::string p(anim_path.utf8().get_data());
|
||||
auto f = gr2::File::load_path(p, &err);
|
||||
auto f = mtgodot::gr2_from_file(gr2_spec, &err);
|
||||
if (!f) {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
|
||||
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
|
||||
@@ -139,6 +273,36 @@ void Metin2AnimPlayer::reload() {
|
||||
apply_pose(0.0);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// mul4x3 lives in gr2_bridge.h now (shared with the weapon-attach grip compose).
|
||||
|
||||
// Blend two bone world transforms: slerp rotation, lerp translation & scale
|
||||
// (PARITY §2.9). Shear on the ~few scaleshear bones is dropped for the ≤blend_time
|
||||
// transient only — the doc sanctions "quat slerp + pos/scale lerp".
|
||||
gr2::Mat4 blend_trs(const gr2::Mat4 &a, const gr2::Mat4 &b, float w) {
|
||||
godot::Basis ba(godot::Vector3(a[0], a[1], a[2]), godot::Vector3(a[4], a[5], a[6]),
|
||||
godot::Vector3(a[8], a[9], a[10]));
|
||||
godot::Basis bb(godot::Vector3(b[0], b[1], b[2]), godot::Vector3(b[4], b[5], b[6]),
|
||||
godot::Vector3(b[8], b[9], b[10]));
|
||||
godot::Quaternion q = ba.get_rotation_quaternion().slerp(bb.get_rotation_quaternion(), w);
|
||||
godot::Vector3 s = ba.get_scale().lerp(bb.get_scale(), w);
|
||||
godot::Vector3 tt =
|
||||
godot::Vector3(a[12], a[13], a[14]).lerp(godot::Vector3(b[12], b[13], b[14]), w);
|
||||
godot::Basis rb(q);
|
||||
rb.rows[0] *= s.x;
|
||||
rb.rows[1] *= s.y;
|
||||
rb.rows[2] *= s.z;
|
||||
gr2::Mat4 o{};
|
||||
o[0] = rb.rows[0].x; o[1] = rb.rows[0].y; o[2] = rb.rows[0].z;
|
||||
o[4] = rb.rows[1].x; o[5] = rb.rows[1].y; o[6] = rb.rows[1].z;
|
||||
o[8] = rb.rows[2].x; o[9] = rb.rows[2].y; o[10] = rb.rows[2].z;
|
||||
o[12] = tt.x; o[13] = tt.y; o[14] = tt.z; o[15] = 1.0f;
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Metin2AnimPlayer::apply_pose(double t) {
|
||||
if (!anim_file) {
|
||||
return;
|
||||
@@ -155,61 +319,114 @@ void Metin2AnimPlayer::apply_pose(double t) {
|
||||
const gr2::Animation &an = anim_file->file_info().animations[0];
|
||||
|
||||
double tt = t;
|
||||
if (duration > 0.0) {
|
||||
if (duration > 0.0 && loop) {
|
||||
tt = std::fmod(t, duration);
|
||||
if (tt < 0.0) {
|
||||
tt += duration;
|
||||
}
|
||||
} else if (duration > 0.0) {
|
||||
tt = std::fmax(0.0, std::fmin(t, duration));
|
||||
}
|
||||
|
||||
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
|
||||
|
||||
if (godot::OS::get_singleton()->get_environment("MTGODOT_CPUSKIN") == "1") {
|
||||
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
|
||||
// skin_buf = invWorld · world_blended (PARITY §2.9).
|
||||
if (prev_anim_file && blend_time > 0.0 && blend_elapsed < blend_time) {
|
||||
const gr2::FileInfo &pfi = prev_anim_file->file_info();
|
||||
if (!pfi.animations.empty()) {
|
||||
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
|
||||
prev_skin_buf);
|
||||
double w = blend_elapsed / blend_time;
|
||||
// ease-in on the incoming clip, matching the client's
|
||||
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
|
||||
// p1=1,m1=1) -> h(w) = 2w^2 - w^3. Flat start, slope-1 finish.
|
||||
w = w * w * (2.0 - w);
|
||||
const size_t n = std::min(world_buf.size(), prev_world_buf.size());
|
||||
blend_world_buf.resize(world_buf.size());
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
blend_world_buf[i] = blend_trs(prev_world_buf[i], world_buf[i], (float)w);
|
||||
for (size_t i = n; i < world_buf.size(); ++i)
|
||||
blend_world_buf[i] = world_buf[i];
|
||||
const auto &bones = sk->bones;
|
||||
for (size_t i = 0; i < world_buf.size() && i < bones.size(); ++i)
|
||||
skin_buf[i] = mul4x3(bones[i].inverse_world, blend_world_buf[i]);
|
||||
world_buf.swap(blend_world_buf); // weapon attach follows the blended hand
|
||||
}
|
||||
}
|
||||
|
||||
// Both skinning paths apply libgr2's per-bone deformer matrices
|
||||
// (skin_buf = Σ w · invWorld · world) with the FULL affine — shear kept.
|
||||
// default : CPU LBS, ArrayMesh rebuilt each frame.
|
||||
// MTGODOT_GPUSKIN=1 : LBS in a custom vertex shader (bone matrices in a
|
||||
// float texture, no Skeleton3D). See m2_material SRC_SKIN.
|
||||
// The old Skeleton3D::set_bone_pose route is gone — Godot's built-in skinning
|
||||
// orthonormalizes the bone matrix and drops the shear (docs/MIDREVIEW.md §4).
|
||||
if (godot::OS::get_singleton()->get_environment("MTGODOT_GPUSKIN") == "1") {
|
||||
model->enable_gpu_skin(true);
|
||||
model->gpu_skin(skin_buf);
|
||||
} else {
|
||||
model->enable_cpu_skin(true);
|
||||
model->cpu_skin(skin_buf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert gr2 global poses -> Godot *local* bone poses and set those.
|
||||
// (Setting global poses directly hits a parent-ordering hazard in
|
||||
// Skeleton3D::set_bone_global_pose that detaches head/upper-body meshes
|
||||
// once bones leave the bind pose.)
|
||||
const int n = (int)std::min<size_t>(world_buf.size(),
|
||||
std::min<size_t>(sk->bones.size(), (size_t)skel->get_bone_count()));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const godot::Transform3D t_i = gr2_to_godot(world_buf[i]);
|
||||
const int parent = sk->bones[i].parent;
|
||||
if (parent < 0 || parent >= n) {
|
||||
skel->set_bone_pose(i, t_i);
|
||||
} else {
|
||||
skel->set_bone_pose(i, gr2_to_godot(world_buf[parent]).affine_inverse() * t_i);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified_ && OS::get_singleton()->get_environment("MTGODOT_VERIFY") == "1") {
|
||||
verified_ = true;
|
||||
skel->force_update_all_bone_transforms();
|
||||
double d_pose = 0.0;
|
||||
int w_pose = -1;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
double dp = _tdiff(skel->get_bone_global_pose(i), gr2_to_godot(world_buf[i]));
|
||||
if (dp > d_pose) {
|
||||
d_pose = dp;
|
||||
w_pose = i;
|
||||
}
|
||||
}
|
||||
UtilityFunctions::print(vformat(
|
||||
"[Metin2AnimPlayer] verify: max |global_pose - conv(world)| = %.6f @ bone %d '%s'",
|
||||
d_pose, w_pose, w_pose >= 0 ? String(sk->bones[w_pose].name.c_str()) : String()));
|
||||
}
|
||||
// Rigid weapon follows equip_right_hand's animated world transform (PARITY §2.1).
|
||||
model->update_weapon_pose(world_buf);
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::_process(double delta) {
|
||||
if (!playing || !anim_file) {
|
||||
return;
|
||||
}
|
||||
time += delta * time_scale;
|
||||
if (prev_anim_file) {
|
||||
blend_elapsed += delta; // real seconds, independent of time_scale
|
||||
if (blend_elapsed >= blend_time) {
|
||||
prev_anim_file = std::nullopt;
|
||||
}
|
||||
}
|
||||
double next = time + delta * time_scale;
|
||||
if (!loop && duration > 0.0 && next >= duration) {
|
||||
next = duration;
|
||||
}
|
||||
time = next;
|
||||
apply_pose(time);
|
||||
dispatch_events(prev_time, time);
|
||||
prev_time = time;
|
||||
if (!loop && duration > 0.0 && time >= duration) {
|
||||
playing = false;
|
||||
emit_signal("playback_finished");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit `motion_event` for every .msa event whose start_time falls in (from, to],
|
||||
// handling loop wrap-around within one clip.
|
||||
void Metin2AnimPlayer::dispatch_events(double from, double to) {
|
||||
if (events.empty() || duration <= 0.0) {
|
||||
return;
|
||||
}
|
||||
if (!loop) {
|
||||
double a = std::fmax(0.0, std::fmin(from, duration));
|
||||
double b = std::fmax(0.0, std::fmin(to, duration));
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
if (a < ev.start_time && ev.start_time <= b) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()),
|
||||
Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
double a = std::fmod(from, duration);
|
||||
if (a < 0) a += duration;
|
||||
double b = a + (to - from);
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
double t = ev.start_time;
|
||||
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
|
||||
if (hit) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()),
|
||||
Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String Metin2AnimPlayer::selfcheck(int samples) {
|
||||
|
||||
Reference in New Issue
Block a user