diff --git a/CMakeLists.txt b/CMakeLists.txt index fa63b2e..87d9fd7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,8 @@ set(COMMON_SOURCES "src/collision/trianglemesh.hpp" "src/collision/trianglemesh.cpp" "src/game/player_input.hpp" + "src/game/skeletoninstance.hpp" + "src/game/skeletoninstance.cpp" "src/game/transform_node.hpp" "src/game/vehicle_sync.hpp" "src/net/defs.hpp" @@ -67,6 +69,8 @@ set(CLIENT_ONLY_SOURCES "src/client/gl.hpp" "src/client/main.cpp" "src/client/utils.hpp" + "src/gameview/characterview.hpp" + "src/gameview/characterview.cpp" "src/gameview/client_session.hpp" "src/gameview/client_session.cpp" "src/gameview/entityview.hpp" @@ -102,6 +106,8 @@ set(CLIENT_ONLY_SOURCES ) set(SERVER_ONLY_SOURCES + "src/game/character.hpp" + "src/game/character.cpp" "src/game/entity.hpp" "src/game/entity.cpp" "src/game/game.hpp" diff --git a/src/assets/animation.cpp b/src/assets/animation.cpp index f44263e..425ea77 100644 --- a/src/assets/animation.cpp +++ b/src/assets/animation.cpp @@ -1,142 +1,124 @@ #include "animation.hpp" #include "skeleton.hpp" -#include "utils/files.hpp" -#include +#include "cmdfile.hpp" + #include -std::shared_ptr assets::Animation::LoadFromFile(const std::string& filename, const Skeleton* skeleton) +std::shared_ptr assets::Animation::LoadFromFile(const std::string& filename, + const Skeleton* skeleton) { - std::istringstream ifs = fs::ReadFileAsStream(filename); - - std::shared_ptr anim = std::make_shared(); + std::shared_ptr anim = std::make_shared(); - int last_frame = 0; - std::vector frame_indices; + int last_frame = 0; + std::vector frame_indices; - auto FillFrameRefs = [&](int end_frame) - { - int channel_start = ((int)anim->channels_.size() - 1) * (int)anim->num_frames_; - int target_size = channel_start + end_frame; - size_t num_frame_refs = frame_indices.size(); + auto FillFrameRefs = [&](int end_frame) { + int channel_start = ((int)anim->channels_.size() - 1) * (int)anim->num_frames_; + int target_size = channel_start + end_frame; + size_t num_frame_refs = frame_indices.size(); - if (num_frame_refs >= target_size) - { - return; // Already filled - } + if (num_frame_refs >= target_size) + { + return; // Already filled + } - if (num_frame_refs % anim->num_frames_ == 0) - { - throw std::runtime_error("Cannot fill frames of channel that has 0 frames: " + filename); - } + if (num_frame_refs % anim->num_frames_ == 0) + { + throw std::runtime_error("Cannot fill frames of channel that has 0 frames: " + filename); + } - size_t last_frame_idx = frame_indices.back(); - frame_indices.resize(target_size, last_frame_idx); - }; + size_t last_frame_idx = frame_indices.back(); + frame_indices.resize(target_size, last_frame_idx); + }; - std::string line; + LoadCMDFile(filename, [&](const std::string& command, std::istringstream& iss) { + if (command == "f") + { + if (anim->num_frames_ == 0) + { + throw std::runtime_error("Frame data specified before number of frames in animation file: " + filename); + } - while (std::getline(ifs, line)) - { - if (line.empty() || line[0] == '#') // Skip empty lines and comments - continue; + if (anim->channels_.empty()) + { + throw std::runtime_error("Frame data specified before any channels in animation file: " + filename); + } - std::istringstream iss(line); + int frame_index; + iss >> frame_index; - std::string command; - iss >> command; + if (frame_index < 0 || frame_index >= (int)anim->num_frames_) + { + throw std::runtime_error("Frame index out of bounds in animation file: " + filename); + } - if (command == "f") - { - if (anim->num_frames_ == 0) - { - throw std::runtime_error("Frame data specified before number of frames in animation file: " + filename); - } + if (frame_index < last_frame) + { + throw std::runtime_error("Frame indices must be in ascending order in animation file: " + filename); + } - if (anim->channels_.empty()) - { - throw std::runtime_error("Frame data specified before any channels in animation file: " + filename); - } + last_frame = frame_index; - int frame_index; - iss >> frame_index; + Transform t; + ParseTransform(iss, t); - if (frame_index < 0 || frame_index >= (int)anim->num_frames_) - { - throw std::runtime_error("Frame index out of bounds in animation file: " + filename); - } + size_t idx = anim->frames_.size(); + anim->frames_.push_back(t); - if (frame_index < last_frame) - { - throw std::runtime_error("Frame indices must be in ascending order in animation file: " + filename); - } + FillFrameRefs(frame_index); // Fill to current frame + frame_indices.push_back(idx); + } + else if (command == "ch") + { + std::string name; + iss >> name; - last_frame = frame_index; + int bone_index = skeleton->GetBoneIndex(name); - Transform t; - iss >> t.position.x >> t.position.y >> t.position.z; - glm::vec3 angles_deg; - iss >> angles_deg.x >> angles_deg.y >> angles_deg.z; - t.SetAngles(angles_deg); - iss >> t.scale; + if (bone_index < 0) + { + throw std::runtime_error("Bone referenced in animation not found in provided skeleton: " + name); + } - size_t idx = anim->frames_.size(); - anim->frames_.push_back(t); - - FillFrameRefs(frame_index); // Fill to current frame - frame_indices.push_back(idx); - } - else if (command == "ch") - { - std::string name; - iss >> name; + FillFrameRefs(anim->num_frames_); // Fill to end for last channel - int bone_index = skeleton->GetBoneIndex(name); + AnimationChannel& channel = anim->channels_.emplace_back(); + channel.bone_index = bone_index; + channel.frames = nullptr; // Will be set up later - if (bone_index < 0) - { - throw std::runtime_error("Bone referenced in animation not found in provided skeleton: " + name); - } + last_frame = 0; + } + else if (command == "frames") + { + iss >> anim->num_frames_; + } + else if (command == "fps") + { + iss >> anim->tps_; + } + }); - FillFrameRefs(anim->num_frames_); // Fill to end for last channel + if (anim->channels_.empty()) + { + throw std::runtime_error("No channels found in animation file: " + filename); + } - AnimationChannel& channel = anim->channels_.emplace_back(); - channel.bone_index = bone_index; - channel.frames = nullptr; // Will be set up later + FillFrameRefs(anim->num_frames_); // Fill to end for last channel - last_frame = 0; + // Set up frame pointers + anim->frame_refs_.resize(frame_indices.size()); + for (size_t i = 0; i < frame_indices.size(); ++i) + { + anim->frame_refs_[i] = &anim->frames_[frame_indices[i]]; + } - } - else if (command == "frames") - { - iss >> anim->num_frames_; - } - else if (command == "fps") - { - iss >> anim->tps_; - } - } - - if (anim->channels_.empty()) - { - throw std::runtime_error("No channels found in animation file: " + filename); - } + // Set up channel frame pointers + for (size_t i = 0; i < anim->channels_.size(); ++i) + { + AnimationChannel& channel = anim->channels_[i]; + channel.frames = &anim->frame_refs_[i * anim->num_frames_]; + } - FillFrameRefs(anim->num_frames_); // Fill to end for last channel - - // Set up frame pointers - anim->frame_refs_.resize(frame_indices.size()); - for (size_t i = 0; i < frame_indices.size(); ++i) - { - anim->frame_refs_[i] = &anim->frames_[frame_indices[i]]; - } - - // Set up channel frame pointers - for (size_t i = 0; i < anim->channels_.size(); ++i) - { - AnimationChannel& channel = anim->channels_[i]; - channel.frames = &anim->frame_refs_[i * anim->num_frames_]; - } - - return anim; + return anim; } diff --git a/src/assets/animation.hpp b/src/assets/animation.hpp index 6ef01f8..d3d55ea 100644 --- a/src/assets/animation.hpp +++ b/src/assets/animation.hpp @@ -1,43 +1,41 @@ #pragma once -#include #include #include +#include #include "utils/transform.hpp" namespace assets { - class Skeleton; +class Skeleton; - struct AnimationChannel - { - int bone_index; - const Transform* const* frames; - }; +struct AnimationChannel +{ + int bone_index; + const Transform* const* frames; +}; - class Animation - { - size_t num_frames_ = 0; - float tps_ = 24.0f; +class Animation +{ +public: + Animation() = default; + static std::shared_ptr LoadFromFile(const std::string& filename, const Skeleton* skeleton); - std::vector channels_; - std::vector frame_refs_; - std::vector frames_; + size_t GetNumFrames() const { return num_frames_; } + float GetTPS() const { return tps_; } + float GetDuration() const { return static_cast(num_frames_) / tps_; } - public: - Animation() = default; + size_t GetNumChannels() const { return channels_.size(); } + const AnimationChannel& GetChannel(int index) const { return channels_[index]; } - size_t GetNumFrames() const { return num_frames_; } - float GetTPS() const { return tps_; } - float GetDuration() const { return static_cast(num_frames_) / tps_; } +private: + size_t num_frames_ = 0; + float tps_ = 24.0f; - size_t GetNumChannels() const { return channels_.size(); } - const AnimationChannel& GetChannel(int index) const { return channels_[index]; } - - static std::shared_ptr LoadFromFile(const std::string& filename, const Skeleton* skeleton); - - }; + std::vector channels_; + std::vector frame_refs_; + std::vector frames_; +}; - -} \ No newline at end of file +} // namespace assets \ No newline at end of file diff --git a/src/assets/cache.cpp b/src/assets/cache.cpp index f30a86b..d9d7af7 100644 --- a/src/assets/cache.cpp +++ b/src/assets/cache.cpp @@ -7,4 +7,4 @@ assets::VehicleCache assets::CacheManager::vehicle_cache_; CLIENT_ONLY(assets::TextureCache assets::CacheManager::texture_cache_;) CLIENT_ONLY(assets::SoundCache assets::CacheManager::sound_cache_;) -CLIENT_ONLY(assets::FontCache assets::CacheManager::font_cache_;) \ No newline at end of file +CLIENT_ONLY(assets::FontCache assets::CacheManager::font_cache_;) diff --git a/src/assets/cmdfile.hpp b/src/assets/cmdfile.hpp index d7999f6..c73965d 100644 --- a/src/assets/cmdfile.hpp +++ b/src/assets/cmdfile.hpp @@ -1,6 +1,7 @@ #pragma once #include "utils/files.hpp" +#include "utils/transform.hpp" #include #include #include @@ -12,4 +13,12 @@ namespace assets void LoadCMDFile(const std::string& filename, const std::function& handler); + +inline void ParseTransform(std::istringstream& iss, Transform& trans) +{ + iss >> trans.position.x >> trans.position.y >> trans.position.z; + iss >> trans.rotation.x >> trans.rotation.y >> trans.rotation.z >> trans.rotation.w; + iss >> trans.scale; +} + } // namespace assets \ No newline at end of file diff --git a/src/assets/map.cpp b/src/assets/map.cpp index 536520e..33abbe9 100644 --- a/src/assets/map.cpp +++ b/src/assets/map.cpp @@ -53,17 +53,13 @@ std::shared_ptr assets::Map::LoadFromFile(const std::string& glm::vec3 angles; - auto trans = &obj.node.local; - - iss >> trans->position.x >> trans->position.y >> trans->position.z; - iss >> angles.x >> angles.y >> angles.z; - trans->SetAngles(angles); - iss >> trans->scale; + auto& trans = obj.node.local; + ParseTransform(iss, trans); obj.node.UpdateMatrix(); - obj.aabb.min = trans->position - glm::vec3(1.0f); - obj.aabb.max = trans->position + glm::vec3(1.0f); + obj.aabb.min = trans.position - glm::vec3(1.0f); + obj.aabb.max = trans.position + glm::vec3(1.0f); std::string flag; while (iss >> flag) diff --git a/src/assets/skeleton.cpp b/src/assets/skeleton.cpp index ab24c40..f2c697b 100644 --- a/src/assets/skeleton.cpp +++ b/src/assets/skeleton.cpp @@ -1,95 +1,78 @@ #include "skeleton.hpp" -#include "utils/files.hpp" -#include +#include "cmdfile.hpp" + #include -void assets::Skeleton::AddBone(const std::string& name, const std::string& parent_name, const Transform& transform) +std::shared_ptr assets::Skeleton::LoadFromFile(const std::string& filename) { - int index = static_cast(bones_.size()); + auto skeleton = std::make_shared(); - Bone& bone = bones_.emplace_back(); - bone.name = name; - bone.parent_idx = GetBoneIndex(parent_name); - bone.bind_transform = transform; - bone.inv_bind_matrix = glm::inverse(transform.ToMatrix()); + LoadCMDFile(filename, [&](const std::string& command, std::istringstream& iss) { + if (command == "b") + { + Transform t; + std::string bone_name, parent_name; - bone_map_[bone.name] = index; + iss >> bone_name >> parent_name; + ParseTransform(iss, t); + + if (iss.fail()) + { + throw std::runtime_error("Failed to parse bone definition in file: " + filename); + } + + skeleton->AddBone(bone_name, parent_name, t); + } + else if (command == "anim") + { + std::string anim_name, anim_filename; + iss >> anim_name >> anim_filename; + + if (iss.fail()) + { + throw std::runtime_error("Failed to parse animation definition in file: " + filename); + } + + std::shared_ptr anim = + Animation::LoadFromFile("data/" + anim_filename + ".anim", skeleton.get()); + skeleton->AddAnimation(anim_name, anim); + } + }); + + return skeleton; } int assets::Skeleton::GetBoneIndex(const std::string& name) const { - auto it = bone_map_.find(name); - if (it != bone_map_.end()) { - return it->second; - } + auto it = bone_map_.find(name); + if (it != bone_map_.end()) + { + return it->second; + } - return -1; + return -1; } const assets::Animation* assets::Skeleton::GetAnimation(const std::string& name) const { - auto it = anims_.find(name); - if (it != anims_.end()) { - return it->second.get(); - } - return nullptr; + auto it = anims_.find(name); + if (it != anims_.end()) + { + return it->second.get(); + } + return nullptr; } -std::shared_ptr assets::Skeleton::LoadFromFile(const std::string& filename) +void assets::Skeleton::AddBone(const std::string& name, const std::string& parent_name, const Transform& transform) { - std::istringstream ifs = fs::ReadFileAsStream(filename); + int index = static_cast(bones_.size()); - std::shared_ptr skeleton = std::make_shared(); + Bone& bone = bones_.emplace_back(); + bone.name = name; + bone.parent_idx = GetBoneIndex(parent_name); + bone.bind_transform = transform; + bone.inv_bind_matrix = glm::inverse(transform.ToMatrix()); - std::string line; - - while (std::getline(ifs, line)) - { - if (line.empty() || line[0] == '#') // Skip empty lines and comments - continue; - - std::istringstream iss(line); - - std::string command; - iss >> command; - - if (command == "b") - { - Transform t; - glm::vec3 angles; - std::string bone_name, parent_name; - - iss >> bone_name >> parent_name; - iss >> t.position.x >> t.position.y >> t.position.z; - iss >> angles.x >> angles.y >> angles.z; - iss >> t.scale; - - if (iss.fail()) - { - throw std::runtime_error("Failed to parse bone definition in file: " + filename); - } - - t.SetAngles(angles); - - skeleton->AddBone(bone_name, parent_name, t); - } - else if (command == "anim") - { - std::string anim_name, anim_filename; - iss >> anim_name >> anim_filename; - - if (iss.fail()) - { - throw std::runtime_error("Failed to parse animation definition in file: " + filename); - } - - std::shared_ptr anim = Animation::LoadFromFile("data/" + anim_filename + ".anim", skeleton.get()); - skeleton->AddAnimation(anim_name, anim); - } - - - } - - return skeleton; -} + bone_map_[bone.name] = index; +} \ No newline at end of file diff --git a/src/assets/skeleton.hpp b/src/assets/skeleton.hpp index e8ed7f2..6b9dc66 100644 --- a/src/assets/skeleton.hpp +++ b/src/assets/skeleton.hpp @@ -1,47 +1,46 @@ #pragma once -#include -#include #include #include +#include +#include -#include "utils/transform.hpp" #include "animation.hpp" +#include "utils/transform.hpp" namespace assets { - struct Bone - { - int parent_idx; - std::string name; - Transform bind_transform; - glm::mat4 inv_bind_matrix; - }; - class Skeleton - { - std::vector bones_; - std::map bone_map_; +struct Bone +{ + int parent_idx; + std::string name; + Transform bind_transform; + glm::mat4 inv_bind_matrix; +}; - std::map> anims_; +class Skeleton +{ +public: + Skeleton() = default; + static std::shared_ptr LoadFromFile(const std::string& filename); - public: - Skeleton() = default; + int GetBoneIndex(const std::string& name) const; - void AddBone(const std::string& name, const std::string& parent_name, const Transform& transform); + size_t GetNumBones() const { return bones_.size(); } + const Bone& GetBone(size_t idx) const { return bones_[idx]; } - int GetBoneIndex(const std::string& name) const; - - size_t GetNumBones() const { return bones_.size(); } - const Bone& GetBone(size_t idx) const { return bones_[idx]; } + const Animation* GetAnimation(const std::string& name) const; - const Animation* GetAnimation(const std::string& name) const; +private: + void AddBone(const std::string& name, const std::string& parent_name, const Transform& transform); + void AddAnimation(const std::string& name, const std::shared_ptr& anim) { anims_[name] = anim; } - static std::shared_ptr LoadFromFile(const std::string& filename); +private: + std::vector bones_; + std::map bone_map_; - private: - void AddAnimation(const std::string& name, const std::shared_ptr& anim) { anims_[name] = anim; } - }; + std::map> anims_; +}; - -} \ No newline at end of file +} // namespace assets \ No newline at end of file diff --git a/src/client/app.cpp b/src/client/app.cpp index e97f093..dcf18c4 100644 --- a/src/client/app.cpp +++ b/src/client/app.cpp @@ -94,6 +94,25 @@ void App::Frame() glm::mat4 camera_world = glm::inverse(view); audiomaster_.SetListenerOrientation(camera_world); + + if (time_ - last_send_time_ > 0.040f) + { + net::ViewYawQ yaw_q; + net::ViewPitchQ pitch_q; + yaw_q.Encode(session_->GetYaw()); + pitch_q.Encode(session_->GetPitch()); + + if (yaw_q.value != view_yaw_q_.value || pitch_q.value != view_pitch_q_.value) + { + auto msg = BeginMsg(net::MSG_VIEWANGLES); + msg.Write(yaw_q.value); + msg.Write(pitch_q.value); + + view_yaw_q_.value = yaw_q.value; + view_pitch_q_.value = pitch_q.value; + last_send_time_ = time_; + } + } } // draw chat @@ -101,15 +120,6 @@ void App::Frame() DrawChat(dlist_); renderer_.DrawList(dlist_, params); - - // if (time_ - last_send_time_ > 0.040f) - // { - // auto msg = BeginMsg(net::MSG_IN); - // msg.Write(input_); - - // last_send_time_ = time_; - - // } } void App::Connected() @@ -153,7 +163,7 @@ void App::MouseMove(const glm::vec2& delta) { float sensitivity = 0.002f; // Sensitivity factor for mouse movement - float delta_yaw = delta.x * sensitivity; + float delta_yaw = -delta.x * sensitivity; float delta_pitch = -delta.y * sensitivity; if (session_) diff --git a/src/client/app.hpp b/src/client/app.hpp index 313c971..0f8101f 100644 --- a/src/client/app.hpp +++ b/src/client/app.hpp @@ -59,6 +59,8 @@ private: glm::ivec2 viewport_size_ = {800, 600}; game::PlayerInputFlags input_ = 0; game::PlayerInputFlags prev_input_ = 0; + net::ViewYawQ view_yaw_q_; + net::ViewPitchQ view_pitch_q_; float prev_time_ = 0.0f; float delta_time_ = 0.0f; diff --git a/src/game/character.cpp b/src/game/character.cpp new file mode 100644 index 0000000..c5043e9 --- /dev/null +++ b/src/game/character.cpp @@ -0,0 +1,189 @@ +#include "character.hpp" +#include "world.hpp" +#include "net/utils.hpp" + +game::Character::Character(World& world, const CharacterInfo& info) + : Super(world, net::ET_CHARACTER), shape_(info.shape), bt_shape_(shape_.radius, shape_.height), + bt_character_(&bt_ghost_, &bt_shape_, 0.3f, btVector3(0, 0, 1)) +{ + btTransform start_transform; + start_transform.setIdentity(); + bt_ghost_.setWorldTransform(start_transform); + bt_ghost_.setCollisionShape(&bt_shape_); + bt_ghost_.setCollisionFlags(btCollisionObject::CF_CHARACTER_OBJECT); + + + btDynamicsWorld& bt_world = world_.GetBtWorld(); + static btGhostPairCallback ghostpaircb; + bt_world.getBroadphase()->getOverlappingPairCache()->setInternalGhostPairCallback(&ghostpaircb); + // bt_world.addCollisionObject(&bt_ghost_, btBroadphaseProxy::CharacterFilter, + // btBroadphaseProxy::StaticFilter | btBroadphaseProxy::DefaultFilter); + bt_world.addCollisionObject(&bt_ghost_); + bt_world.addAction(&bt_character_); +} + +static bool Turn(float& angle, float target, float step) +{ + constexpr float PI = glm::pi(); + constexpr float TWO_PI = glm::two_pi(); + + angle = glm::mod(angle, TWO_PI); + target = glm::mod(target, TWO_PI); + + float diff = glm::mod(target - angle + PI, TWO_PI) - PI; + + if (glm::abs(diff) <= step) + { + angle = target; + return true; + } + + angle = glm::mod(angle + glm::sign(diff) * step, TWO_PI); + return false; +} + +void game::Character::Update() +{ + Super::Update(); + + auto bt_trans = bt_ghost_.getWorldTransform(); + root_.local.SetBtTransform(bt_trans); + + UpdateMovement(); + SendUpdateMsg(); +} + +void game::Character::SendInitData(Player& player, net::OutMessage& msg) const +{ + Super::SendInitData(player, msg); +} + +void game::Character::SetInput(CharacterInputType type, bool enable) +{ + if (enable) + in_ |= (1 << type); + else + in_ &= ~(1 << type); +} + +// static bool SweepCapsule(btCollisionWorld& world, const btCapsuleShapeZ& shape, const glm::vec3& start, +// const glm::vec3& end, float& hit_fraction, glm::vec3& hit_normal) +// { +// btVector3 bt_start(start.x, start.y, start.z); +// btVector3 bt_end(end.x, end.y, end.z); + +// static const btMatrix3x3 bt_basis(1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f); + +// btTransform start_transform(bt_basis, bt_start); +// btTransform end_transform(bt_basis, bt_end); + +// btCollisionWorld::ClosestConvexResultCallback result_callback(bt_start, bt_end); +// world.convexSweepTest(&shape, start_transform, end_transform, result_callback); + +// if (result_callback.hasHit()) +// { +// hit_fraction = result_callback.m_closestHitFraction; +// hit_normal = glm::vec3(result_callback.m_hitNormalWorld.x(), result_callback.m_hitNormalWorld.y(), +// result_callback.m_hitNormalWorld.z()); + +// return true; +// } + +// return false; +// } + +void game::Character::SetPosition(const glm::vec3& position) +{ + auto trans = bt_ghost_.getWorldTransform(); + trans.setOrigin(btVector3(position.x, position.y, position.z)); + bt_ghost_.setWorldTransform(trans); +} + +game::Character::~Character() +{ + btDynamicsWorld& bt_world = world_.GetBtWorld(); + bt_world.removeAction(&bt_character_); + bt_world.removeCollisionObject(&bt_ghost_); +} + +void game::Character::UpdateMovement() +{ + constexpr float dt = 1.0f / 25.0f; + + glm::vec2 movedir(0.0f); + + if (in_ & (1 << CIN_FORWARD)) + movedir.x += 1.0f; + + if (in_ & (1 << CIN_BACKWARD)) + movedir.x -= 1.0f; + + if (in_ & (1 << CIN_RIGHT)) + movedir.y -= 1.0f; + + if (in_ & (1 << CIN_LEFT)) + movedir.y += 1.0f; + + glm::vec3 walkdir(0.0f); + + if (movedir.x != 0.0f || movedir.y != 0.0f) + { + float target_yaw = forward_yaw_ + std::atan2(movedir.y, movedir.x); + Turn(yaw_, target_yaw, 4.0f * dt); + + glm::vec3 forward_dir(glm::cos(yaw_), glm::sin(yaw_), 0.0f); + walkdir = forward_dir * walk_speed_ * dt; + } + + bt_character_.setWalkDirection(btVector3(walkdir.x, walkdir.y, walkdir.z)); + + if (in_ & (1 << CIN_JUMP) && bt_character_.canJump()) + { + bt_character_.jump(btVector3(0.0f, 0.0f, 10.0f)); + } +} + +void game::Character::SendUpdateMsg() +{ + net::PositionQ posq; + net::EncodePosition(root_.local.position, posq); + + auto msg = BeginEntMsg(net::EMSG_UPDATE); + net::WritePositionQ(msg, posq); + msg.Write(yaw_); +} + +void game::Character::Move(glm::vec3& velocity, float t) +{ + // glm::vec3 u = velocity * t; // Calculate the movement vector + + // btCollisionWorld& bt_world = world_.GetBtWorld(); + // btCapsuleShapeZ bt_shape(shape_.radius, shape_.height); + + // const int MAX_ITERS = 16; + // for (size_t i = 0; i < MAX_ITERS && glm::dot(u, u) > 0.0f; ++i) + // { + // // printf("Entity::Move: Iteration %zu, u = (%f, %f, %f)\n", i, u.x, u.y, u.z); + + // glm::vec3 to = position_ + u; + + // float hit_fraction = 1.0f; + // glm::vec3 hit_normal; + + // bool hit = SweepCapsule(bt_world, bt_shape, position_, to, hit_fraction, hit_normal); + + // // Update the position based on the hit fraction + // position_ += hit_fraction * u; + + // if (!hit) + // break; + + // // hit_normal *= -1.0f; // Invert the normal to point outwards + // // printf("Entity::Move: Hit detected, hit_fraction = %f, hit_normal = (%f, %f, %f)\n", hit_fraction, + // // hit_normal.x, hit_normal.y, hit_normal.z); + + // u -= hit_fraction * u; // Reduce the movement vector by the hit fraction + // u -= glm::dot(u, hit_normal) * hit_normal; // Reflect the velocity along the hit normal + // velocity -= glm::dot(velocity, hit_normal) * hit_normal; // Adjust the velocity + // } +} diff --git a/src/game/character.hpp b/src/game/character.hpp new file mode 100644 index 0000000..cbb264c --- /dev/null +++ b/src/game/character.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "entity.hpp" +#include "BulletCollision/CollisionDispatch/btGhostObject.h" +#include "BulletDynamics/Character/btKinematicCharacterController.h" + +namespace game +{ + +using CharacterInputFlags = uint8_t; + +enum CharacterInputType +{ + CIN_FORWARD, + CIN_BACKWARD, + CIN_LEFT, + CIN_RIGHT, + CIN_JUMP, +}; + +struct CapsuleShape +{ + float radius; + float height; + + CapsuleShape(float radius, float height) : radius(radius), height(height) {} +}; + +struct CharacterInfo +{ + CapsuleShape shape = CapsuleShape(0.3f, 0.75f); +}; + +class Character : public Entity +{ +public: + using Super = Entity; + + Character(World& world, const CharacterInfo& info); + + virtual void Update() override; + virtual void SendInitData(Player& player, net::OutMessage& msg) const override; + + void SetInput(CharacterInputType type, bool enable); + void SetInputs(CharacterInputFlags inputs) { in_ = inputs; } + + void SetForwardYaw(float yaw) { forward_yaw_ = yaw; } + + void SetPosition(const glm::vec3& position); + + ~Character() override; + +private: + void UpdateMovement(); + void SendUpdateMsg(); + + void Move(glm::vec3& velocity, float t); + +private: + CapsuleShape shape_; + + // glm::vec3 position_ = glm::vec3(0.0f); + // glm::vec3 velocity_ = glm::vec3(0.0f); + + CharacterInputFlags in_ = 0; + + btCapsuleShapeZ bt_shape_; + btPairCachingGhostObject bt_ghost_; + + btKinematicCharacterController bt_character_; + + float yaw_ = 0.0f; + float forward_yaw_ = 0.0f; + + float walk_speed_ = 2.0f; + +}; + +} // namespace game \ No newline at end of file diff --git a/src/game/openworld.cpp b/src/game/openworld.cpp index ac5c992..24e2ed3 100644 --- a/src/game/openworld.cpp +++ b/src/game/openworld.cpp @@ -94,7 +94,7 @@ game::OpenWorld::OpenWorld() : World("openworld") // } // spawn bots - for (size_t i = 0; i < 300; ++i) + for (size_t i = 0; i < 100; ++i) { SpawnBot(); } @@ -131,40 +131,79 @@ void game::OpenWorld::Update(int64_t delta_time) void game::OpenWorld::PlayerJoined(Player& player) { - SpawnVehicle(player); + // SpawnVehicle(player); + SpawnCharacter(player); } void game::OpenWorld::PlayerInput(Player& player, PlayerInputType type, bool enabled) { - auto vehicle = player_vehicles_.at(&player); - // player.SendChat("input zmenen: " + std::to_string(static_cast(type)) + "=" + (enabled ? "1" : "0")); + // auto vehicle = player_vehicles_.at(&player); + // // player.SendChat("input zmenen: " + std::to_string(static_cast(type)) + "=" + (enabled ? "1" : "0")); + + // switch (type) + // { + // case IN_FORWARD: + // vehicle->SetInput(VIN_FORWARD, enabled); + // break; + + // case IN_BACKWARD: + // vehicle->SetInput(VIN_BACKWARD, enabled); + // break; + + // case IN_LEFT: + // vehicle->SetInput(VIN_LEFT, enabled); + // break; + + // case IN_RIGHT: + // vehicle->SetInput(VIN_RIGHT, enabled); + // break; + + // case IN_DEBUG1: + // if (enabled) + // vehicle->SetPosition({ 100.0f, 100.0f, 5.0f }); + // break; + + // case IN_DEBUG2: + // if (enabled) + // SpawnVehicle(player); + // break; + + // default: + // break; + // } + + auto character = player_characters_.at(&player); switch (type) { case IN_FORWARD: - vehicle->SetInput(VIN_FORWARD, enabled); + character->SetInput(CIN_FORWARD, enabled); break; case IN_BACKWARD: - vehicle->SetInput(VIN_BACKWARD, enabled); + character->SetInput(CIN_BACKWARD, enabled); break; case IN_LEFT: - vehicle->SetInput(VIN_LEFT, enabled); + character->SetInput(CIN_LEFT, enabled); break; case IN_RIGHT: - vehicle->SetInput(VIN_RIGHT, enabled); + character->SetInput(CIN_RIGHT, enabled); + break; + + case IN_JUMP: + character->SetInput(CIN_JUMP, enabled); break; case IN_DEBUG1: if (enabled) - vehicle->SetPosition({ 100.0f, 100.0f, 5.0f }); + character->SetPosition({ 100.0f, 100.0f, 5.0f }); break; case IN_DEBUG2: if (enabled) - SpawnVehicle(player); + SpawnCharacter(player); break; default: @@ -172,9 +211,17 @@ void game::OpenWorld::PlayerInput(Player& player, PlayerInputType type, bool ena } } +void game::OpenWorld::PlayerViewAnglesChanged(Player& player, float yaw, float pitch) +{ + auto character = player_characters_.at(&player); + std::cout << "player aiming " << yaw << " " << pitch <SetForwardYaw(yaw); +} + void game::OpenWorld::PlayerLeft(Player& player) { - RemoveVehicle(player); + // RemoveVehicle(player); + RemoveCharacter(player); } void game::OpenWorld::RemoveVehicle(Player& player) @@ -187,6 +234,30 @@ void game::OpenWorld::RemoveVehicle(Player& player) } } +void game::OpenWorld::SpawnCharacter(Player& player) +{ + RemoveCharacter(player); + + CharacterInfo cinfo; + auto& character = Spawn(cinfo); + character.SetNametag("player (" + std::to_string(character.GetEntNum()) + ")"); + character.SetPosition({ 100.0f, 100.0f, 5.0f }); + + player.SetCamera(character.GetEntNum()); + + player_characters_[&player] = &character; +} + +void game::OpenWorld::RemoveCharacter(Player& player) +{ + auto it = player_characters_.find(&player); + if (it != player_characters_.end()) + { + it->second->Remove(); + player_characters_.erase(it); + } +} + // static void BotThink(game::Vehicle& vehicle) // { // int direction = rand() % 3; // 0=none, 1=forward, 2=backward diff --git a/src/game/openworld.hpp b/src/game/openworld.hpp index 5c99a91..04f0b33 100644 --- a/src/game/openworld.hpp +++ b/src/game/openworld.hpp @@ -2,6 +2,7 @@ #include "world.hpp" #include "vehicle.hpp" +#include "character.hpp" namespace game { @@ -15,16 +16,21 @@ public: virtual void PlayerJoined(Player& player) override; virtual void PlayerInput(Player& player, PlayerInputType type, bool enabled) override; + virtual void PlayerViewAnglesChanged(Player& player, float yaw, float pitch) override; virtual void PlayerLeft(Player& player) override; private: void SpawnVehicle(Player& player); void RemoveVehicle(Player& player); + void SpawnCharacter(Player& player); + void RemoveCharacter(Player& player); + void SpawnBot(); private: std::map player_vehicles_; + std::map player_characters_; std::vector bots_; }; diff --git a/src/game/player.cpp b/src/game/player.cpp index 6a49ef7..943cd05 100644 --- a/src/game/player.cpp +++ b/src/game/player.cpp @@ -18,6 +18,10 @@ bool game::Player::ProcessMsg(net::MessageType type, net::InMessage& msg) { case net::MSG_IN: return ProcessInputMsg(msg); + + case net::MSG_VIEWANGLES: + return ProcessViewAnglesMsg(msg); + default: return false; } @@ -187,6 +191,23 @@ bool game::Player::ProcessInputMsg(net::InMessage& msg) return true; } +bool game::Player::ProcessViewAnglesMsg(net::InMessage& msg) +{ + net::ViewYawQ yaw_q; + net::ViewPitchQ pitch_q; + + if (!msg.Read(yaw_q.value) || !msg.Read(pitch_q.value)) + return false; + + view_yaw_ = yaw_q.Decode(); + view_pitch_ = pitch_q.Decode(); + + if (world_) + world_->PlayerViewAnglesChanged(*this, view_yaw_, view_pitch_); + + return true; +} + void game::Player::Input(PlayerInputType type, bool enabled) { if (enabled) @@ -200,3 +221,4 @@ void game::Player::Input(PlayerInputType type, bool enabled) world_->PlayerInput(*this, type, enabled); } } + diff --git a/src/game/player.hpp b/src/game/player.hpp index afad8b7..70f9875 100644 --- a/src/game/player.hpp +++ b/src/game/player.hpp @@ -31,6 +31,8 @@ public: void SendChat(const std::string text); PlayerInputFlags GetInput() const { return in_; } + float GetViewYaw() const { return view_yaw_; } + float GetViewPitch() const { return view_pitch_; } ~Player(); @@ -46,6 +48,7 @@ private: // msg handlers bool ProcessInputMsg(net::InMessage& msg); + bool ProcessViewAnglesMsg(net::InMessage& msg); // events void Input(PlayerInputType type, bool enabled); @@ -59,6 +62,7 @@ private: std::set known_ents_; PlayerInputFlags in_ = 0; + float view_yaw_ = 0.0f, view_pitch_ = 0.0f; net::EntNum cam_ent_ = 0; glm::vec3 cull_pos_ = glm::vec3(0.0f); diff --git a/src/game/skeletoninstance.cpp b/src/game/skeletoninstance.cpp new file mode 100644 index 0000000..3b837ed --- /dev/null +++ b/src/game/skeletoninstance.cpp @@ -0,0 +1,86 @@ +#include "skeletoninstance.hpp" + +game::SkeletonInstance::SkeletonInstance(std::shared_ptr skeleton, + const TransformNode* root_node) + : skeleton_(std::move(skeleton)), root_node_(root_node) +{ + SetupBoneNodes(); +} + +void game::SkeletonInstance::ApplySkelAnim(const assets::Animation& anim, float time, float weight) +{ + float anim_frame = time * anim.GetTPS(); + + if (anim_frame < 0.0f) + anim_frame = 0.0f; + + size_t num_anim_frames = anim.GetNumFrames(); + + size_t frame_i = static_cast(anim_frame); + size_t frame0 = frame_i % num_anim_frames; + size_t frame1 = (frame_i + 1) % num_anim_frames; + float t = anim_frame - static_cast(frame_i); + + for (size_t i = 0; i < anim.GetNumChannels(); ++i) + { + const assets::AnimationChannel& channel = anim.GetChannel(i); + TransformNode& node = bone_nodes_[channel.bone_index]; + + const Transform* t0 = channel.frames[frame0]; + const Transform* t1 = channel.frames[frame1]; + + Transform anim_transform; + + if (t0 != t1) + { + anim_transform = Transform::Lerp(*t0, *t1, t); // Frames are different, interpolate + } + else + { + anim_transform = *t0; // Frames are the same, no interpolation needed + } + + if (weight < 1.0f) + { + // blend with existing transform + node.local = Transform::Lerp(node.local, anim_transform, weight); + } + else + { + node.local = anim_transform; + } + } +} + +void game::SkeletonInstance::UpdateBoneMatrices() +{ + for (TransformNode& node : bone_nodes_) + { + node.UpdateMatrix(); + } +} + +void game::SkeletonInstance::SetupBoneNodes() +{ + size_t num_bones = skeleton_->GetNumBones(); + bone_nodes_.resize(num_bones); + + for (size_t i = 0; i < num_bones; ++i) + { + const auto& bone = skeleton_->GetBone(i); + TransformNode& node = bone_nodes_[i]; + + node.local = bone.bind_transform; + + if (bone.parent_idx >= 0) + { + node.parent = &bone_nodes_[bone.parent_idx]; + } + else + { + node.parent = root_node_; + } + } + + UpdateBoneMatrices(); +} diff --git a/src/game/skeletoninstance.hpp b/src/game/skeletoninstance.hpp new file mode 100644 index 0000000..95b10b9 --- /dev/null +++ b/src/game/skeletoninstance.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "assets/skeleton.hpp" +#include "transform_node.hpp" +#include "utils/defs.hpp" + +namespace game +{ + +class SkeletonInstance +{ +public: + SkeletonInstance() = default; + SkeletonInstance(std::shared_ptr skeleton, const TransformNode* root_node); + + const TransformNode* GetRootNode() const { return root_node_; } + const TransformNode& GetBoneNode(size_t index) const { return bone_nodes_[index]; } + + void ApplySkelAnim(const assets::Animation& anim, float time, float weight); + void UpdateBoneMatrices(); + + const std::vector GetBoneNodes() const { return bone_nodes_; } + + const std::shared_ptr& GetSkeleton() const { return skeleton_; } + +private: + void SetupBoneNodes(); + +private: + std::shared_ptr skeleton_; + const TransformNode* root_node_ = nullptr; + std::vector bone_nodes_; +}; + +} // namespace game \ No newline at end of file diff --git a/src/game/world.hpp b/src/game/world.hpp index 7b24ea8..b92a18c 100644 --- a/src/game/world.hpp +++ b/src/game/world.hpp @@ -35,6 +35,7 @@ public: // events virtual void PlayerJoined(Player& player) {} virtual void PlayerInput(Player& player, PlayerInputType type, bool enabled) {} + virtual void PlayerViewAnglesChanged(Player& player, float yaw, float pitch) {} virtual void PlayerLeft(Player& player) {} Entity* GetEntity(net::EntNum entnum); diff --git a/src/gameview/characterview.cpp b/src/gameview/characterview.cpp new file mode 100644 index 0000000..943ca99 --- /dev/null +++ b/src/gameview/characterview.cpp @@ -0,0 +1,66 @@ +#include "characterview.hpp" +#include "assets/cache.hpp" +#include "net/utils.hpp" + +game::view::CharacterView::CharacterView(WorldView& world, net::InMessage& msg) : EntityView(world, msg) +{ + + sk_ = SkeletonInstance(assets::CacheManager::GetSkeleton("data/human.sk"), &root_); +} + +bool game::view::CharacterView::ProcessMsg(net::EntMsgType type, net::InMessage& msg) +{ + switch (type) + { + case net::EMSG_UPDATE: + return ProcessUpdateMsg(msg); + + default: + return Super::ProcessMsg(type, msg); + } +} + +void game::view::CharacterView::Update(const UpdateInfo& info) +{ + auto anim = sk_.GetSkeleton()->GetAnimation("walk"); + sk_.ApplySkelAnim(*anim, info.time, 1.0f); + root_.UpdateMatrix(); + sk_.UpdateBoneMatrices(); +} + +void game::view::CharacterView::Draw(const DrawArgs& args) +{ + Super::Draw(args); + + glm::vec3 start = root_.local.position; + glm::vec3 end = start + glm::vec3(0.0f, 0.0f, 1.5f); + args.dlist.AddBeam(start, end, 0xFF777777, 0.1f); + + start = root_.local.position; + end = start + glm::vec3(glm::cos(yaw_), glm::sin(yaw_), 0.0f) * 0.5f; + args.dlist.AddBeam(start, end, 0xFF007700, 0.05f); + + // draw bones debug + const auto& bone_nodes = sk_.GetBoneNodes(); + for (const auto& bone_node : bone_nodes) + { + if (!bone_node.parent) + continue; + + glm::vec3 p0 = bone_node.parent->matrix[3]; + glm::vec3 p1 = bone_node.matrix[3]; + + args.dlist.AddBeam(p0, p1, 0xFF00EEEE, 0.01f); + } +} + +bool game::view::CharacterView::ProcessUpdateMsg(net::InMessage& msg) +{ + net::PositionQ posq; + if (!net::ReadPositionQ(msg, posq) || !msg.Read(yaw_)) + return false; + + net::DecodePosition(posq, root_.local.position); + + return true; +} diff --git a/src/gameview/characterview.hpp b/src/gameview/characterview.hpp new file mode 100644 index 0000000..89dcd34 --- /dev/null +++ b/src/gameview/characterview.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "entityview.hpp" +#include "game/skeletoninstance.hpp" + +namespace game::view +{ + +class CharacterView : public EntityView +{ +public: + using Super = EntityView; + + CharacterView(WorldView& world, net::InMessage& msg); + DELETE_COPY_MOVE(CharacterView) + + virtual bool ProcessMsg(net::EntMsgType type, net::InMessage& msg) override; + virtual void Update(const UpdateInfo& info) override; + virtual void Draw(const DrawArgs& args) override; + +private: + bool ProcessUpdateMsg(net::InMessage& msg); + +private: + float yaw_ = 0.0f; + + SkeletonInstance sk_; + +}; + +} diff --git a/src/gameview/client_session.cpp b/src/gameview/client_session.cpp index 633cf63..ac1c67d 100644 --- a/src/gameview/client_session.cpp +++ b/src/gameview/client_session.cpp @@ -1,7 +1,7 @@ #include "client_session.hpp" -#include #include "client/app.hpp" +#include // #include game::view::ClientSession::ClientSession(App& app) : app_(app) {} @@ -32,7 +32,7 @@ bool game::view::ClientSession::ProcessSingleMessage(net::MessageType type, net: case net::MSG_CAM: return ProcessCameraMsg(msg); - + case net::MSG_CHAT: return ProcessChatMsg(msg); @@ -47,16 +47,18 @@ bool game::view::ClientSession::ProcessSingleMessage(net::MessageType type, net: void game::view::ClientSession::ProcessMouseMove(float delta_yaw, float delta_pitch) { - yaw_ += delta_yaw; - // yaw_ = glm::fmod(yaw_, 2.0f * glm::pi()); + yaw_ = glm::mod(yaw_ + delta_yaw, glm::two_pi()); pitch_ += delta_pitch; // Clamp pitch to avoid gimbal lock - if (pitch_ > glm::radians(89.0f)) { - pitch_ = glm::radians(89.0f); - } else if (pitch_ < glm::radians(-89.0f)) { - pitch_ = glm::radians(-89.0f); - } + if (pitch_ > glm::radians(89.0f)) + { + pitch_ = glm::radians(89.0f); + } + else if (pitch_ < glm::radians(-89.0f)) + { + pitch_ = glm::radians(-89.0f); + } } void game::view::ClientSession::Update(const UpdateInfo& info) @@ -76,11 +78,11 @@ void game::view::ClientSession::GetViewInfo(glm::vec3& eye, glm::mat4& view) con center += ent->GetRoot().local.position; } - float yaw_cos = glm::cos(yaw_); - float yaw_sin = glm::sin(yaw_); - float pitch_cos = glm::cos(pitch_); - float pitch_sin = glm::sin(pitch_); - glm::vec3 dir(yaw_sin * pitch_cos, yaw_cos * pitch_cos, pitch_sin); + float yaw_cos = glm::cos(yaw_); + float yaw_sin = glm::sin(yaw_); + float pitch_cos = glm::cos(pitch_); + float pitch_sin = glm::sin(pitch_); + glm::vec3 dir(yaw_cos * pitch_cos, yaw_sin * pitch_cos, pitch_sin); float distance = 8.0f; diff --git a/src/gameview/client_session.hpp b/src/gameview/client_session.hpp index f80f351..56a7aa9 100644 --- a/src/gameview/client_session.hpp +++ b/src/gameview/client_session.hpp @@ -31,6 +31,9 @@ public: audio::Master& GetAudioMaster() const; + float GetYaw() const { return yaw_; } + float GetPitch() const { return pitch_; } + private: // msg handlers bool ProcessWorldMsg(net::InMessage& msg); diff --git a/src/gameview/entityview.cpp b/src/gameview/entityview.cpp index 3441544..c44e8a7 100644 --- a/src/gameview/entityview.cpp +++ b/src/gameview/entityview.cpp @@ -95,13 +95,9 @@ void game::view::EntityView::DrawAxes(const DrawArgs& args) glm::vec3 end(0.0f); end[i] = len; - gfx::DrawBeamCmd cmd; - cmd.start = glm::vec3(root_.matrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); - cmd.end = glm::vec3(root_.matrix * glm::vec4(end, 1.0f)); - cmd.color = colors[i]; - cmd.radius = 0.05f; - //cmd.num_segments = 10; - //cmd.max_offset = 0.1f; - args.dlist.AddBeam(cmd); + glm::vec3 beam_start = glm::vec3(root_.matrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); + glm::vec3 beam_end = glm::vec3(root_.matrix * glm::vec4(end, 1.0f)); + + args.dlist.AddBeam(beam_start, beam_end, colors[i], 0.05f); } } diff --git a/src/gameview/worldview.cpp b/src/gameview/worldview.cpp index a6640d1..2e11860 100644 --- a/src/gameview/worldview.cpp +++ b/src/gameview/worldview.cpp @@ -2,6 +2,7 @@ #include "assets/cache.hpp" +#include "characterview.hpp" #include "vehicleview.hpp" #include "client_session.hpp" @@ -77,6 +78,10 @@ bool game::view::WorldView::ProcessEntSpawnMsg(net::InMessage& msg) { switch (type) { + case net::ET_CHARACTER: + entslot = std::make_unique(*this, msg); + break; + case net::ET_VEHICLE: entslot = std::make_unique(*this, msg); break; diff --git a/src/gfx/draw_list.hpp b/src/gfx/draw_list.hpp index df9e0de..e1f72e3 100644 --- a/src/gfx/draw_list.hpp +++ b/src/gfx/draw_list.hpp @@ -3,8 +3,8 @@ #include #include "assets/skeleton.hpp" -#include "surface.hpp" #include "hud.hpp" +#include "surface.hpp" namespace gfx { @@ -23,10 +23,16 @@ struct DrawBeamCmd { glm::vec3 start; glm::vec3 end; - uint32_t color = 0xFFFFFFFF; - float radius = 0.1f; - size_t num_segments = 1; - float max_offset = 0.0f; + uint32_t color; + float radius; + size_t num_segments; + float max_offset; + + DrawBeamCmd(const glm::vec3& start, const glm::vec3& end, uint32_t color, float radius, + size_t num_segments, float max_offset) + : start(start), end(end), color(color), radius(radius), num_segments(num_segments), max_offset(max_offset) + { + } }; struct DrawHudCmd @@ -44,7 +50,14 @@ struct DrawList std::vector huds; void AddSurface(const DrawSurfaceCmd& cmd) { surfaces.emplace_back(cmd); } + void AddBeam(const DrawBeamCmd& cmd) { beams.emplace_back(cmd); } + void AddBeam(const glm::vec3& start, const glm::vec3& end, uint32_t color = 0xFFFFFFFF, float radius = 0.1f, + size_t num_segments = 1, float max_offset = 0.0f) + { + beams.emplace_back(start, end, color, radius, num_segments, max_offset); + } + void AddHUD(const DrawHudCmd& cmd) { huds.emplace_back(cmd); } void Clear() diff --git a/src/net/defs.hpp b/src/net/defs.hpp index ca5ab58..a3008d0 100644 --- a/src/net/defs.hpp +++ b/src/net/defs.hpp @@ -17,9 +17,12 @@ enum MessageType : uint8_t // ID MSG_ID, - // IN + // IN MSG_IN, + // VIEWANGLES + MSG_VIEWANGLES, + /*~~~~~~~~ Session ~~~~~~~~*/ // CHAT MSG_CHAT, @@ -82,6 +85,7 @@ struct PositionQ }; using AngleQ = Quantized; +using PositiveAngleQ = Quantized; using QuatElemQ = Quantized; struct QuatQ diff --git a/tools/export.py b/tools/export.py new file mode 100644 index 0000000..8983063 --- /dev/null +++ b/tools/export.py @@ -0,0 +1,799 @@ +from __future__ import annotations +from dataclasses import dataclass +import bpy +import os +import re +import math + +CHUNK_SIZE = 250.0 + +@dataclass +class Vec3: + x: float + y: float + z: float + + def __add__(self, other): + return Vec3(self.x + other.x, self.y + other.y, self.z + other.z) + + def __mul__(self, scalar: float): + return Vec3(self.x * scalar, self.y * scalar, self.z * scalar) + + def dot(self, other): + return self.x*other.x + self.y*other.y + self.z*other.z + + def cross(self, other): + return Vec3( + self.y*other.z - self.z*other.y, + self.z*other.x - self.x*other.z, + self.x*other.y - self.y*other.x + ) + + @staticmethod + def min(a: Vec3, b: Vec3): + return Vec3( + a.x if a.x < b.x else b.x, + a.y if a.y < b.y else b.y, + a.z if a.z < b.z else b.z + ) + + @staticmethod + def max(a: Vec3, b: Vec3): + return Vec3( + a.x if a.x > b.x else b.x, + a.y if a.y > b.y else b.y, + a.z if a.z > b.z else b.z + ) + + + def norm(self): + return math.sqrt(self.dot(self)) + +class Vertex: + position: tuple[float, float, float] + normal: tuple[float, float, float] + uv: tuple[float, float] + color: tuple[float, float, float] | None + + def __init__(self, position, normal, uv, color=None): + self.position = tuple(round(x, 6) for x in position) + self.normal = tuple(round(x, 6) for x in normal) + self.uv = tuple(round(x, 6) for x in uv) + self.color = tuple(round(c, 3) for c in color) if color else None + + def __hash__(self): + return hash((self.position, self.normal, self.uv, self.color)) + + def __eq__(self, other): + return (self.position, self.normal, self.uv, self.color) == (other.position, other.normal, other.uv, other.color) + +class Surface: + tris: list[tuple[int, int, int]] + texture: str + twosided: bool + ocolor: bool + blend: str|None + + def __init__(self, name: str): + self.tris = [] + self.texture = name + self.twosided = False + self.ocolor = False + self.blend = None + +class Model: + vertices: list[Vertex] + vertex_map: dict[Vertex, int] + materials: dict[str, Surface] + make_col_trimesh: bool + make_convex_hull: bool + + def __init__(self): + self.vertices = [] + self.vertex_map = {} + self.materials = {} + self.make_col_trimesh = False + self.make_convex_hull = False + + def add_vertex(self, vertex: Vertex) -> int: + if vertex in self.vertex_map: + return self.vertex_map[vertex] + index = len(self.vertices) + self.vertices.append(vertex) + self.vertex_map[vertex] = index + return index + + def add_triangle(self, material_name: str, v1: int, v2: int, v3: int): + # if material_name not in self.materials: + # self.materials[material_name] = Surface(material_name) + self.materials[material_name].tris.append((v1, v2, v3)) + +class Transform: + position: tuple[float, float, float] + rotation: tuple[float, float, float, float] # quat xyzw + scale: float + + def __init__(self, position, rotation, scale): + self.position = tuple(round(x, 6) for x in position) + self.rotation = tuple(round(x, 6) for x in rotation) + self.scale = round(scale, 6) + +class GraphNode: + pos: tuple[float, float, float] + + def __init__(self, pos): + self.pos = tuple(round(x, 6) for x in pos) + +class GraphEdge: + nodes: tuple[int, int] + + def __init__(self, node1, node2): + self.nodes = (node1, node2) + +class Graph: + name: str + nodes: list[GraphNode] + edges: list[GraphEdge] + + def __init__(self, name: str): + self.name = name + self.nodes = [] + self.edges = [] + + def add_node(self, node: GraphNode) -> int: + index = len(self.nodes) + self.nodes.append(node) + return index + + def add_edge(self, node1_index: int, node2_index: int): + edge = GraphEdge(node1_index, node2_index) + self.edges.append(edge) + +class Chunk: + coord: tuple[int, int] # x,y + shifted_coord: tuple[int, int] + aabb_min: Vec3 + aabb_max: Vec3 + static_objects: list[tuple[str, Transform]] + surface_ranges: list[tuple[str, int, int]] # name, first, count + + def __init__(self, coord: tuple[int, int]): + self.coord = coord + self.shifted_coord = (0, 0) + self.static_objects = [] + self.surface_ranges = [] + + self.aabb_min = Vec3(math.inf, math.inf, math.inf) + self.aabb_max = Vec3(-math.inf, -math.inf, -math.inf) + + def extend_aabb(self, pos: Vec3): + self.aabb_min = Vec3.min(self.aabb_min, pos) + self.aabb_max = Vec3.max(self.aabb_max, pos) + +class Map: + basemodel: Model + basemodel_name: str + static_objects: list[tuple[str, Transform]] + graphs: list[Graph] + chunks: dict[tuple[int, int], Chunk] + max_chunk: tuple[int, int] + + def __init__(self): + self.basemodel = Model() + self.basemodel_name = "" + self.static_objects = [] + self.graphs = [] + self.chunks = None + self.max_chunk = (0, 0) + + def create_chunks(self): + self.chunks = {} + + def get_chunk(coord: tuple[int,int]) -> Chunk: + if not coord in self.chunks: + chunk = Chunk(coord) + self.chunks[coord] = chunk + return chunk + return self.chunks[coord] + + def pos_to_chunk(pos: Vec3) -> tuple[int,int]: + return int(math.floor(pos.x / CHUNK_SIZE)), int(math.floor(pos.y / CHUNK_SIZE)) + + def tri_to_chunk(tri: tuple[int, int, int]) -> tuple[int, int]: + p0, p1, p2 = [Vec3(*self.basemodel.vertices[i].position) for i in tri] + return pos_to_chunk((p0 + p1 + p2) * (1/3)) + + # surfaces + for name, surface in self.basemodel.materials.items(): + tris = [(tri, tri_to_chunk(tri)) for tri in surface.tris] + tris.sort(key=lambda x: x[1]) #sort by chunk coord + + cur_chunk: Chunk|None = None + start = 0 + i = 0 + + def finish_cur_chunk(): + if cur_chunk is None or i - start < 1: + return + + count = i - start + cur_chunk.surface_ranges.append((name, start, count)) + + # extend aabb wtih tris + for j in range(start, i): + p0, p1, p2 = [Vec3(*self.basemodel.vertices[k].position) for k in tris[j][0]] + cur_chunk.extend_aabb(p0) + cur_chunk.extend_aabb(p1) + cur_chunk.extend_aabb(p2) + + + surface.tris.clear() + + for tri_coord in tris: + tri, coord = tri_coord + surface.tris.append(tri) + + if cur_chunk is None or coord != cur_chunk.coord: + finish_cur_chunk() + cur_chunk = get_chunk(coord) + start = i + + i += 1 + + finish_cur_chunk() + + # objects + for obj in self.static_objects: + name, trans = obj + pos = Vec3(*trans.position) + + chunk = get_chunk(pos_to_chunk(pos)) + chunk.extend_aabb(pos) + chunk.static_objects.append(obj) + + # min/max + min_chunk = (100000, 100000) + max_chunk = (-100000, -100000) + + for coord in self.chunks: + min_chunk = ( + min_chunk[0] if min_chunk[0] < coord[0] else coord[0], + min_chunk[1] if min_chunk[1] < coord[1] else coord[1] + ) + + max_chunk = ( + max_chunk[0] if max_chunk[0] > coord[0] else coord[0], + max_chunk[1] if max_chunk[1] > coord[1] else coord[1] + ) + + for coord, chunk in self.chunks.items(): + chunk.shifted_coord = ( + coord[0] - min_chunk[0], + coord[1] - min_chunk[1], + ) + + self.max_chunk = ( + max_chunk[0] - min_chunk[0], + max_chunk[1] - min_chunk[1], + ) + +class Wheel: + type: str + model_name: str + position: tuple[float, float, float] + radius: float + +class Vehicle: + basemodel_name: str + wheels: list[Wheel] # type, model, transform + + def __init__(self): + self.wheels = [] + +class Bone: + name: str + parent: str|None + trans: Transform + + def __init__(self, name): + self.name = name + +class AnimChannel: + name: str + frames: list[tuple[int, Transform]] + + def __init__(self, name): + self.name = name + self.frames = [] + +class Animation: + name: str + fps: float + frames: int + channels: list[AnimChannel] + + def __init__(self, name: str, fps: float, frames: int): + self.name = name + self.fps = fps + self.frames = frames + self.channels = [] + +class Skeleton: + name: str + bones: list[Bone] + bone_set: set[str] + anims: list[Animation] + + def __init__(self, name, armature): + self.name = name + self.bones = [] + self.bone_set = set() + self.armature = armature + self.anims = [] + +class Exporter: + skeletons: dict[str, Skeleton] + + def __init__(self): + self.blend_dir = os.path.dirname(bpy.data.filepath) + self.out_path = os.path.join(self.blend_dir, "export") + self.skeletons = {} + + @staticmethod + def add_mesh_to_model(obj, model: Model): + if obj.type != "MESH": + print("warning: tried to export object that is not a mesh") + return + + print(f" processing mesh: {obj.name}") + + mesh = obj.data + mesh.calc_loop_triangles() + + # Prepare UV layers + uv_layer = mesh.uv_layers.active.data + + for tri in mesh.loop_triangles: + face_indices = [] + + material_index = tri.material_index + + # Get the material from the object's material slots + if material_index < len(obj.material_slots): + material = obj.material_slots[material_index].material + material_name = material.name if material else "Unknown" + else: + material_name = "Unknown" + + mat_type, mat_name, mat_params = Exporter.extract_name(material_name) + + if mat_type != "MAT": + continue # skip non material + + for loop_index in tri.loops: + loop = mesh.loops[loop_index] + vertex = mesh.vertices[loop.vertex_index] + + pos = tuple(c for c in vertex.co) + normal = tuple(n for n in loop.normal) + uv = tuple(c for c in uv_layer[loop_index].uv) + + # get color from named attribute + color = None + color_attr = mesh.color_attributes.get("vertex_color") + if color_attr: + color_data = color_attr.data[loop_index] + color = tuple(c for c in color_data.color[:3]) # ignore alpha + + vert = Vertex(position=pos, normal=normal, uv=uv, color=color) + vert_index = model.add_vertex(vert) + + face_indices.append(vert_index) + + if mat_name not in model.materials: + surface = Surface(mat_name) + surface.twosided = "2S" in mat_params + surface.ocolor = "OCOLOR" in mat_params + blend = mat_params.get("BLEND") + if isinstance(blend, str): + surface.blend = blend + model.materials[mat_name] = surface + + model.add_triangle(mat_name, *face_indices) + + @staticmethod + def add_mesh_to_graph(obj, graph: Graph): + mesh = obj.data + + # Ensure we have access to the 'G_flip' attribute + flip_layer = None + if "G_flip" in mesh.attributes: + flip_layer = mesh.attributes["G_flip"] + + # Add nodes + for v in mesh.vertices: + pos = tuple(c for c in v.co) + graph.add_node(GraphNode(pos)) + + # Add edges + for e in mesh.edges: + v1, v2 = e.vertices[0], e.vertices[1] + + if flip_layer and flip_layer.data[e.index].value: + graph.add_edge(v2, v1) + else: + graph.add_edge(v1, v2) + + def rad2deg(self, rad: float) -> float: + return rad * (180.0 / 3.141592653589793) + + def transform_str(self, transform: Transform): + # rx, ry, rz = transform.rotation + # dx, dy, dz = self.rad2deg(rx), self.rad2deg(ry), self.rad2deg(rz) + t, r, s = transform.position, transform.rotation, transform.scale + return f"{t[0]:.6f} {t[1]:.6f} {t[2]:.6f} {r[0]:.6f} {r[1]:.6f} {r[2]:.6f} {r[3]:.6f} {s:.6f}" + + def export_mdl(self, model: Model, filepath: str): + with open(filepath, "w") as f: + if model.make_col_trimesh: + f.write("makecoltrimesh\n") + + if model.make_convex_hull: + f.write("makeconvexhull\n") + + for v in model.vertices: + color_str = f" {v.color[0]} {v.color[1]} {v.color[2]}" if v.color else "" + f.write(f"v {v.position[0]} {v.position[1]} {v.position[2]} {v.normal[0]} {v.normal[1]} {v.normal[2]} {v.uv[0]} {v.uv[1]}{color_str}\n") + + for mat_name, surface in model.materials.items(): + f.write(f"surface {mat_name} +texture {surface.texture}") + if surface.twosided: + f.write(" +2sided") + if surface.ocolor: + f.write(" +ocolor") + if surface.blend is not None: + f.write(f" +blend {surface.blend}") + f.write("\n") + for tri in surface.tris: + f.write(f"f {tri[0]} {tri[1]} {tri[2]}\n") + + def export_map(self, map: Map, filepath: str): + with open(filepath, "w") as f: + f.write(f"basemodel {map.basemodel_name}\n") + + # graphs + for graph in map.graphs: + f.write(f"graph {graph.name}\n") + for node in graph.nodes: + f.write(f"n {node.pos[0]} {node.pos[1]} {node.pos[2]}\n") + for edge in graph.edges: + f.write(f"e {edge.nodes[0]} {edge.nodes[1]}\n") + + # # static + # for obj_name, transform in map.static_objects: + # f.write(f"static {obj_name} {Exporter.transform_str(transform)}\n") + + f.write(f"chunks {map.max_chunk[0]} {map.max_chunk[1]}\n") + + chunks_sorted = [c[1] for c in map.chunks.items()] + chunks_sorted.sort(key=lambda c: c.shifted_coord) + + for chunk in chunks_sorted: + f.write(f"chunk {chunk.shifted_coord[0]} {chunk.shifted_coord[1]} {chunk.aabb_min.x} {chunk.aabb_min.y} {chunk.aabb_min.z} {chunk.aabb_max.x} {chunk.aabb_max.y} {chunk.aabb_max.z}\n") + + for name, first, count in chunk.surface_ranges: + f.write(f"surface {name} {first} {count}\n") + + for obj_name, transform in chunk.static_objects: + f.write(f"static {obj_name} {self.transform_str(transform)}\n") + + def export_veh(self, veh: Vehicle, filepath: str): + with open(filepath, "w") as f: + f.write(f"basemodel {veh.basemodel_name}\n") + + for wheel in veh.wheels: + f.write(f"wheel {wheel.type} {wheel.model_name} {wheel.position[0]} {wheel.position[1]} {wheel.position[2]} {wheel.radius}\n") + + def export_sk(self, sk: Skeleton, filepath: str): + with open(filepath, "w") as f: + for bone in sk.bones: + parent_str = bone.parent if bone.parent is not None else "NONE" + f.write(f"b {bone.name} {parent_str} {self.transform_str(bone.trans)}\n") + + for anim in sk.anims: + f.write(f"anim {anim.name} {sk.name}_{anim.name}\n") + + def export_anim(self, anim: Animation, filepath: str): + with open(filepath, 'w') as f: + f.write(f"frames {anim.frames}\n") + f.write(f"fps {anim.fps}\n") + + for chan in anim.channels: + f.write(f"ch {chan.name}\n") + + for idx, trans in chan.frames: + f.write(f"f {idx} {self.transform_str(trans)}\n") + + @staticmethod + def extract_name(fullname: str) -> tuple[str|None, str, dict[str, str|bool]]: + match = re.match(rf"^(\w+)\/([\w]+)\/?([^\.]*)", fullname) + + if not match: + return None, fullname, {} + + type, name, all_params_str = match.group(1), match.group(2), match.group(3) + + # extract params + params_str = all_params_str.split("/") + params: dict[str, str|bool] = {} + for str_param in params_str: + if str_param.find("=") == -1: + params[str_param] = True + continue + + k, v = str_param.split("=") + params[k] = v + + return type, name, params + + def get_obj_transform(self, obj) -> Transform: + mat = obj.matrix_world + # position = obj.location + # rotation = (obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w) + # scale = obj.scale[0] # assume uniform scale + # return Transform(position, rotation, scale) + return Transform(*self.matrix_decompose(mat)) + + def process_MDL(self, col, name, params): + print(f"exporting MDL: {name}") + model = Model() + + if "C" in params: + Cs = params["C"].split(",") + for C in Cs: + if C == "tri": + model.make_col_trimesh = True + elif C == "convex": + model.make_convex_hull = True + + for obj in col.objects: + type, _, _ = self.extract_name(obj.name) + + if type == "M": + self.add_mesh_to_model(obj, model) + + mdl_filepath = os.path.join(self.out_path, f"{name}.mdl") + self.export_mdl(model, mdl_filepath) + + def process_MAP(self, col, name, params): + print(f"exporting MAP: {name}") + map = Map() + map.basemodel_name = name + map.basemodel.make_col_trimesh = True + + def proc_col(col): + for obj in col.objects: + type, obj_name, _ = self.extract_name(obj.name) + + if type == "M": + self.add_mesh_to_model(obj, map.basemodel) + elif type == "OBJ": + transform = self.get_obj_transform(obj) + map.static_objects.append((obj_name, transform)) + elif type == "GRAPH": + graph = Graph(obj_name) + self.add_mesh_to_graph(obj, graph) + map.graphs.append(graph) + + for child_col in col.children: + proc_col(child_col) + + proc_col(col) + + map.create_chunks() + + mdl_filepath = os.path.join(self.out_path, f"{name}.mdl") + self.export_mdl(map.basemodel, mdl_filepath) + + map_filepath = os.path.join(self.out_path, f"{name}.map") + self.export_map(map, map_filepath) + + def process_VEH(self, col, name, params): + print(f"exporting VEH: {name}") + veh = Vehicle() + veh.basemodel_name = name + + for obj in col.objects: + type, obj_name, params = self.extract_name(obj.name) + + if type == "WHEEL": + wheel_type = params["W"] if "W" in params else "" + radius = float(params["R"].replace(",", ".")) if "R" in params else 0.5 + transform = self.get_obj_transform(obj) + + wheel = Wheel() + wheel.type = wheel_type + wheel.model_name = obj_name + wheel.position = transform.position + wheel.radius = radius + veh.wheels.append(wheel) + + veh.wheels.sort(key=lambda w: w.type) + + veh_filepath = os.path.join(self.out_path, f"{name}.veh") + self.export_veh(veh, veh_filepath) + + def get_armature_keep_bones(self, armature): + keep_bones = set() + + for bone in armature.data.bones: + #bone_name = bone.name + + if bone.use_deform: # or is_tag: + keep_bones.add(bone) + + while bone.parent: + bone = bone.parent + keep_bones.add(bone) + + return keep_bones + + def matrix_decompose(self, matrix): + t, r, s = matrix.decompose() + return (t.x, t.y, t.z), (r.x, r.y, r.z, r.w), s.x + + def process_SK(self, obj, name, params): + if obj.type != "ARMATURE": + print("SK object not armature!") + return + + sk = Skeleton(name, obj) + + keep_bones = self.get_armature_keep_bones(obj) + + keep_bones_str = ",".join([bone.name for bone in keep_bones]) + print(f"Keep bones: {keep_bones_str}") + print(f"Num bones: {len(keep_bones)}") + + for bone in obj.data.bones: + if not bone in keep_bones: + continue + + xbone = Bone(bone.name) + + parent = bone.parent + xbone.parent = parent.name if parent else None + + bind_matrix = bone.matrix_local + xbone.trans = Transform(*self.matrix_decompose(bind_matrix)) + + sk.bones.append(xbone) + sk.bone_set.add(xbone.name) + + self.skeletons[name] = sk + + def process_A(self, action, name, params): + if not "_" in name: + print(f"{name}: required format skeleton_animname") + return + + sk_name, anim_name = name.split("_", 1) + + if not sk_name in self.skeletons: + print(f"{anim_name}: unknown anim skeleton {sk_name}") + return + + sk = self.skeletons[sk_name] + + original_action = sk.armature.animation_data.action + original_frame = bpy.context.scene.frame_current + + sk.armature.animation_data.action = action + + bone_frames = {bonename: [] for bonename in sk.bone_set} + + _, end = map(int, action.frame_range) + fps = bpy.context.scene.render.fps + + def vectors_similar(v1, v2, threshold): + return (v1 - v2).length < threshold + + def quats_similar(q1, q2, threshold=1e-4): + return q1.rotation_difference(q2).angle < threshold + + def frames_similar(f1, f2, threshold=0.00001): + _, t1, r1, s1, _ = f1 + _, t2, r2, s2, _ = f2 + + if not vectors_similar(t1, t2, threshold): + return False + + if not quats_similar(r1, r2): + return False + + if not vectors_similar(s1, s2, threshold): + return False + + return True + + for frame in range(0, end): + bpy.context.scene.frame_set(frame) + bpy.context.view_layer.update() + + for bonename in sk.bone_set: + pose_bone = sk.armature.pose.bones.get(bonename) + + if not pose_bone: + continue + + matrix = (pose_bone.parent.matrix.inverted() @ pose_bone.matrix) if pose_bone.parent else pose_bone.matrix.copy() + + translation, rotation, scale = matrix.decompose() + current_frame = (frame, translation, rotation, scale, matrix) + + frame_list = bone_frames[bonename] + + if len(frame_list) > 0: + last_frame = frame_list[-1] + + if frames_similar(last_frame, current_frame): + continue + + frame_list.append(current_frame) + + anim = Animation(anim_name, fps, end) + + for bone_name, frames in bone_frames.items(): + chan = AnimChannel(bone_name) + + for frame in frames: + idx, _, _, _, matrix = frame + chan.frames.append((idx, Transform(*self.matrix_decompose(matrix)))) + + anim.channels.append(chan) + + anim.channels.sort(key=lambda ch: ch.name) + + bpy.context.scene.frame_set(original_frame) + sk.armature.animation_data.action = original_action + + sk.anims.append(anim) + + def run(self): + print("=== EXPORT STARTED ===") + os.makedirs(self.out_path, exist_ok=True) + + # collections + for col in bpy.context.scene.collection.children: + type, name, params = self.extract_name(col.name) + + if type == "MDL": + self.process_MDL(col, name, params) + elif type == "MAP": + self.process_MAP(col, name, params) + elif type == "VEH": + self.process_VEH(col, name, params) + + # skeletons + for obj in bpy.context.scene.collection.objects: + type, name, params = self.extract_name(obj.name) + + if type == "SK": + self.process_SK(obj, name, params) + + # animations + for action in bpy.data.actions: + type, name, params = self.extract_name(action.name) + + if type == "A": + self.process_A(action, name, params) + + # export skeletons & anims + for name, sk in self.skeletons.items(): + sk.anims.sort(key=lambda a: a.name) + for anim in sk.anims: + self.export_anim(anim, os.path.join(self.out_path, f"{name}_{anim.name}.anim")) + self.export_sk(sk, os.path.join(self.out_path, f"{name}.sk")) + + +Exporter().run() diff --git a/tools/fnt2font.py b/tools/fnt2font.py new file mode 100644 index 0000000..cd0efab --- /dev/null +++ b/tools/fnt2font.py @@ -0,0 +1,48 @@ +import xml.etree.ElementTree as ET +import sys +from pathlib import Path + +def convert_bmfont_xml(xml_path, output_path): + tree = ET.parse(xml_path) + root = tree.getroot() + + # --- Read font info --- + info = root.find("info") + common = root.find("common") + page = root.find("pages/page") + + font_size = info.attrib.get("size", "0") + tex_width = common.attrib.get("scaleW", "0") + tex_height = common.attrib.get("scaleH", "0") + texture_name = Path(page.attrib["file"]).stem + + lines = [] + lines.append(f"texture {texture_name} {tex_width} {tex_height}") + lines.append(f"size {font_size}") + + # --- Glyphs --- + chars = root.find("chars") + for ch in chars.findall("char"): + code = ch.attrib["id"] + x = ch.attrib["x"] + y = ch.attrib["y"] + w = ch.attrib["width"] + h = ch.attrib["height"] + xoff = ch.attrib["xoffset"] + yoff = ch.attrib["yoffset"] + xadv = ch.attrib["xadvance"] + + lines.append(f"c {code} {x} {y} {w} {h} {xoff} {yoff} {xadv}") + + # --- Write output --- + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + print(f"Written: {output_path}") + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python fnt2font.py input.fnt output.txt") + sys.exit(1) + + convert_bmfont_xml(sys.argv[1], sys.argv[2])