#include "Character.h" #include "Animator.h" #include "CharacterMind.h" #include "Ragdoll.h" #include "Physics.h" #include "tFunctions.h" #include "Engine.h" #include #include #include #include // ------------------------------------------------------- // CharacterBody // ------------------------------------------------------- void CharacterBody::Perform(const CharacterAction& action) { switch (action.type) { case CharacterActionType::Move: { sprinting = action.sprint; if (IsDashing()) break; // the dash carries it, wherever it was going wishDirection = glm::vec3(action.direction.x, 0.0f, action.direction.z); if (glm::length(wishDirection) > 0.0001f) { wishDirection = glm::normalize(wishDirection); if (!combat.GetCurrentMove() && !combat.GetCurrentAbility() && !inAir) // mid swing it keeps facing where it swung, and in the air where it jumped turnDirection = wishDirection; } // Told where to look, it keeps looking there whichever way it walks const glm::vec3 facing(action.facing.x, 0.0f, action.facing.z); strafing = glm::length(facing) > 0.0001f; if (strafing) turnDirection = glm::normalize(facing); break; } case CharacterActionType::Jump: if (!IsDashing()) jumpWanted = true; break; case CharacterActionType::Dash: { // On the ground, between attacks and not too soon after the last one if (IsDashing() || dashCooldownLeft > 0.0f || inAir || windup >= 0.0f || !physics || combat.GetCurrentMove() || combat.GetCurrentAbility()) break; glm::vec3 direction(action.direction.x, 0.0f, action.direction.z); direction = glm::length(direction) > 0.0001f ? glm::normalize(direction) : GetForwardDirection(); dashDirection = direction; dashWindup = dashDelay * GetGameTimeScale(); dashLeft = dashTime * GetGameTimeScale(); Face(direction, true); PlayAction(dashAnimation); break; } case CharacterActionType::Ability: if (IsDashing() || !combat.IsAbilityReady(action.slot)) break; Face(action.direction, true); combat.UseAbility(action.slot, action.point); break; case CharacterActionType::Attack: if (IsDashing()) break; Face(action.direction, true); // round to it before the swing starts, so the step and hitbox go that way combat.QueueAttack(); break; } } void CharacterBody::CancelDash() { if (!IsDashing()) return; dashWindup = 0.0f; dashLeft = 0.0f; dashCooldownLeft = dashCooldown * GetGameTimeScale(); StopDashAnimation(); } void CharacterBody::StopDashAnimation() { if (actionAnimator && dashAnimation && actionAnimator->m_CurrentAnimation == dashAnimation && actionAnimator->isPlaying) actionAnimator->StopAnimation(); } void CharacterBody::ApplyMovement(bool grounded, float speedFactor, float dt, float referenceDt) { dashCooldownLeft = std::max(dashCooldownLeft - dt, 0.0f); // Dashing it goes where the dash sends it, fastest at the start and slowing to a stop, nothing else moves it if (IsDashing()) { wishDirection = glm::vec3(0.0f); jumpWanted = false; SetWalking(false); if (!physics || dt <= 0.0f) return; // Winding up it stands where it is if (dashWindup > 0.0f) { physics->linearvelocity.x = 0.0f; physics->linearvelocity.z = 0.0f; dashWindup -= dt; if (dashWindup > 0.0f) return; dt = -dashWindup; // whatever's left of this frame goes to the dash itself dashWindup = 0.0f; } const float length = dashTime * GetGameTimeScale(); const float speed = 2.0f * dashDistance / length * (dashLeft / length); // falls off in a straight line, covering dashDistance physics->linearvelocity.x = dashDirection.x * speed; physics->linearvelocity.z = dashDirection.z * speed; physics->WakeUp(); dashLeft -= dt; if (dashLeft <= 0.0f) { dashLeft = 0.0f; dashCooldownLeft = dashCooldown * GetGameTimeScale(); } return; } // An ability lunging it forward takes over, like a dash does. Once it's done, standing still brings it to a stop. float lungeSpeed = 0.0f; if (combat.GetAbilityLunge(lungeSpeed)) { // stunned, the ability is already cut off wishDirection = glm::vec3(0.0f); SetWalking(false); if (physics) { const glm::vec3 forward = GetForwardDirection(); physics->linearvelocity.x = forward.x * lungeSpeed; physics->linearvelocity.z = forward.z * lungeSpeed; physics->WakeUp(); } return; } const bool moving = glm::length(wishDirection) > 0.0f && speedFactor > 0.0f; SetWalking(moving); // The rest of the dash animation is only there for standing still, walking or jumping out of it cuts it off if (moving || jumpWanted) StopDashAnimation(); // Sprinting only when it's free to go full speed, not mid attack const float sprint = sprinting && speedFactor >= 1.0f ? sprintMultiplier : 1.0f; // Straight along the input whichever way it's facing, turning to face it as it goes const glm::vec3 walkDirection = wishDirection; wishDirection = glm::vec3(0.0f); // used up, the next Move action has to ask again const bool jump = jumpWanted; jumpWanted = false; // Just after taking off it's still close enough to the ground to count as on it, so it's in the air regardless takeoffTime = std::max(takeoffTime - dt, 0.0f); inAir = !grounded || takeoffTime > 0.0f; // Starting a jump, if it's on the ground and not in the middle of an attack. The animation starts now, // and it leaves the ground jumpDelay into it. if (jump && canJump && !inAir && windup < 0.0f && physics && !combat.GetCurrentMove() && !combat.GetCurrentAbility()) { windup = jumpDelay * GetGameTimeScale(); if (jumpAnimator && jumpAnimator->m_CurrentAnimation) jumpAnimator->PlayAnimation(); } if (windup >= 0.0f) { windup -= dt; if (windup < 0.0f && !inAir && physics) { physics->linearvelocity.y = jumpSpeed; physics->WakeUp(); inAir = true; takeoffTime = 0.2f; } } if (walkAnimator && moving) walkAnimator->playbackSpeed = speedFactor * statusSpeedMultiplier * (sprint > 1.0f ? sprintAnimationSpeed : 1.0f); // slower walking, slower steps // With time stopped there's nothing to apply, and the rescaling below would divide zero by zero if (!physics || dt <= 0.0f) return; const float movespeed = walkSpeed * speedFactor * sprint * statusSpeedMultiplier; const glm::vec3 inputVel = moving ? walkDirection * movespeed : glm::vec3(0.0f); const float accel = 40.0f; const float damp = 0.75f; if (grounded) { // Damping and acceleration worked out per reference step, then rescaled so frame rate doesn't change them const float dampStep = std::pow(damp, dt / referenceDt); const float accelScale = referenceDt * damp * (1.0f - dampStep) / (dt * dampStep * (1.0f - damp)); physics->linearvelocity.x *= dampStep; physics->linearvelocity.z *= dampStep; physics->linearvelocity.x += inputVel.x * accel * dt * accelScale; physics->linearvelocity.z += inputVel.z * accel * dt * accelScale; } // In the air it just carries on the way it was going when it left the ground } void CharacterBody::Step(float dt) { if (glm::length(turnDirection) > 0.0001f) { const glm::quat target = LookAt(-turnDirection); const glm::quat current = t.GetRotationQuaternion(); const float blend = 1.0f - std::exp(-turnSpeed * dt); t.RotateToQuaternion(glm::normalize(glm::slerp(current, target, blend))); } const float target = walking ? 1.0f : 0.0f; walkBlend += (target - walkBlend) * (1.0f - std::exp(-walkFadeSpeed * dt)); const float airTarget = (inAir || windup >= 0.0f) && jumpAnimator && jumpAnimator->isPlaying ? 1.0f : 0.0f; airBlend += (airTarget - airBlend) * (1.0f - std::exp(-airFadeSpeed * dt)); combat.Step(*this, dt); // Clips play in real time, the way they were animated, rather than on the sped up game clock const float animationDt = dt / GetGameTimeScale(); for (Animator* a : animators) a->Step(animationDt); // After stepping, since the animator ramps its own weight up to full when it plays if (walkAnimator) walkAnimator->blendWeight = walkBlend; if (jumpAnimator) { jumpAnimator->blendWeight = airBlend; if (!inAir && windup < 0.0f && airBlend < 0.01f) jumpAnimator->isPlaying = false; // landed and faded out, off until the next jump } if (rig && animator->m_CurrentAnimation) { blendedBones = animator->GetFinalBoneMatrices(); BlendBones(animator->m_CurrentAnimation->GetRootNode(), glm::mat4(1.0f)); } } // Blends two parent-relative transforms, position and scale linearly and rotation along the shortest arc static glm::mat4 BlendLocalTransforms(const glm::mat4& from, const glm::mat4& to, float t) { const glm::vec3 fromScale(glm::length(glm::vec3(from[0])), glm::length(glm::vec3(from[1])), glm::length(glm::vec3(from[2]))); const glm::vec3 toScale(glm::length(glm::vec3(to[0])), glm::length(glm::vec3(to[1])), glm::length(glm::vec3(to[2]))); const glm::mat3 fromRotation(glm::vec3(from[0]) / fromScale.x, glm::vec3(from[1]) / fromScale.y, glm::vec3(from[2]) / fromScale.z); const glm::mat3 toRotation(glm::vec3(to[0]) / toScale.x, glm::vec3(to[1]) / toScale.y, glm::vec3(to[2]) / toScale.z); const glm::quat rotation = glm::normalize(glm::slerp(glm::quat_cast(fromRotation), glm::quat_cast(toRotation), t)); glm::mat4 result = glm::mat4_cast(rotation) * glm::scale(glm::mat4(1.0f), glm::mix(fromScale, toScale, t)); result[3] = glm::vec4(glm::mix(glm::vec3(from[3]), glm::vec3(to[3]), t), 1.0f); return result; } // Every node's rest transform relative to the model, and its parent's name, from one clip's hierarchy static void CollectRestPose(const AssimpNodeData& node, const glm::mat4& parentTransform, const std::string& parentName, std::unordered_map& globals, std::unordered_map& parents) { const glm::mat4 global = parentTransform * node.transformation; globals[node.name] = global; parents[node.name] = parentName; for (const AssimpNodeData& child : node.children) CollectRestPose(child, global, node.name, globals, parents); } // A clip exported with only some bones, like an arm on its own, has them hung off whatever ancestor made it into // the file. Its tracks are then relative to that ancestor instead of the bone's real parent. Going through the rest // pose of both files turns one into the other, so the arm still rides on the torso. const std::unordered_map& CharacterBody::GetParentCorrections(const Animation* layer) { const Animation* base = animator->m_CurrentAnimation; const auto key = std::make_pair(base, layer); const auto cached = parentCorrections.find(key); if (cached != parentCorrections.end()) return cached->second; std::unordered_map& corrections = parentCorrections[key]; if (!base || !layer || base == layer) return corrections; std::unordered_map baseGlobals, layerGlobals; std::unordered_map baseParents, layerParents; CollectRestPose(base->GetRootNode(), glm::mat4(1.0f), "", baseGlobals, baseParents); CollectRestPose(layer->GetRootNode(), glm::mat4(1.0f), "", layerGlobals, layerParents); for (const std::string& bone : layer->m_BoneNames) { const auto baseParent = baseParents.find(bone); const auto layerParent = layerParents.find(bone); if (baseParent == baseParents.end() || layerParent == layerParents.end()) continue; if (baseParent->second == layerParent->second) continue; const auto baseParentGlobal = baseGlobals.find(baseParent->second); const auto layerParentGlobal = layerGlobals.find(layerParent->second); const glm::mat4 from = layerParentGlobal != layerGlobals.end() ? layerParentGlobal->second : glm::mat4(1.0f); const glm::mat4 to = baseParentGlobal != baseGlobals.end() ? baseParentGlobal->second : glm::mat4(1.0f); corrections[bone] = glm::inverse(to) * from; } return corrections; } // Walks the base animation's hierarchy. Each bone starts from its rest pose, then every layer with a track for it // blends in on top in weight order, before it's passed down to its children. void CharacterBody::BlendBones(const AssimpNodeData& node, const glm::mat4& parentTransform) { glm::mat4 local = node.transformation; for (Animator* a : animators) { if (!a->m_CurrentAnimation || a->blendWeight <= 0.0f) continue; if (a != animator && !a->isPlaying) continue; const auto& transforms = a->GetLocalTransforms(); const auto found = transforms.find(node.name); if (found == transforms.end()) continue; glm::mat4 layerLocal = found->second; if (a != animator) { const auto& corrections = GetParentCorrections(a->m_CurrentAnimation); const auto correction = corrections.find(node.name); if (correction != corrections.end()) layerLocal = correction->second * layerLocal; } local = a == animator || a->blendWeight >= 1.0f ? layerLocal : BlendLocalTransforms(local, layerLocal, a->blendWeight); } glm::mat4 global = parentTransform * local; if (node.name == "Head") global = glm::scale(global, glm::vec3(HeadScale)); const auto info = rig->m_BoneInfoMap.find(node.name); if (info != rig->m_BoneInfoMap.end()) { const int index = info->second.id; if (index >= 0 && index < (int)blendedBones.size()) blendedBones[index] = global * info->second.offset; } for (const AssimpNodeData& child : node.children) BlendBones(child, global); } void CharacterBody::SetWalking(bool isWalking) { walking = isWalking; } void CharacterBody::Face(const glm::vec3& direction, bool instantly) { const glm::vec3 flat(direction.x, 0.0f, direction.z); if (glm::length(flat) <= 0.0001f) return; turnDirection = glm::normalize(flat); if (instantly) t.RotateToQuaternion(LookAt(-turnDirection)); } glm::vec3 CharacterBody::GetForwardDirection() const { glm::vec3 forward = -t.GetFrontVector(); forward.y = 0.0f; const float length = glm::length(forward); if (length <= 0.0001f) return glm::vec3(0.0f, 0.0f, -1.0f); return forward / length; } // ------------------------------------------------------- // Character // ------------------------------------------------------- void Character::Think() { if (!mind || dead) return; pendingActions.clear(); mind->ProduceActions(*this, pendingActions); // still asked while stunned, so it keeps track of things if (!CanAct()) return; for (const CharacterAction& action : pendingActions) body->Perform(action); } int Character::GetCollisionGroup() { // Kept clear of the ragdoll's groups, which count up from 1 static int nextGroup = 1000; if (collisionGroup == 0) collisionGroup = nextGroup++; return collisionGroup; } void Character::CreatePhysicsBody(Physics& physics, float massKg) { if (body->physics) return; p* physicsBody = new p(body); physicsBody->mass = massKg; physicsBody->lockRotation = true; // it turns by facing where it walks, never by tipping over physicsBody->friction = 0.0f; // walking does its own stopping physicsBody->collisionGroup = GetCollisionGroup(); physics.AddObject(physicsBody); body->physics = physicsBody; world = &physics; } void Character::EnableRagdoll(Physics& physics) { if (!ragdoll) ragdoll = new Ragdoll(*body, GetRigMatrix(), physics); } void Character::Stun(float seconds) { if (dead || seconds <= 0.0f) return; const float length = seconds * GetGameTimeScale(); if (length <= stunLeft) return; stunLeft = length; body->combat.Reset(); body->CancelDash(); } void Character::AddStatus(StatusEffect::Type type, float seconds) { for (StatusEffect& status : statuses) { if (status.type != type) continue; if (status.timeLeft >= 0.0f && (seconds < 0.0f || seconds > status.timeLeft)) status.timeLeft = seconds; return; } statuses.push_back({ type, seconds }); } void Character::RemoveStatus(StatusEffect::Type type) { statuses.erase(std::remove_if(statuses.begin(), statuses.end(), [type](const StatusEffect& status) { return status.type == type; }), statuses.end()); } void Character::Knockdown(float seconds) { if (dead || ragdoll || seconds <= 0.0f) return; AddStatus(StatusEffect::Type::Knockdown, seconds); body->combat.Reset(); body->CancelDash(); body->SetWalking(false); body->PlayAction(body->knockdownAnimation, 1.0f, true); wasKnockedDown = true; } bool Character::CanAct() const { if (IsStunned()) return false; for (const StatusEffect& status : statuses) if (GetStatusEffectInfo(status.type).preventsActing) return false; return true; } bool Character::DodgesHits() const { for (const StatusEffect& status : statuses) if (GetStatusEffectInfo(status.type).dodgesHits) return true; return false; } bool Character::HasStatus(StatusEffect::Type type) const { for (const StatusEffect& status : statuses) if (status.type == type) return true; return false; } void Character::Step(float dt) { stunLeft = std::max(stunLeft - dt, 0.0f); const float realDt = dt / GetGameTimeScale(); for (StatusEffect& status : statuses) if (status.timeLeft >= 0.0f) status.timeLeft = std::max(status.timeLeft - realDt, 0.0f); statuses.erase(std::remove_if(statuses.begin(), statuses.end(), [](const StatusEffect& status) { return status.timeLeft == 0.0f; }), statuses.end()); // Back up, out of the pose it was lying in const bool knockedDown = IsKnockedDown(); if (wasKnockedDown && !knockedDown && body->actionAnimator && body->knockdownAnimation && body->actionAnimator->m_CurrentAnimation == body->knockdownAnimation) { body->actionAnimator->holdsAtEnd = false; body->actionAnimator->StopAnimation(); } wasKnockedDown = knockedDown; if (ragdoll) { ragdoll->Update(dt); return; } body->Step(dt); } void Character::ApplyMovement(bool grounded, float dt, float referenceDt) { // Swinging and abilities slow it down const AttackMove* attack = body->combat.GetCurrentMove(); const Ability* ability = body->combat.GetCurrentAbility(); const float speed = IsStunned() ? 0.0f : ability ? ability->moveSpeedMultiplier : attack ? attack->moveSpeedMultiplier : 1.0f; // Status effects all multiply together float statusSpeed = 1.0f, gravity = 1.0f; bool canJump = true; for (const StatusEffect& status : statuses) { const StatusEffectInfo& info = GetStatusEffectInfo(status.type); statusSpeed *= info.speedMultiplier; gravity *= info.gravityMultiplier; canJump = canJump && !info.preventsJumping; } body->statusSpeedMultiplier = statusSpeed; body->canJump = canJump; body->ApplyMovement(grounded, speed, dt, referenceDt); // Physics already pulls it down once, this is the rest if (!grounded && gravity != 1.0f && world && body->physics) body->physics->linearvelocity += world->GetGravity() * (gravity - 1.0f) * dt; } float Character::TakeDamage(float amount) { if (dead || amount <= 0.0f) return 0.0f; health = std::min(health, maxHealth); const float taken = std::min(amount, health); health -= taken; if (health <= 0.0f) Die(); return taken; } float Character::Heal(float amount) { if (dead || amount <= 0.0f) return 0.0f; const float before = health; health = std::min(health + amount, maxHealth); return std::max(health - before, 0.0f); } void Character::Flinch() { if (dead || ragdoll || !body->flinchAnimation || IsKnockedDown()) // on the floor it stays lying there return; // It shares the action layer with attacks, so a punch can't carry on underneath it body->combat.Reset(); body->CancelDash(); body->PlayAction(body->flinchAnimation); } void Character::Die() { if (dead) return; // Some just pick themselves straight back up, like training dummies if (healsOnDeath) { health = maxHealth; return; } // Stops where it is. Whatever it was doing is cut off, and it stops thinking until it's revived. dead = true; deaths++; health = 0.0f; body->SetWalking(false); body->combat.Reset(); body->CancelDash(); } void Character::Revive() { if (!dead) return; delete ragdoll; ragdoll = nullptr; dead = false; health = maxHealth; stunLeft = 0.0f; // Timed status effects are gone, ones that last forever stay statuses.erase(std::remove_if(statuses.begin(), statuses.end(), [](const StatusEffect& status) { return status.timeLeft >= 0.0f; }), statuses.end()); body->combat.Reset(); if (p* box = body->physics) { box->EnableCollision(); box->EnableVelocity(); box->linearvelocity = glm::vec3(0.0f); box->WakeUp(); } } bool Character::GetActionBoneTransform(const Animation* animation, const std::string& bone, glm::mat4& transform) const { const Animator* action = body ? body->actionAnimator : nullptr; if (ragdoll || !action || !action->isPlaying || !animation || action->m_CurrentAnimation != animation || !body->rig) return false; const auto info = body->rig->m_BoneInfoMap.find(bone); if (info == body->rig->m_BoneInfoMap.end()) return false; const std::vector& bones = action->GetFinalBoneMatrices(); const int index = info->second.id; if (index < 0 || index >= (int)bones.size()) return false; // The skinning matrix takes the bone's rest pose to where it is now, so undoing the rest pose leaves where it is transform = GetRigMatrix() * bones[index] * glm::inverse(info->second.offset); return true; } bool Character::GetBoneTransform(const std::string& bone, glm::mat4& transform) const { if (ragdoll || !body || !body->rig) return false; const auto info = body->rig->m_BoneInfoMap.find(bone); if (info == body->rig->m_BoneInfoMap.end()) return false; const std::vector& bones = body->GetBlendedBoneMatrices(); const int index = info->second.id; if (index < 0 || index >= (int)bones.size()) return false; transform = GetRigMatrix() * bones[index] * glm::inverse(info->second.offset); return true; } glm::mat4 Character::GetRigMatrix() const { // body->t's scale is the hitbox only; the rig is drawn at visualScale so it fills that hitbox return body->t.GetTranslationMatrix() * body->t.GetRotationMatrix() * glm::scale(glm::mat4(1.0f), glm::vec3(visualScale)); } bool Character::GetWorldBounds(BoundingBox& bounds) const { if (!body) return false; if (ragdoll && !ragdoll->GetParts().empty()) { bounds.min = glm::vec3(FLT_MAX); bounds.max = glm::vec3(-FLT_MAX); for (const Ragdoll::Part* part : ragdoll->GetParts()) { const BoundingBox partBounds = part->transform.t.GetAABB(); bounds.min = glm::min(bounds.min, partBounds.min); bounds.max = glm::max(bounds.max, partBounds.max); } return true; } // The rig is about 2 units across natively in every direction it reaches, arms out included. // A cube that size around the body covers any pose and any turn. const glm::vec3 reach(visualScale); bounds.min = body->t.GetTranslation() - reach; bounds.max = body->t.GetTranslation() + reach; return true; } void Character::ApplyTemplate(const CharacterTemplate& from) { characterTemplate = &from; if (from.rig) body->SetRig(from.rig); if (from.idleAnimation) { body->SetAnimation(from.idleAnimation); body->animator->loops = true; } if (from.walkAnimation) body->SetWalkAnimation(from.walkAnimation); body->jumpDelay = from.jumpDelay; if (from.jumpAnimation) body->SetJumpAnimation(from.jumpAnimation); else if (body->jumpAnimator) body->jumpAnimator->ClearAnimation(); // jumps without one body->flinchAnimation = from.flinchAnimation; body->knockdownAnimation = from.knockdownAnimation; body->combat.Reset(); body->combat.combo = from.combo; body->combat.comboName = from.comboName; body->combat.abilities = from.abilities; healsOnDeath = from.healsOnDeath; body->walkSpeed = from.walkSpeed; body->sprintMultiplier = from.sprintMultiplier; body->dashAnimation = from.dashAnimation; body->dashDistance = from.dashDistance; body->dashDelay = from.dashDelay; body->dashTime = from.dashTime; body->dashCooldown = from.dashCooldown; maxHealth = from.maxHealth; if (!dead) health = maxHealth; } Character::Character() { body = new CharacterBody(); mind = nullptr; shadertype = ShaderType::RigShader; } void Character::Render(Shader& shader) { if (!body || !body->rig || !body->animator) return; const std::vector transforms = ragdoll ? ragdoll->GetBoneMatrices() : body->animator->m_CurrentAnimation ? body->GetBlendedBoneMatrices() : std::vector(100, glm::mat4(1.0f)); shader.SetMat4Array("finalBonesMatrices", transforms.data(), glm::min((int)transforms.size(), 100)); shader.Set4F("colorOverride", colorOverride); surface.Apply(shader, 1, glm::vec3(visualScale)); shader.Set1F("metallic", metallic); shader.Set1F("roughness", roughness); shader.Set1F("ao", ao); body->rig->Render(shader, ragdoll ? glm::mat4(1.0f) : GetRigMatrix()); // ragdoll bones are already in world space shader.Set4F("colorOverride", glm::vec4(0.0f)); // don't leak into other rig draws }