Browse Source

Enhance archer hold mode animation with proper kneeling pose

djeada 1 month ago
parent
commit
ec4abe8b21

+ 50 - 3
app/controllers/command_controller.cpp

@@ -78,14 +78,23 @@ CommandResult CommandController::onStopCommand() {
     if (!entity)
     if (!entity)
       continue;
       continue;
 
 
+    // STOP clears ALL modes and actions
     resetMovement(entity);
     resetMovement(entity);
-
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
 
 
+    // Clear patrol mode
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
       patrol->patrolling = false;
       patrol->patrolling = false;
       patrol->waypoints.clear();
       patrol->waypoints.clear();
     }
     }
+
+    // Clear hold mode (archers stand up immediately)
+    auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+    if (holdMode && holdMode->active) {
+      holdMode->active = false;
+      holdMode->exitCooldown = holdMode->standUpDuration;  // Start stand-up transition
+      emit holdModeChanged(false);
+    }
   }
   }
 
 
   result.inputConsumed = true;
   result.inputConsumed = true;
@@ -108,20 +117,38 @@ CommandResult CommandController::onHoldCommand() {
     if (!entity)
     if (!entity)
       continue;
       continue;
 
 
-    resetMovement(entity);
+    // HOLD MODE ONLY FOR ARCHERS (ranged units)
+    auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
+    if (!unit || unit->unitType != "archer")
+      continue;
 
 
+    auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+    
+    // TOGGLE: If already in hold mode, exit it
+    if (holdMode && holdMode->active) {
+      holdMode->active = false;
+      holdMode->exitCooldown = holdMode->standUpDuration;
+      emit holdModeChanged(false);
+      continue;  // Don't clear other actions when toggling off
+    }
+
+    // ENTER HOLD MODE: Clear all other modes and actions
+    resetMovement(entity);
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
 
 
+    // Clear patrol mode
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
       patrol->patrolling = false;
       patrol->patrolling = false;
       patrol->waypoints.clear();
       patrol->waypoints.clear();
     }
     }
 
 
-    auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+    // Activate hold mode
     if (!holdMode) {
     if (!holdMode) {
       holdMode = entity->addComponent<Engine::Core::HoldModeComponent>();
       holdMode = entity->addComponent<Engine::Core::HoldModeComponent>();
     }
     }
     holdMode->active = true;
     holdMode->active = true;
+    holdMode->exitCooldown = 0.0f;
+    emit holdModeChanged(true);
 
 
     auto *movement = entity->getComponent<Engine::Core::MovementComponent>();
     auto *movement = entity->getComponent<Engine::Core::MovementComponent>();
     if (movement) {
     if (movement) {
@@ -258,4 +285,24 @@ void CommandController::resetMovement(Engine::Core::Entity *entity) {
   App::Utils::resetMovement(entity);
   App::Utils::resetMovement(entity);
 }
 }
 
 
+bool CommandController::anySelectedInHoldMode() const {
+  if (!m_selectionSystem || !m_world) {
+    return false;
+  }
+
+  const auto &selected = m_selectionSystem->getSelectedUnits();
+  for (Engine::Core::EntityID entityId : selected) {
+    Engine::Core::Entity *entity = m_world->getEntity(entityId);
+    if (!entity)
+      continue;
+
+    auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+    if (holdMode && holdMode->active) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
 } // namespace App::Controllers
 } // namespace App::Controllers

+ 3 - 0
app/controllers/command_controller.h

@@ -48,9 +48,12 @@ public:
   QVector3D getPatrolFirstWaypoint() const { return m_patrolFirstWaypoint; }
   QVector3D getPatrolFirstWaypoint() const { return m_patrolFirstWaypoint; }
   void clearPatrolFirstWaypoint() { m_hasPatrolFirstWaypoint = false; }
   void clearPatrolFirstWaypoint() { m_hasPatrolFirstWaypoint = false; }
 
 
+  Q_INVOKABLE bool anySelectedInHoldMode() const;
+
 signals:
 signals:
   void attackTargetSelected();
   void attackTargetSelected();
   void troopLimitReached();
   void troopLimitReached();
+  void holdModeChanged(bool active);
 
 
 private:
 private:
   Engine::Core::World *m_world;
   Engine::Core::World *m_world;

+ 2 - 2
game/core/component.h

@@ -203,11 +203,11 @@ public:
 class HoldModeComponent : public Component {
 class HoldModeComponent : public Component {
 public:
 public:
   HoldModeComponent()
   HoldModeComponent()
-      : active(true), exitCooldown(0.0f), standUpDuration(0.8f) {}
+      : active(true), exitCooldown(0.0f), standUpDuration(2.0f) {}
 
 
   bool active;
   bool active;
   float exitCooldown;
   float exitCooldown;
-  float standUpDuration;
+  float standUpDuration;  // Time it takes to stand up from kneeling (seconds)
 };
 };
 
 
 } // namespace Engine::Core
 } // namespace Engine::Core

