#include "character/CharacterAnimation.h" #include "character/CharacterObject.h" #include #include #include #include #include #include #include #include #include namespace frostbite2D { namespace { const std::array kAvatarParts = { "weapon_avatar", "aurora_avatar", "hair_avatar", "hat_avatar", "face_avatar", "breast_avatar", "coat_avatar", "skin_avatar", "waist_avatar", "pants_avatar", "shoes_avatar", "weapon"}; std::string truncatePath(const std::string& path) { size_t slashPos = path.find_last_of('/'); if (slashPos == std::string::npos) { return {}; } return path.substr(0, slashPos); } std::string actionTail(const std::string& path) { size_t slashPos = path.find_last_of('/'); if (slashPos == std::string::npos) { return path; } return path.substr(slashPos); } struct BoundsAccumulator { bool valid = false; float minX = 0.0f; float minY = 0.0f; float maxX = 0.0f; float maxY = 0.0f; void includeRect(const Rect& rect) { if (rect.empty()) { return; } if (!valid) { minX = rect.left(); minY = rect.top(); maxX = rect.right(); maxY = rect.bottom(); valid = true; return; } minX = std::min(minX, rect.left()); minY = std::min(minY, rect.top()); maxX = std::max(maxX, rect.right()); maxY = std::max(maxY, rect.bottom()); } }; Rect transformRectBounds(const Rect& rect, const Transform2D& transform) { Vec2 p0 = transform.transformPoint(Vec2(rect.left(), rect.top())); Vec2 p1 = transform.transformPoint(Vec2(rect.right(), rect.top())); Vec2 p2 = transform.transformPoint(Vec2(rect.left(), rect.bottom())); Vec2 p3 = transform.transformPoint(Vec2(rect.right(), rect.bottom())); float minX = std::min(std::min(p0.x, p1.x), std::min(p2.x, p3.x)); float minY = std::min(std::min(p0.y, p1.y), std::min(p2.y, p3.y)); float maxX = std::max(std::max(p0.x, p1.x), std::max(p2.x, p3.x)); float maxY = std::max(std::max(p0.y, p1.y), std::max(p2.y, p3.y)); return Rect(minX, minY, maxX - minX, maxY - minY); } void combineHash(uint64& seed, uint64 value) { constexpr uint64 kOffset = 0x9e3779b97f4a7c15ULL; seed ^= value + kOffset + (seed << 6) + (seed >> 2); } CharacterAnimation::CompositeFrameInfo combineCompositeFrameInfo( const CharacterAnimation::CompositeFrameInfo& lhs, const CharacterAnimation::CompositeFrameInfo& rhs) { if (!lhs.valid) { return rhs; } if (!rhs.valid) { return lhs; } float left = std::floor(std::min(lhs.localBounds.left(), rhs.localBounds.left())); float top = std::floor(std::min(lhs.localBounds.top(), rhs.localBounds.top())); float right = std::ceil(std::max(lhs.localBounds.right(), rhs.localBounds.right())); float bottom = std::ceil(std::max(lhs.localBounds.bottom(), rhs.localBounds.bottom())); CharacterAnimation::CompositeFrameInfo result; result.valid = true; result.localBounds = Rect(left, top, std::max(right - left, 1.0f), std::max(bottom - top, 1.0f)); result.width = std::max(static_cast(std::lround(result.localBounds.width())), 1); result.height = std::max(static_cast(std::lround(result.localBounds.height())), 1); result.originInTexture = Vec2(-result.localBounds.left(), -result.localBounds.top()); result.groundAnchorInTexture = result.originInTexture; return result; } } // namespace bool CharacterAnimation::Init(CharacterObject* parent, const character::CharacterConfig& config, const CharacterEquipmentManager& equipmentManager) { parent_ = parent; actionAnimations_.clear(); missingAnimationPaths_.clear(); skippedMissingAnimationCount_ = 0; currentActionTag_.clear(); direction_ = 1; actionFrameFlagCallback_ = nullptr; actionEndCallback_ = nullptr; compositeCanvas_ = MakePtr(); compositeFrameInfoByAction_.clear(); compositeDirty_ = true; compositeVersion_ = 0; lastCompositeSignature_ = 0; if (!compositeCanvas_->Init(1, 1)) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "CharacterAnimation: failed to initialize composite canvas"); compositeCanvas_.Reset(); } else { compositeCanvas_->SetCustomDrawCallback([this]() { RenderCurrentActionToCompositeCanvas(); }); } for (const auto& [actionName, actionPath] : config.animationPath) { for (const char* slotName : kAvatarParts) { CreateAnimationBySlot(actionName, slotName, actionPath, config, equipmentManager); } } if (actionAnimations_.empty()) { SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "CharacterAnimation: no usable action animations for job %d", config.jobId); return false; } if (skippedMissingAnimationCount_ > 0) { SDL_Log("CharacterAnimation: skipped %zu missing animation loads across %zu unique paths for job %d", skippedMissingAnimationCount_, missingAnimationPaths_.size(), config.jobId); } BuildCompositeActionFrames(); return true; } void CharacterAnimation::Update(float deltaTime) { Actor::Update(deltaTime); uint64 renderSignature = CaptureCurrentRenderSignature(); if (renderSignature != lastCompositeSignature_) { compositeDirty_ = true; } } void CharacterAnimation::Render() { if (compositeDirty_ && compositeCanvas_ && Renderer::get().isFrameActive()) { RefreshCompositeTextureIfNeeded(); } Actor::Render(); } std::string CharacterAnimation::FormatImgPath(std::string path, Animation::ReplaceData data) { size_t pos = path.find("%04d"); if (pos != std::string::npos) { path.replace(pos, 4, "%02d%02d"); } char buffer[512] = {}; std::snprintf(buffer, sizeof(buffer), path.c_str(), data.param1, data.param2); return std::string(buffer); } void CharacterAnimation::CreateAnimationBySlot( const std::string& actionName, const std::string& slotName, const std::string& actionPath, const character::CharacterConfig& config, const CharacterEquipmentManager& equipmentManager) { if (slotName == std::string("skin_avatar")) { if (shouldSkipMissingAnimation(actionPath)) { return; } Animation::ReplaceData replaceData(0, 0); if (const auto* equip = equipmentManager.GetEquip(slotName)) { auto it = equip->jobAnimations.find(config.jobId); if (it != equip->jobAnimations.end() && !it->second.empty()) { replaceData.param1 = it->second.front().imgFormat[0]; replaceData.param2 = it->second.front().imgFormat[1]; } } auto animation = MakePtr(actionPath, FormatImgPath, replaceData); if (!animation || !animation->IsUsable()) { return; } animation->SetVisible(false); AddChild(animation); actionAnimations_[actionName].push_back(animation); return; } const auto* equip = equipmentManager.GetEquip(slotName); if (!equip) { return; } auto jobAniIt = equip->jobAnimations.find(config.jobId); if (jobAniIt == equip->jobAnimations.end()) { return; } std::string equipDir = truncatePath(equip->path); std::string actionPathTail = actionTail(actionPath); for (const auto& variation : jobAniIt->second) { if (variation.animationGroup.empty()) { continue; } std::string aniPath = equipDir + "/" + variation.animationGroup + actionPathTail; if (shouldSkipMissingAnimation(aniPath)) { continue; } auto animation = MakePtr( aniPath, FormatImgPath, Animation::ReplaceData(variation.imgFormat[0], variation.imgFormat[1])); if (!animation || !animation->IsUsable()) { continue; } animation->SetVisible(false); animation->SetZOrder(variation.layer); AddChild(animation); actionAnimations_[actionName].push_back(animation); } } bool CharacterAnimation::shouldSkipMissingAnimation(const std::string& aniPath) { PvfArchive& pvf = PvfArchive::get(); std::string normalizedPath = pvf.normalizePath(aniPath); if (missingAnimationPaths_.find(normalizedPath) != missingAnimationPaths_.end()) { ++skippedMissingAnimationCount_; return true; } if (pvf.hasFile(normalizedPath)) { return false; } missingAnimationPaths_.insert(std::move(normalizedPath)); ++skippedMissingAnimationCount_; return true; } std::string CharacterAnimation::DescribeAvailableActions() const { if (actionAnimations_.empty()) { return ""; } std::ostringstream stream; bool first = true; for (const auto& [actionName, animations] : actionAnimations_) { (void)animations; if (!first) { stream << ", "; } stream << actionName; first = false; } return stream.str(); } Animation* CharacterAnimation::GetPrimaryAnimation(const std::string& actionName) const { auto it = actionAnimations_.find(actionName); if (it == actionAnimations_.end()) { return nullptr; } for (const auto& animation : it->second) { if (animation && animation->IsUsable()) { return animation.Get(); } } for (const auto& animation : it->second) { if (animation) { return animation.Get(); } } return nullptr; } Animation* CharacterAnimation::GetCurrentPrimaryAnimation() const { if (currentActionTag_.empty()) { return nullptr; } return GetPrimaryAnimation(currentActionTag_); } void CharacterAnimation::RefreshRuntimeCallbacks() { auto currentIt = actionAnimations_.find(currentActionTag_); if (currentIt == actionAnimations_.end()) { return; } Animation* primaryAnimation = GetPrimaryAnimation(currentActionTag_); for (auto& animation : currentIt->second) { if (!animation) { continue; } if (animation.Get() == primaryAnimation) { animation->changeFrameCallback_ = actionFrameFlagCallback_; animation->endCallback_ = actionEndCallback_; } else { animation->changeFrameCallback_ = nullptr; animation->endCallback_ = nullptr; } } } bool CharacterAnimation::SetAction(const std::string& actionName) { auto nextIt = actionAnimations_.find(actionName); if (nextIt == actionAnimations_.end()) { if (parent_) { parent_->ReportFatalCharacterError("CharacterAnimation::SetAction", "requested animation tag is not loaded", std::string(), actionName); } else { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "CharacterAnimation: action %s missing and parent is null", actionName.c_str()); } return false; } if (!currentActionTag_.empty()) { auto currentIt = actionAnimations_.find(currentActionTag_); if (currentIt != actionAnimations_.end()) { for (auto& animation : currentIt->second) { animation->Reset(); animation->SetVisible(false); } } } for (auto& animation : nextIt->second) { animation->Reset(); animation->SetVisible(true); } currentActionTag_ = actionName; RefreshRuntimeCallbacks(); SetDirection(direction_); MarkCompositeDirty(); return true; } void CharacterAnimation::SetDirection(int direction) { direction_ = direction >= 0 ? 1 : -1; auto currentIt = actionAnimations_.find(currentActionTag_); if (currentIt == actionAnimations_.end()) { return; } for (auto& animation : currentIt->second) { if (animation) { animation->SetDirection(direction_); } } MarkCompositeDirty(); } bool CharacterAnimation::IsCurrentActionFinished() const { const Animation* primaryAnimation = GetCurrentPrimaryAnimation(); if (!primaryAnimation) { return false; } return !primaryAnimation->IsUsable(); } int CharacterAnimation::GetCurrentFrameIndex() const { const Animation* primaryAnimation = GetCurrentPrimaryAnimation(); return primaryAnimation ? primaryAnimation->GetCurrentFrameIndex() : 0; } int CharacterAnimation::GetCurrentFrameCount() const { const Animation* primaryAnimation = GetCurrentPrimaryAnimation(); return primaryAnimation ? primaryAnimation->GetTotalFrameCount() : 0; } float CharacterAnimation::GetCurrentNormalizedProgress() const { int totalFrames = GetCurrentFrameCount(); if (totalFrames <= 1) { return IsCurrentActionFinished() ? 1.0f : 0.0f; } float progress = static_cast(GetCurrentFrameIndex()) / static_cast(totalFrames - 1); return std::clamp(progress, 0.0f, 1.0f); } animation::AniFrame CharacterAnimation::GetCurrentFrameInfo() const { const Animation* primaryAnimation = GetCurrentPrimaryAnimation(); if (!primaryAnimation) { return animation::AniFrame(); } return primaryAnimation->GetCurrentFrameInfo(); } void CharacterAnimation::SetActionFrameFlagCallback(ActionFrameFlagCallback callback) { actionFrameFlagCallback_ = std::move(callback); RefreshRuntimeCallbacks(); } void CharacterAnimation::SetActionEndCallback(ActionEndCallback callback) { actionEndCallback_ = std::move(callback); RefreshRuntimeCallbacks(); } bool CharacterAnimation::EnsureCompositeTextureReady() { RefreshCompositeTextureIfNeeded(); return HasCompositeTexture(); } bool CharacterAnimation::HasCompositeTexture() const { return compositeCanvas_ && compositeCanvas_->IsCanvasReady() && GetCurrentCompositeFrameInfo().valid; } Ptr CharacterAnimation::GetCompositeTexture() const { return compositeCanvas_ ? compositeCanvas_->GetOutputTexture() : nullptr; } Vec2 CharacterAnimation::GetCompositeTextureSize() const { CompositeFrameInfo info = GetCurrentCompositeFrameInfo(); return Vec2(static_cast(info.width), static_cast(info.height)); } Vec2 CharacterAnimation::GetCompositeOriginInTexture() const { return GetCurrentCompositeFrameInfo().originInTexture; } Vec2 CharacterAnimation::GetCompositeGroundAnchorInTexture() const { return GetCurrentCompositeFrameInfo().groundAnchorInTexture; } CharacterAnimation::CompositeFrameInfo CharacterAnimation::ComputeCompositeFrameInfo( const std::vector>& animations, int direction) const { CompositeFrameInfo info; BoundsAccumulator bounds; for (const auto& animation : animations) { if (!animation) { continue; } Rect animationBounds; if (!animation->GetStaticLocalBounds(direction, animationBounds)) { continue; } bounds.includeRect( transformRectBounds(animationBounds, animation->GetLocalTransform())); } if (!bounds.valid) { return info; } float left = std::floor(bounds.minX); float top = std::floor(bounds.minY); float right = std::ceil(bounds.maxX); float bottom = std::ceil(bounds.maxY); info.valid = true; info.localBounds = Rect(left, top, std::max(right - left, 1.0f), std::max(bottom - top, 1.0f)); info.width = std::max(static_cast(std::lround(info.localBounds.width())), 1); info.height = std::max(static_cast(std::lround(info.localBounds.height())), 1); info.originInTexture = Vec2(-info.localBounds.left(), -info.localBounds.top()); info.groundAnchorInTexture = info.originInTexture; return info; } void CharacterAnimation::BuildCompositeActionFrames() { compositeFrameInfoByAction_.clear(); for (const auto& [actionName, animations] : actionAnimations_) { CompositeFrameInfo rightInfo = ComputeCompositeFrameInfo(animations, 1); CompositeFrameInfo leftInfo = ComputeCompositeFrameInfo(animations, -1); compositeFrameInfoByAction_[actionName] = combineCompositeFrameInfo(rightInfo, leftInfo); } } CharacterAnimation::CompositeFrameInfo CharacterAnimation::GetCurrentCompositeFrameInfo() const { auto it = compositeFrameInfoByAction_.find(currentActionTag_); if (it == compositeFrameInfoByAction_.end()) { return CompositeFrameInfo(); } return it->second; } uint64 CharacterAnimation::CaptureCurrentRenderSignature() const { uint64 signature = 1469598103934665603ULL; combineHash(signature, static_cast(direction_ >= 0 ? 1 : 0)); combineHash(signature, static_cast(std::hash{}(currentActionTag_))); auto currentIt = actionAnimations_.find(currentActionTag_); if (currentIt == actionAnimations_.end()) { return signature; } for (const auto& animation : currentIt->second) { if (!animation) { continue; } combineHash(signature, animation->GetRenderSignature()); } return signature; } void CharacterAnimation::MarkCompositeDirty() { compositeDirty_ = true; if (compositeCanvas_) { compositeCanvas_->SetDirty(); } } void CharacterAnimation::UpdateCompositeCamera() { if (!compositeCanvas_) { return; } CompositeFrameInfo info = GetCurrentCompositeFrameInfo(); Camera& camera = compositeCanvas_->GetCanvasCamera(); camera.setViewport(std::max(info.width, 1), std::max(info.height, 1)); camera.setZoom(1.0f); Vec2 cameraOrigin = GetWorldTransform().transformPoint( Vec2(info.localBounds.left(), info.localBounds.top())); camera.setPosition(cameraOrigin); } void CharacterAnimation::RefreshCompositeTextureIfNeeded() { if (!compositeDirty_ || !compositeCanvas_) { return; } CompositeFrameInfo info = GetCurrentCompositeFrameInfo(); if (!info.valid) { compositeDirty_ = false; lastCompositeSignature_ = CaptureCurrentRenderSignature(); return; } if (!compositeCanvas_->SetCanvasSize(std::max(info.width, 1), std::max(info.height, 1))) { return; } UpdateCompositeCamera(); if (!compositeCanvas_->Redraw()) { return; } compositeDirty_ = false; lastCompositeSignature_ = CaptureCurrentRenderSignature(); ++compositeVersion_; } void CharacterAnimation::RenderCurrentActionToCompositeCanvas() { if (actionAnimations_.find(currentActionTag_) == actionAnimations_.end()) { return; } RenderChildren(); } } // namespace frostbite2D