+ 13 - 2
game/systems/command_service.cpp

@@ -114,8 +114,10 @@ void CommandService::moveUnits(Engine::Core::World &world,
       continue;
       continue;
 
 
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
-    if (holdMode) {
+    if (holdMode && holdMode->active) {
+      // Only start stand-up transition if unit was actually in hold mode
       holdMode->active = false;
       holdMode->active = false;
+      holdMode->exitCooldown = holdMode->standUpDuration;
     }
     }
 
 
     auto *atk = e->getComponent<Engine::Core::AttackComponent>();
     auto *atk = e->getComponent<Engine::Core::AttackComponent>();
@@ -321,6 +323,13 @@ void CommandService::moveGroup(Engine::Core::World &world,
     if (!entity)
     if (!entity)
       continue;
       continue;
 
 
+    // Handle hold mode exit for archers in group moves
+    auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+    if (holdMode && holdMode->active) {
+      holdMode->active = false;
+      holdMode->exitCooldown = holdMode->standUpDuration;
+    }
+
     auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
     auto *transform = entity->getComponent<Engine::Core::TransformComponent>();
     if (!transform)
     if (!transform)
       continue;
       continue;
@@ -682,8 +691,10 @@ void CommandService::attackTarget(
       continue;
       continue;
 
 
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
-    if (holdMode) {
+    if (holdMode && holdMode->active) {
+      // Only start stand-up transition if unit was actually in hold mode
       holdMode->active = false;
       holdMode->active = false;
+      holdMode->exitCooldown = holdMode->standUpDuration;
     }
     }
 
 
     auto *attackTarget = e->getComponent<Engine::Core::AttackTargetComponent>();
     auto *attackTarget = e->getComponent<Engine::Core::AttackTargetComponent>();

+ 26 - 5
game/systems/movement_system.cpp

@@ -106,6 +106,7 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
   }
   }
 
 
   auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
   auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
+  bool inHoldMode = false;
   if (holdMode) {
   if (holdMode) {
     if (holdMode->exitCooldown > 0.0f) {
     if (holdMode->exitCooldown > 0.0f) {
       holdMode->exitCooldown =
       holdMode->exitCooldown =
@@ -118,19 +119,39 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
       movement->vz = 0.0f;
       movement->vz = 0.0f;
       movement->path.clear();
       movement->path.clear();
       movement->pathPending = false;
       movement->pathPending = false;
-      return;
+      inHoldMode = true;
     }
     }
 
 
-    if (holdMode->exitCooldown > 0.0f) {
-      movement->hasTarget = false;
+    // During stand-up transition (exitCooldown > 0), block movement execution
+    // but DON'T clear movement targets - unit remembers where to go
+    if (holdMode->exitCooldown > 0.0f && !inHoldMode) {
       movement->vx = 0.0f;
       movement->vx = 0.0f;
       movement->vz = 0.0f;
       movement->vz = 0.0f;
-      movement->path.clear();
-      movement->pathPending = false;
+      // Keep hasTarget, path, and pathPending intact!
+      // Unit will start moving once exitCooldown reaches 0
       return;
       return;
     }
     }
   }
   }
 
 
+  if (inHoldMode) {
+    if (!entity->hasComponent<Engine::Core::BuildingComponent>()) {
+      if (transform->hasDesiredYaw) {
+        float current = transform->rotation.y;
+        float targetYaw = transform->desiredYaw;
+        float diff = std::fmod((targetYaw - current + 540.0f), 360.0f) - 180.0f;
+        float turnSpeed = 180.0f;
+        float step =
+            std::clamp(diff, -turnSpeed * deltaTime, turnSpeed * deltaTime);
+        transform->rotation.y = current + step;
+
+        if (std::fabs(diff) < 0.5f) {
+          transform->hasDesiredYaw = false;
+        }
+      }
+    }
+    return;
+  }
+
   auto *atk = entity->getComponent<Engine::Core::AttackComponent>();
   auto *atk = entity->getComponent<Engine::Core::AttackComponent>();
   if (atk && atk->inMeleeLock) {
   if (atk && atk->inMeleeLock) {
 
 

+ 117 - 26
render/entity/archer_renderer.cpp

@@ -73,21 +73,84 @@ public:
 
 
     float bowX = 0.0f;
     float bowX = 0.0f;
 
 
-    if (anim.isInHoldMode || anim.holdExitProgress > 0.0f) {
+    // Apply kneeling pose if in hold mode OR transitioning out
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      // t: 1.0 = fully kneeling, 0.0 = fully standing
       float t = anim.isInHoldMode ? 1.0f : (1.0f - anim.holdExitProgress);
       float t = anim.isInHoldMode ? 1.0f : (1.0f - anim.holdExitProgress);
 
 
-      float targetFootOffset = -0.25f * t;
-      pose.footYOffset = targetFootOffset;
-      pose.footL.setY(HP::GROUND_Y + pose.footYOffset);
-      pose.footR.setY(HP::GROUND_Y + pose.footYOffset);
-
-      pose.shoulderL.setY(pose.shoulderL.y() - 0.15f * t);
-      pose.shoulderR.setY(pose.shoulderR.y() - 0.15f * t);
-      pose.headPos.setY(pose.headPos.y() - 0.10f * t);
-      pose.neckBase.setY(pose.neckBase.y() - 0.12f * t);
-
-      QVector3D holdHandL(bowX - 0.12f, HP::SHOULDER_Y + 0.20f, 0.45f);
-      QVector3D holdHandR(bowX + 0.08f, HP::SHOULDER_Y + 0.50f, 0.15f);
+      // LEG-DRIVEN KNEEL: Sniper-style stance
+      // - Narrow stance (legs close together, not spread)
+      // - Left knee on ground, shin horizontal back
+      // - Right leg forms L-shape: thigh down, shin back to planted foot
+      
+      float kneelDepth = 0.45f * t;  // How much character lowers
+      
+      // PELVIS lowers via leg bending
+      float pelvisY = HP::WAIST_Y - kneelDepth;
+      pose.pelvisPos.setY(pelvisY);
+      
+      // SNIPER STANCE: Narrow stance, legs close to body centerline
+      float stanceNarrow = 0.12f;  // Narrow stance
+      
+      // LEFT LEG: Knee on ground, shin ~parallel to ground, foot back
+      // Upper leg: pelvis → knee (should be ~0.35 units)
+      // Lower leg: knee → foot (should be ~0.35 units)
+      float leftKneeY = HP::GROUND_Y + 0.08f * t;  // Knee on/near ground
+      float leftKneeZ = -0.05f * t;  // Knee at body center
+      
+      pose.kneeL = QVector3D(-stanceNarrow, leftKneeY, leftKneeZ);
+      
+      // Foot behind knee - shin horizontal/slightly angled back
+      pose.footL = QVector3D(
+        -stanceNarrow - 0.03f,  // Foot slightly outward
+        HP::GROUND_Y,
+        leftKneeZ - HP::LOWER_LEG_LEN * 0.95f * t  // Full shin length back
+      );
+      
+      // RIGHT LEG: SNIPER L-SHAPE - ROTATED 90°
+      // Thigh HORIZONTAL (parallel to ground, extending forward from pelvis)
+      // Calf VERTICAL (drops down from knee to foot on ground)
+      // 
+      // Pelvis is at (0, pelvisY, ~0)
+      // Knee should be FORWARD from pelvis at roughly same Y height
+      // Foot on ground below knee
+      
+      float rightFootZ = 0.30f * t;  // Foot forward for stability
+      pose.footR = QVector3D(
+        stanceNarrow,  // Narrow stance
+        HP::GROUND_Y + pose.footYOffset,
+        rightFootZ  // Foot forward on ground
+      );
+      
+      // Knee: forward from pelvis, at roughly pelvis height (thigh horizontal!)
+      // The thigh extends forward in +Z direction, not down in -Y
+      float rightKneeY = pelvisY - 0.10f;  // Knee slightly below pelvis (not hanging down!)
+      float rightKneeZ = rightFootZ - 0.05f;  // Knee directly above/behind foot
+      
+      pose.kneeR = QVector3D(
+        stanceNarrow,
+        rightKneeY,
+        rightKneeZ  // Knee above foot, creating vertical calf
+      );
+
+      // RIGID UPPER BODY DROP: Entire upper rig translates down (NO COMPRESSION!)
+      float upperBodyDrop = kneelDepth;
+      
+      pose.shoulderL.setY(HP::SHOULDER_Y - upperBodyDrop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upperBodyDrop);
+      pose.neckBase.setY(HP::NECK_BASE_Y - upperBodyDrop);
+      pose.headPos.setY((HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5f - upperBodyDrop);
+
+      // Slight forward lean for archer stance
+      float forwardLean = 0.10f * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forwardLean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forwardLean);
+      pose.neckBase.setZ(pose.neckBase.z() + forwardLean * 0.8f);
+      pose.headPos.setZ(pose.headPos.z() + forwardLean * 0.7f);
+
+      // Hand positions for holding bow raised (sky-facing stance)
+      QVector3D holdHandL(bowX - 0.15f, pose.shoulderL.y() + 0.30f, 0.55f);
+      QVector3D holdHandR(bowX + 0.12f, pose.shoulderR.y() + 0.15f, 0.10f);
       QVector3D normalHandL(bowX - 0.05f + armAsymmetry,
       QVector3D normalHandL(bowX - 0.05f + armAsymmetry,
                             HP::SHOULDER_Y + 0.05f + armHeightJitter, 0.55f);
                             HP::SHOULDER_Y + 0.05f + armHeightJitter, 0.55f);
       QVector3D normalHandR(0.15f - armAsymmetry * 0.5f,
       QVector3D normalHandR(0.15f - armAsymmetry * 0.5f,
@@ -232,7 +295,7 @@ public:
       }
       }
     }
     }
 
 
-    drawQuiver(ctx, v, extras, seed, out);
+    drawQuiver(ctx, v, pose, extras, seed, out);
 
 
     float attackPhase = 0.0f;
     float attackPhase = 0.0f;
     if (anim.isAttacking && !anim.isMelee) {
     if (anim.isAttacking && !anim.isMelee) {
@@ -329,9 +392,13 @@ public:
     QVector3D mailColor = v.palette.metal * QVector3D(0.85f, 0.87f, 0.92f);
     QVector3D mailColor = v.palette.metal * QVector3D(0.85f, 0.87f, 0.92f);
     QVector3D leatherTrim = v.palette.leatherDark * 0.90f;
     QVector3D leatherTrim = v.palette.leatherDark * 0.90f;
 
 
+    // Use pose.pelvisPos.y() instead of hardcoded HP::WAIST_Y
+    // so armor follows the body when kneeling
+    float waistY = pose.pelvisPos.y();
+    
     QVector3D mailTop(0, yTopCover + 0.01f, 0);
     QVector3D mailTop(0, yTopCover + 0.01f, 0);
-    QVector3D mailMid(0, (yTopCover + HP::WAIST_Y) * 0.5f, 0);
-    QVector3D mailBot(0, HP::WAIST_Y + 0.08f, 0);
+    QVector3D mailMid(0, (yTopCover + waistY) * 0.5f, 0);
+    QVector3D mailBot(0, waistY + 0.08f, 0);
     float rTop = torsoR * 1.10f;
     float rTop = torsoR * 1.10f;
     float rMid = torsoR * 1.08f;
     float rMid = torsoR * 1.08f;
 
 
@@ -389,15 +456,16 @@ public:
     drawManica(pose.shoulderL, pose.elbowL);
     drawManica(pose.shoulderL, pose.elbowL);
     drawManica(pose.shoulderR, pose.elbowR);
     drawManica(pose.shoulderR, pose.elbowR);
 
 
-    QVector3D beltTop(0, HP::WAIST_Y + 0.06f, 0);
-    QVector3D beltBot(0, HP::WAIST_Y - 0.02f, 0);
+    // Belt follows pelvis position (not hardcoded WAIST_Y)
+    QVector3D beltTop(0, waistY + 0.06f, 0);
+    QVector3D beltBot(0, waistY - 0.02f, 0);
     float beltR = torsoR * 1.12f;
     float beltR = torsoR * 1.12f;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, beltTop, beltBot, beltR), leatherTrim,
              cylinderBetween(ctx.model, beltTop, beltBot, beltR), leatherTrim,
              nullptr, 1.0f);
              nullptr, 1.0f);
 
 
     QVector3D brassColor = v.palette.metal * QVector3D(1.2f, 1.0f, 0.65f);
     QVector3D brassColor = v.palette.metal * QVector3D(1.2f, 1.0f, 0.65f);
-    ring(QVector3D(0, HP::WAIST_Y + 0.02f, 0), beltR * 1.02f, 0.010f,
+    ring(QVector3D(0, waistY + 0.02f, 0), beltR * 1.02f, 0.010f,
          brassColor);
          brassColor);
 
 
     auto drawPteruge = [&](float angle, float yStart, float length) {
     auto drawPteruge = [&](float angle, float yStart, float length) {
@@ -416,7 +484,8 @@ public:
       drawPteruge(angle, shoulderPterugeY, 0.14f);
       drawPteruge(angle, shoulderPterugeY, 0.14f);
     }
     }
 
 
-    float waistPterugeY = HP::WAIST_Y - 0.04f;
+    // Waist pteruges follow pelvis position
+    float waistPterugeY = waistY - 0.04f;
     for (int i = 0; i < 10; ++i) {
     for (int i = 0; i < 10; ++i) {
       float angle = (i / 10.0f) * 2.0f * 3.14159265f;
       float angle = (i / 10.0f) * 2.0f * 3.14159265f;
       drawPteruge(angle, waistPterugeY, 0.18f);
       drawPteruge(angle, waistPterugeY, 0.18f);
@@ -466,12 +535,17 @@ public:
 
 
 private:
 private:
   static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
   static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose,
                          const ArcherExtras &extras, uint32_t seed,
                          const ArcherExtras &extras, uint32_t seed,
                          ISubmitter &out) {
                          ISubmitter &out) {
     using HP = HumanProportions;
     using HP = HumanProportions;
 
 
-    QVector3D qTop(-0.08f, HP::SHOULDER_Y + 0.10f, -0.25f);
-    QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
+    // SPINE-ANCHORED QUIVER: Position relative to shoulder midpoint (spine)
+    // with stable left/back offset that doesn't drift in hold mode
+    QVector3D spineMid = (pose.shoulderL + pose.shoulderR) * 0.5f;
+    QVector3D quiverOffset(-0.08f, 0.10f, -0.25f);  // Stable left/back offset
+    QVector3D qTop = spineMid + quiverOffset;
+    QVector3D qBase = qTop + QVector3D(-0.02f, -0.30f, 0.03f);
 
 
     float quiverR = HP::HEAD_RADIUS * 0.45f;
     float quiverR = HP::HEAD_RADIUS * 0.45f;
     out.mesh(getUnitCylinder(),
     out.mesh(getUnitCylinder(),
@@ -504,13 +578,17 @@ private:
     const QVector3D forward(0.0f, 0.0f, 1.0f);
     const QVector3D forward(0.0f, 0.0f, 1.0f);
 
 
     QVector3D grip = pose.handL;
     QVector3D grip = pose.handL;
-    QVector3D topEnd(extras.bowX, extras.bowTopY, grip.z());
-    QVector3D botEnd(extras.bowX, extras.bowBotY, grip.z());
+    
+    // BOW STAYS IN CONSISTENT VERTICAL PLANE
+    // Ends are vertically aligned at fixed Z (slightly forward from body center)
+    float bowPlaneZ = 0.45f;  // Fixed Z position - bow always faces forward
+    QVector3D topEnd(extras.bowX, extras.bowTopY, bowPlaneZ);
+    QVector3D botEnd(extras.bowX, extras.bowBotY, bowPlaneZ);
 
 
     QVector3D nock(
     QVector3D nock(
         extras.bowX,
         extras.bowX,
         clampf(pose.handR.y(), extras.bowBotY + 0.05f, extras.bowTopY - 0.05f),
         clampf(pose.handR.y(), extras.bowBotY + 0.05f, extras.bowTopY - 0.05f),
-        clampf(pose.handR.z(), grip.z() - 0.30f, grip.z() + 0.30f));
+        clampf(pose.handR.z(), bowPlaneZ - 0.30f, bowPlaneZ + 0.30f));
 
 
     const int segs = 22;
     const int segs = 22;
     auto qBezier = [](const QVector3D &a, const QVector3D &c,
     auto qBezier = [](const QVector3D &a, const QVector3D &c,
@@ -518,7 +596,20 @@ private:
       float u = 1.0f - t;
       float u = 1.0f - t;
       return u * u * a + 2.0f * u * t * c + t * t * b;
       return u * u * a + 2.0f * u * t * c + t * t * b;
     };
     };
-    QVector3D ctrl = nock + forward * extras.bowDepth;
+    
+    // SKY-FACING BOW CURVE: Move control point toward +Y (upward) AND slightly +Z (depth)
+    // The bow curves upward by pushing the control point HIGH above the midpoint
+    // Also maintain some forward depth for realistic bow shape
+    float bowMidY = (topEnd.y() + botEnd.y()) * 0.5f;
+    
+    // Push control point SIGNIFICANTLY upward to create sky-facing arc
+    // The higher ctrlY, the more the bow points up toward sky
+    float ctrlY = bowMidY + 0.45f;  // Strong upward bias for sky-facing
+    
+    // Control point: high in Y (sky), modest forward in Z (depth)
+    // Prioritize upward over forward
+    QVector3D ctrl(extras.bowX, ctrlY, bowPlaneZ + extras.bowDepth * 0.6f);
+    
     QVector3D prev = botEnd;
     QVector3D prev = botEnd;
     for (int i = 1; i <= segs; ++i) {
     for (int i = 1; i <= segs; ++i) {
       float t = float(i) / float(segs);
       float t = float(i) / float(segs);

+ 35 - 15
render/humanoid_base.cpp

@@ -86,6 +86,7 @@ AnimationInputs HumanoidRendererBase::sampleAnimState(const DrawContext &ctx) {
   anim.isAttacking = false;
   anim.isAttacking = false;
   anim.isMelee = false;
   anim.isMelee = false;
   anim.isInHoldMode = false;
   anim.isInHoldMode = false;
+  anim.isExitingHold = false;
   anim.holdExitProgress = 0.0f;
   anim.holdExitProgress = 0.0f;
 
 
   if (!ctx.entity)
   if (!ctx.entity)
@@ -101,6 +102,7 @@ AnimationInputs HumanoidRendererBase::sampleAnimState(const DrawContext &ctx) {
 
 
   anim.isInHoldMode = (holdMode && holdMode->active);
   anim.isInHoldMode = (holdMode && holdMode->active);
   if (holdMode && !holdMode->active && holdMode->exitCooldown > 0.0f) {
   if (holdMode && !holdMode->active && holdMode->exitCooldown > 0.0f) {
+    anim.isExitingHold = true;
     anim.holdExitProgress =
     anim.holdExitProgress =
         1.0f - (holdMode->exitCooldown / holdMode->standUpDuration);
         1.0f - (holdMode->exitCooldown / holdMode->standUpDuration);
   }
   }
@@ -174,6 +176,13 @@ void HumanoidRendererBase::computeLocomotionPose(
   pose.footR = QVector3D(HP::SHOULDER_WIDTH * 0.58f * sWidth,
   pose.footR = QVector3D(HP::SHOULDER_WIDTH * 0.58f * sWidth,
                          HP::GROUND_Y + pose.footYOffset, 0.0f);
                          HP::GROUND_Y + pose.footYOffset, 0.0f);
 
 
+  // Initialize pelvis at waist height by default
+  pose.pelvisPos = QVector3D(0.0f, HP::WAIST_Y * hScale, 0.0f);
+
+  // Initialize knees at default standing position
+  pose.kneeL = QVector3D(pose.footL.x(), HP::KNEE_Y * hScale, pose.footL.z());
+  pose.kneeR = QVector3D(pose.footR.x(), HP::KNEE_Y * hScale, pose.footR.z());
+
   pose.shoulderL.setY(pose.shoulderL.y() + variation.shoulderTilt);
   pose.shoulderL.setY(pose.shoulderL.y() + variation.shoulderTilt);
   pose.shoulderR.setY(pose.shoulderR.y() - variation.shoulderTilt);
   pose.shoulderR.setY(pose.shoulderR.y() - variation.shoulderTilt);
 
 
@@ -271,7 +280,8 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   const float yTopCover = std::max(yShoulder + 0.04f, yNeck + 0.00f);
   const float yTopCover = std::max(yShoulder + 0.04f, yNeck + 0.00f);
 
 
   QVector3D tunicTop{0.0f, yTopCover - 0.006f, 0.0f};
   QVector3D tunicTop{0.0f, yTopCover - 0.006f, 0.0f};
-  QVector3D tunicBot{0.0f, HP::WAIST_Y + 0.03f, 0.0f};
+  // Use pelvisPos instead of hardcoded WAIST_Y so torso follows when kneeling
+  QVector3D tunicBot{0.0f, pose.pelvisPos.y() + 0.03f, 0.0f};
   out.mesh(getUnitTorso(),
   out.mesh(getUnitTorso(),
            cylinderBetween(ctx.model, tunicTop, tunicBot, torsoR),
            cylinderBetween(ctx.model, tunicTop, tunicBot, torsoR),
            v.palette.cloth, nullptr, 1.0f);
            v.palette.cloth, nullptr, 1.0f);
@@ -345,9 +355,9 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
 
 
   constexpr float DEG = 3.1415926535f / 180.f;
   constexpr float DEG = 3.1415926535f / 180.f;
 
 
-  const QVector3D waist(0.f, HP::WAIST_Y, 0.f);
-  const QVector3D hipL = waist + QVector3D(-hipHalf, 0.f, 0.f);
-  const QVector3D hipR = waist + QVector3D(+hipHalf, 0.f, 0.f);
+  // Use pose.pelvisPos instead of hardcoded waist for proper kneeling
+  const QVector3D hipL = pose.pelvisPos + QVector3D(-hipHalf, 0.f, 0.f);
+  const QVector3D hipR = pose.pelvisPos + QVector3D(+hipHalf, 0.f, 0.f);
   const float midX = 0.5f * (hipL.x() + hipR.x());
   const float midX = 0.5f * (hipL.x() + hipR.x());
 
 
   auto clampX = [&](const QVector3D &v, float mid) {
   auto clampX = [&](const QVector3D &v, float mid) {
@@ -386,17 +396,27 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   const float kneeForwardPush = HP::LOWER_LEG_LEN * kneeForward;
   const float kneeForwardPush = HP::LOWER_LEG_LEN * kneeForward;
   const float kneeDropAbs = kneeDrop;
   const float kneeDropAbs = kneeDrop;
 
 
-  auto computeKnee = [&](const QVector3D &hip, const QVector3D &ankle) {
-    QVector3D dir = ankle - hip;
-    QVector3D knee = hip + 0.5f * dir;
-    knee += QVector3D(0, 0, 1) * kneeForwardPush;
-    knee.setY(knee.y() - kneeDropAbs);
-    knee.setX((hip.x() + ankle.x()) * 0.5f);
-    return knee;
-  };
-
-  QVector3D kneeL = computeKnee(hipL, ankleL);
-  QVector3D kneeR = computeKnee(hipR, ankleR);
+  // Use pose's knee positions if they've been customized (e.g., for kneeling)
+  // Otherwise compute default standing knees
+  QVector3D kneeL, kneeR;
+  bool useCustomKnees = (pose.kneeL.y() < HP::KNEE_Y * 0.9f || 
+                         pose.kneeR.y() < HP::KNEE_Y * 0.9f);
+  
+  if (useCustomKnees) {
+    kneeL = pose.kneeL;
+    kneeR = pose.kneeR;
+  } else {
+    auto computeKnee = [&](const QVector3D &hip, const QVector3D &ankle) {
+      QVector3D dir = ankle - hip;
+      QVector3D knee = hip + 0.5f * dir;
+      knee += QVector3D(0, 0, 1) * kneeForwardPush;
+      knee.setY(knee.y() - kneeDropAbs);
+      knee.setX((hip.x() + ankle.x()) * 0.5f);
+      return knee;
+    };
+    kneeL = computeKnee(hipL, ankleL);
+    kneeR = computeKnee(hipR, ankleR);
+  }
 
 
   const float heelBack = heelBackFrac * footLen;
   const float heelBack = heelBackFrac * footLen;
   const float ballLen = ballFrac * footLen;
   const float ballLen = ballFrac * footLen;

+ 5 - 0
render/humanoid_base.h

@@ -21,6 +21,7 @@ struct AnimationInputs {
   bool isAttacking;
   bool isAttacking;
   bool isMelee;
   bool isMelee;
   bool isInHoldMode;
   bool isInHoldMode;
+  bool isExitingHold;  // True during stand-up transition
   float holdExitProgress;
   float holdExitProgress;
 };
 };
 
 
@@ -39,6 +40,10 @@ struct HumanoidPose {
   QVector3D elbowL, elbowR;
   QVector3D elbowL, elbowR;
   QVector3D handL, handR;
   QVector3D handL, handR;
 
 
+  // Pelvis/hip position for proper leg articulation
+  QVector3D pelvisPos;
+  QVector3D kneeL, kneeR;
+
   float footYOffset;
   float footYOffset;
   QVector3D footL, footR;
   QVector3D footL, footR;
 };
 };

+ 23 - 9
ui/qml/HUDBottom.qml

@@ -307,8 +307,6 @@ RowLayout {
                 onClicked: {
                 onClicked: {
                     if (typeof game !== 'undefined' && game.onStopCommand)
                     if (typeof game !== 'undefined' && game.onStopCommand)
                         game.onStopCommand();
                         game.onStopCommand();
-
-                    bottomRoot.commandModeChanged("normal");
                 }
                 }
                 ToolTip.visible: hovered
                 ToolTip.visible: hovered
                 ToolTip.text: bottomRoot.hasMovableUnits ? "Stop all actions immediately" : "Select troops first"
                 ToolTip.text: bottomRoot.hasMovableUnits ? "Stop all actions immediately" : "Select troops first"
@@ -333,30 +331,46 @@ RowLayout {
             }
             }
 
 
             Button {
             Button {
+                id: holdButton
                 Layout.fillWidth: true
                 Layout.fillWidth: true
                 Layout.preferredHeight: 38
                 Layout.preferredHeight: 38
                 text: "Hold"
                 text: "Hold"
                 focusPolicy: Qt.NoFocus
                 focusPolicy: Qt.NoFocus
                 enabled: bottomRoot.hasMovableUnits
                 enabled: bottomRoot.hasMovableUnits
+                
+                property bool isHoldActive: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.anySelectedInHoldMode) ? game.anySelectedInHoldMode() : false)
+                
                 onClicked: {
                 onClicked: {
                     if (typeof game !== 'undefined' && game.onHoldCommand)
                     if (typeof game !== 'undefined' && game.onHoldCommand)
                         game.onHoldCommand();
                         game.onHoldCommand();
-
-                    bottomRoot.commandModeChanged("normal");
                 }
                 }
+                
+                Connections {
+                    target: (typeof game !== 'undefined') ? game : null
+                    function onHoldModeChanged(active) {
+                        holdButton.isHoldActive = (typeof game !== 'undefined' && game.anySelectedInHoldMode) ? game.anySelectedInHoldMode() : false;
+                    }
+                }
+                
                 ToolTip.visible: hovered
                 ToolTip.visible: hovered
-                ToolTip.text: bottomRoot.hasMovableUnits ? "Hold position and defend" : "Select troops first"
+                ToolTip.text: bottomRoot.hasMovableUnits ? (isHoldActive ? "Exit hold mode (toggle)" : "Hold position and defend") : "Select troops first"
                 ToolTip.delay: 500
                 ToolTip.delay: 500
 
 
                 background: Rectangle {
                 background: Rectangle {
-                    color: parent.enabled ? (parent.pressed ? "#8e44ad" : (parent.hovered ? "#9b59b6" : "#34495e")) : "#1a252f"
+                    color: {
+                        if (!parent.enabled) return "#1a252f";
+                        if (parent.isHoldActive) return "#8e44ad";  // Active purple
+                        if (parent.pressed) return "#8e44ad";
+                        if (parent.hovered) return "#9b59b6";
+                        return "#34495e";
+                    }
                     radius: 6
                     radius: 6
-                    border.color: parent.enabled ? "#8e44ad" : "#1a252f"
-                    border.width: 2
+                    border.color: parent.enabled ? (parent.isHoldActive ? "#d35400" : "#8e44ad") : "#1a252f"
+                    border.width: parent.isHoldActive ? 3 : 2
                 }
                 }
 
 
                 contentItem: Text {
                 contentItem: Text {
-                    text: "📍\n" + parent.text
+                    text: (parent.isHoldActive ? "✓ " : "") + "📍\n" + parent.text
                     font.pointSize: 8
                     font.pointSize: 8
                     font.bold: true
                     font.bold: true
                     color: parent.enabled ? "#ecf0f1" : "#7f8c8d"
                     color: parent.enabled ? "#ecf0f1" : "#7f8c8d"