瀏覽代碼

Add Spearman unit with custom renderer and shaders

Co-authored-by: djeada <[email protected]>
copilot-swe-agent[bot] 1 月之前
父節點
當前提交
402626133f

+ 113 - 0
assets/shaders/spearman.frag

@@ -0,0 +1,113 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float leatherGrain(vec2 p) {
+  float grain = noise(p * 10.0) * 0.16;
+  float pores = noise(p * 22.0) * 0.08;
+  return grain + pores;
+}
+
+float fabricWeave(vec2 p) {
+  float weaveX = sin(p.x * 60.0);
+  float weaveZ = sin(p.z * 60.0);
+  return weaveX * weaveZ * 0.05;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+  bool isMetal =
+      (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
+  bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
+  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
+
+  if (isMetal) {
+    float metalBrushed = abs(sin(v_worldPos.y * 80.0)) * 0.025;
+    float rust = noise(uv * 8.0) * 0.10;
+    float dents = noise(uv * 6.0) * 0.035;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float metalSheen = pow(viewAngle, 9.0) * 0.30;
+    float metalFresnel = pow(1.0 - viewAngle, 2.0) * 0.22;
+
+    color += vec3(metalSheen + metalFresnel);
+    color += vec3(metalBrushed);
+    color -= vec3(rust * 0.35 + dents * 0.25);
+  }
+  else if (isLeather) {
+    float leather = leatherGrain(uv);
+    float wear = noise(uv * 4.0) * 0.12 - 0.06;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    color *= 1.0 + leather - 0.08 + wear;
+    color += vec3(leatherSheen);
+  }
+  else if (isFabric) {
+    float weave = fabricWeave(v_worldPos.xz);
+    float fabricFuzz = noise(uv * 18.0) * 0.08;
+    float folds = noise(uv * 5.0) * 0.10 - 0.05;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
+
+    color *= 1.0 + fabricFuzz - 0.04 + folds;
+    color += vec3(weave + fabricSheen);
+  }
+  else {
+    float detail = noise(uv * 8.0) * 0.14;
+    color *= 1.0 + detail - 0.07;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  float wrapAmount = isMetal ? 0.12 : (isLeather ? 0.25 : 0.35);
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+
+  if (isMetal) {
+    diff = pow(diff, 0.88);
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 19 - 0
assets/shaders/spearman.vert

@@ -0,0 +1,19 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 1 - 0
game/CMakeLists.txt

@@ -65,6 +65,7 @@ add_library(game_systems STATIC
     units/unit.cpp
     units/archer.cpp
     units/knight.cpp
+    units/spearman.cpp
     units/factory.cpp
     units/barracks.cpp
 )

+ 5 - 0
game/units/factory.cpp

@@ -2,6 +2,7 @@
 #include "archer.h"
 #include "barracks.h"
 #include "knight.h"
+#include "spearman.h"
 
 namespace Game {
 namespace Units {
@@ -15,6 +16,10 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
       "knight", [](Engine::Core::World &world, const SpawnParams &params) {
         return Knight::Create(world, params);
       });
+  reg.registerFactory(
+      "spearman", [](Engine::Core::World &world, const SpawnParams &params) {
+        return Spearman::Create(world, params);
+      });
   reg.registerFactory(
       "barracks", [](Engine::Core::World &world, const SpawnParams &params) {
         return Barracks::Create(world, params);

+ 94 - 0
game/units/spearman.cpp

@@ -0,0 +1,94 @@
+#include "spearman.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include <iostream>
+
+static inline QVector3D teamColor(int ownerId) {
+  switch (ownerId) {
+  case 1:
+    return QVector3D(0.20f, 0.55f, 1.00f);
+  case 2:
+    return QVector3D(1.00f, 0.30f, 0.30f);
+  case 3:
+    return QVector3D(0.20f, 0.80f, 0.40f);
+  case 4:
+    return QVector3D(1.00f, 0.80f, 0.20f);
+  default:
+    return QVector3D(0.8f, 0.8f, 0.8f);
+  }
+}
+
+namespace Game {
+namespace Units {
+
+Spearman::Spearman(Engine::Core::World &world) : Unit(world, "spearman") {}
+
+std::unique_ptr<Spearman> Spearman::Create(Engine::Core::World &world,
+                                           const SpawnParams &params) {
+  auto unit = std::unique_ptr<Spearman>(new Spearman(world));
+  unit->init(params);
+  return unit;
+}
+
+void Spearman::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  m_t->scale = {0.55f, 0.55f, 0.55f};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->unitType = m_type;
+  m_u->health = 120;
+  m_u->maxHealth = 120;
+  m_u->speed = 2.5f;
+  m_u->ownerId = params.playerId;
+  m_u->visionRange = 15.0f;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  } else {
+  }
+
+  QVector3D tc = teamColor(m_u->ownerId);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->targetX = params.position.x();
+    m_mv->targetY = params.position.z();
+  }
+
+  m_atk = e->addComponent<Engine::Core::AttackComponent>();
+
+  m_atk->range = 2.5f;
+  m_atk->damage = 8;
+  m_atk->cooldown = 1.5f;
+
+  m_atk->meleeRange = 2.5f;
+  m_atk->meleeDamage = 18;
+  m_atk->meleeCooldown = 0.8f;
+
+  m_atk->preferredMode = Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = false;
+  m_atk->canMelee = true;
+  m_atk->maxHeightDifference = 2.0f;
+
+  Engine::Core::EventManager::instance().publish(
+      Engine::Core::UnitSpawnedEvent(m_id, m_u->ownerId, m_u->unitType));
+}
+
+} // namespace Units
+} // namespace Game

+ 19 - 0
game/units/spearman.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "unit.h"
+
+namespace Game {
+namespace Units {
+
+class Spearman : public Unit {
+public:
+  static std::unique_ptr<Spearman> Create(Engine::Core::World &world,
+                                          const SpawnParams &params);
+
+private:
+  Spearman(Engine::Core::World &world);
+  void init(const SpawnParams &params);
+};
+
+} // namespace Units
+} // namespace Game

+ 1 - 0
render/CMakeLists.txt

@@ -22,6 +22,7 @@ add_library(render_gl STATIC
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/knight_renderer.cpp
+    entity/spearman_renderer.cpp
     entity/barracks_renderer.cpp
     # entity/arrow.cpp removed; arrow VFX renderer code moved to geom/arrow.cpp
     geom/selection_ring.cpp

+ 2 - 0
render/entity/registry.cpp

@@ -5,6 +5,7 @@
 #include "archer_renderer.h"
 #include "barracks_renderer.h"
 #include "knight_renderer.h"
+#include "spearman_renderer.h"
 
 namespace Render::GL {
 
@@ -23,6 +24,7 @@ RenderFunc EntityRendererRegistry::get(const std::string &type) const {
 void registerBuiltInEntityRenderers(EntityRendererRegistry &registry) {
   registerArcherRenderer(registry);
   registerKnightRenderer(registry);
+  registerSpearmanRenderer(registry);
   registerBarracksRenderer(registry);
 }
 

+ 449 - 0
render/entity/spearman_renderer.cpp

@@ -0,0 +1,449 @@
+#include "spearman_renderer.h"
+#include "../../game/core/component.h"
+#include "../../game/core/entity.h"
+#include "../../game/core/world.h"
+#include "../../game/units/troop_config.h"
+#include "../../game/visuals/team_colors.h"
+#include "../geom/math_utils.h"
+#include "../geom/transforms.h"
+#include "../gl/backend.h"
+#include "../gl/mesh.h"
+#include "../gl/primitives.h"
+#include "../gl/shader.h"
+#include "../humanoid_base.h"
+#include "../humanoid_math.h"
+#include "../humanoid_specs.h"
+#include "../palette.h"
+#include "../scene_renderer.h"
+#include "../submitter.h"
+#include "registry.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <unordered_map>
+
+namespace Render::GL {
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
+static constexpr std::size_t MAX_EXTRAS_CACHE_SIZE = 10000;
+
+static inline float easeInOutCubic(float t) {
+  t = clamp01(t);
+  return t < 0.5f ? 4.0f * t * t * t
+                  : 1.0f - std::pow(-2.0f * t + 2.0f, 3.0f) / 2.0f;
+}
+
+static inline float smoothstep(float a, float b, float x) {
+  x = clamp01((x - a) / (b - a));
+  return x * x * (3.0f - 2.0f * x);
+}
+
+static inline float lerp(float a, float b, float t) {
+  return a * (1.0f - t) + b * t;
+}
+
+struct SpearmanExtras {
+  QVector3D spearShaftColor;
+  QVector3D spearheadColor;
+  QVector3D shieldColor;
+  float spearLength = 1.20f;
+  float spearShaftRadius = 0.020f;
+  float spearheadLength = 0.18f;
+  float shieldRadius = 0.16f;
+  bool hasShield = true;
+};
+
+class SpearmanRenderer : public HumanoidRendererBase {
+public:
+  QVector3D getProportionScaling() const override {
+    return QVector3D(1.10f, 1.02f, 1.05f);
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, SpearmanExtras> m_extrasCache;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D teamTint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(teamTint, seed);
+  }
+
+  void customizePose(const DrawContext &ctx, const AnimationInputs &anim,
+                     uint32_t seed, HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    float armHeightJitter = (hash01(seed ^ 0xABCDu) - 0.5f) * 0.03f;
+    float armAsymmetry = (hash01(seed ^ 0xDEF0u) - 0.5f) * 0.04f;
+
+    if (anim.isAttacking && anim.isMelee) {
+      const float attackCycleTime = 0.8f;
+      float attackPhase = std::fmod(anim.time * (1.0f / attackCycleTime), 1.0f);
+
+      QVector3D guardPos(0.25f, HP::SHOULDER_Y + 0.10f, 0.20f);
+      QVector3D preparePos(0.30f, HP::SHOULDER_Y + 0.35f, -0.10f);
+      QVector3D thrustPos(0.30f, HP::SHOULDER_Y + 0.15f, 0.80f);
+      QVector3D recoverPos(0.25f, HP::SHOULDER_Y + 0.05f, 0.35f);
+
+      if (attackPhase < 0.20f) {
+        float t = easeInOutCubic(attackPhase / 0.20f);
+        pose.handR = guardPos * (1.0f - t) + preparePos * t;
+        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f, 0.15f);
+      } else if (attackPhase < 0.30f) {
+        pose.handR = preparePos;
+        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f, 0.15f);
+      } else if (attackPhase < 0.50f) {
+        float t = (attackPhase - 0.30f) / 0.20f;
+        t = t * t * t;
+        pose.handR = preparePos * (1.0f - t) + thrustPos * t;
+        pose.handL =
+            QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f * (1.0f - t * 0.5f),
+                      0.15f + 0.15f * t);
+      } else if (attackPhase < 0.70f) {
+        float t = easeInOutCubic((attackPhase - 0.50f) / 0.20f);
+        pose.handR = thrustPos * (1.0f - t) + recoverPos * t;
+        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.025f * (1.0f - t),
+                               lerp(0.30f, 0.18f, t));
+      } else {
+        float t = smoothstep(0.70f, 1.0f, attackPhase);
+        pose.handR = recoverPos * (1.0f - t) + guardPos * t;
+        pose.handL = QVector3D(-0.20f - 0.02f * (1.0f - t),
+                               HP::SHOULDER_Y + armHeightJitter * (1.0f - t),
+                               lerp(0.18f, 0.15f, t));
+      }
+    } else {
+      pose.handR = QVector3D(0.28f + armAsymmetry,
+                             HP::SHOULDER_Y - 0.02f + armHeightJitter, 0.30f);
+      pose.handL = QVector3D(-0.22f - 0.5f * armAsymmetry,
+                             HP::SHOULDER_Y + 0.5f * armHeightJitter, 0.18f);
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose, const AnimationInputs &anim,
+                      ISubmitter &out) const override {
+    uint32_t seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFu;
+
+    SpearmanExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeSpearmanExtras(seed, v);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+
+    bool isAttacking = anim.isAttacking && anim.isMelee;
+    float attackPhase = 0.0f;
+    if (isAttacking) {
+      float attackCycleTime = 0.8f;
+      attackPhase = std::fmod(anim.time * (1.0f / attackCycleTime), 1.0f);
+    }
+
+    drawSpear(ctx, pose, v, extras, isAttacking, attackPhase, out);
+    if (extras.hasShield) {
+      drawShield(ctx, pose, v, extras, out);
+    }
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    QVector3D ironColor = v.palette.metal * QVector3D(0.88f, 0.90f, 0.92f);
+
+    float helmR = pose.headR * 1.12f;
+    QVector3D helmBot(0, pose.headPos.y() - pose.headR * 0.15f, 0);
+    QVector3D helmTop(0, pose.headPos.y() + pose.headR * 1.25f, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helmBot, helmTop, helmR), ironColor,
+             nullptr, 1.0f);
+
+    QVector3D capTop(0, pose.headPos.y() + pose.headR * 1.32f, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helmTop, capTop, helmR * 0.96f),
+             ironColor * 1.04f, nullptr, 1.0f);
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D a = center + QVector3D(0, h * 0.5f, 0);
+      QVector3D b = center - QVector3D(0, h * 0.5f, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0f);
+    };
+
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.95f, 0), helmR * 1.01f,
+         0.012f, ironColor * 1.06f);
+    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.02f, 0), helmR * 1.01f,
+         0.012f, ironColor * 1.06f);
+
+    float visorY = pose.headPos.y() + pose.headR * 0.10f;
+    float visorZ = helmR * 0.68f;
+
+    for (int i = 0; i < 3; ++i) {
+      float y = visorY + pose.headR * (0.18f - i * 0.12f);
+      QVector3D visorL(-helmR * 0.30f, y, visorZ);
+      QVector3D visorR(helmR * 0.30f, y, visorZ);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, visorL, visorR, 0.010f),
+               QVector3D(0.15f, 0.15f, 0.15f), nullptr, 1.0f);
+    }
+  }
+
+  void drawArmorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                        const HumanoidPose &pose, float yTopCover, float torsoR,
+                        float shoulderHalfSpan, float upperArmR,
+                        const QVector3D &rightAxis,
+                        ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    QVector3D ironColor = v.palette.metal * QVector3D(0.88f, 0.90f, 0.92f);
+    QVector3D leatherColor = v.palette.leather * 0.95f;
+
+    QVector3D chestTop(0, yTopCover + 0.02f, 0);
+    QVector3D chestBot(0, HP::WAIST_Y + 0.08f, 0);
+    float rChest = torsoR * 1.14f;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, chestTop, chestBot, rChest), ironColor,
+             nullptr, 1.0f);
+
+    auto drawPauldron = [&](const QVector3D &shoulder,
+                            const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float segY = shoulder.y() + 0.03f - i * 0.040f;
+        float segR = upperArmR * (2.2f - i * 0.10f);
+        QVector3D segPos = shoulder + outward * (0.015f + i * 0.006f);
+        segPos.setY(segY);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, segPos, segR),
+                 i == 0 ? ironColor * 1.04f : ironColor * (1.0f - i * 0.02f),
+                 nullptr, 1.0f);
+      }
+    };
+
+    drawPauldron(pose.shoulderL, -rightAxis);
+    drawPauldron(pose.shoulderR, rightAxis);
+
+    auto drawArmPlate = [&](const QVector3D &shoulder, const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float len = dir.length();
+      if (len < 1e-5f)
+        return;
+      dir /= len;
+
+      for (int i = 0; i < 2; ++i) {
+        float t0 = 0.12f + i * 0.28f;
+        float t1 = t0 + 0.24f;
+        QVector3D a = shoulder + dir * (t0 * len);
+        QVector3D b = shoulder + dir * (t1 * len);
+        float r = upperArmR * (1.26f - i * 0.03f);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 ironColor * (0.96f - i * 0.02f), nullptr, 1.0f);
+      }
+    };
+
+    drawArmPlate(pose.shoulderL, pose.elbowL);
+    drawArmPlate(pose.shoulderR, pose.elbowR);
+
+    for (int i = 0; i < 3; ++i) {
+      float y = HP::WAIST_Y + 0.06f - i * 0.035f;
+      float r = torsoR * (1.12f + i * 0.020f);
+      QVector3D stripTop(0, y, 0);
+      QVector3D stripBot(0, y - 0.030f, 0);
+
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, stripTop, stripBot, r),
+               leatherColor * (0.98f - i * 0.02f), nullptr, 1.0f);
+    }
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float yTopCover,
+                               float yNeck, const QVector3D &rightAxis,
+                               ISubmitter &out) const override {
+  }
+
+private:
+  static SpearmanExtras computeSpearmanExtras(uint32_t seed,
+                                              const HumanoidVariant &v) {
+    SpearmanExtras e;
+
+    e.spearShaftColor = v.palette.leather * QVector3D(0.85f, 0.75f, 0.65f);
+    e.spearheadColor = QVector3D(0.75f, 0.76f, 0.80f);
+
+    float shieldHue = hash01(seed ^ 0x12345u);
+    if (shieldHue < 0.50f) {
+      e.shieldColor = v.palette.cloth * 1.05f;
+    } else {
+      e.shieldColor = v.palette.leather * 1.20f;
+    }
+
+    e.spearLength = 1.15f + (hash01(seed ^ 0xABCDu) - 0.5f) * 0.10f;
+    e.spearShaftRadius = 0.018f + (hash01(seed ^ 0x7777u) - 0.5f) * 0.003f;
+    e.spearheadLength = 0.16f + (hash01(seed ^ 0xBEEFu) - 0.5f) * 0.04f;
+    e.shieldRadius = 0.15f + (hash01(seed ^ 0xDEF0u) - 0.5f) * 0.03f;
+
+    e.hasShield = (hash01(seed ^ 0x5555u) > 0.20f);
+    return e;
+  }
+
+  static void drawSpear(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v, const SpearmanExtras &extras,
+                        bool isAttacking, float attackPhase, ISubmitter &out) {
+    QVector3D gripPos = pose.handR;
+
+    QVector3D spearDir = QVector3D(0.08f, 0.15f, 1.0f);
+    if (spearDir.lengthSquared() > 1e-6f)
+      spearDir.normalize();
+
+    if (isAttacking) {
+      if (attackPhase >= 0.30f && attackPhase < 0.50f) {
+        float t = (attackPhase - 0.30f) / 0.20f;
+        QVector3D attackDir = QVector3D(0.05f, 0.02f, 1.0f);
+        if (attackDir.lengthSquared() > 1e-6f)
+          attackDir.normalize();
+        
+        spearDir = spearDir * (1.0f - t) + attackDir * t;
+        if (spearDir.lengthSquared() > 1e-6f)
+          spearDir.normalize();
+      }
+    }
+
+    QVector3D shaftBase = gripPos - spearDir * 0.25f;
+    QVector3D shaftTip = gripPos + spearDir * extras.spearLength;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaftBase, shaftTip,
+                             extras.spearShaftRadius),
+             extras.spearShaftColor, nullptr, 1.0f);
+
+    QVector3D spearheadBase = shaftTip;
+    QVector3D spearheadTip = shaftTip + spearDir * extras.spearheadLength;
+
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, spearheadBase, spearheadTip,
+                        extras.spearShaftRadius * 1.8f),
+             extras.spearheadColor, nullptr, 1.0f);
+
+    QVector3D gripEnd = gripPos + spearDir * 0.08f;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, gripPos, gripEnd,
+                             extras.spearShaftRadius * 1.4f),
+             v.palette.leather, nullptr, 1.0f);
+  }
+
+  static void drawShield(const DrawContext &ctx, const HumanoidPose &pose,
+                         const HumanoidVariant &v, const SpearmanExtras &extras,
+                         ISubmitter &out) {
+    const float scaleFactor = 2.2f;
+    const float R = extras.shieldRadius * scaleFactor;
+
+    const float yawDeg = -65.0f;
+    QMatrix4x4 rot;
+    rot.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
+
+    const QVector3D n = rot.map(QVector3D(0.0f, 0.0f, 1.0f));
+    const QVector3D axisX = rot.map(QVector3D(1.0f, 0.0f, 0.0f));
+    const QVector3D axisY = rot.map(QVector3D(0.0f, 1.0f, 0.0f));
+
+    QVector3D shieldCenter =
+        pose.handL + axisX * (-R * 0.30f) + axisY * (-0.04f) + n * (0.05f);
+
+    const float plateHalf = 0.0012f;
+    const float plateFull = plateHalf * 2.0f;
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shieldCenter + n * plateHalf);
+      m.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
+      m.scale(R, R, plateFull);
+      out.mesh(getUnitCylinder(), m, extras.shieldColor, nullptr, 1.0f);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shieldCenter - n * plateHalf);
+      m.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
+      m.scale(R * 0.985f, R * 0.985f, plateFull);
+      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.85f, nullptr, 1.0f);
+    }
+
+    auto drawRingRotated = [&](float radius, float thickness,
+                               const QVector3D &color) {
+      const int segments = 16;
+      for (int i = 0; i < segments; ++i) {
+        float a0 = (float)i / segments * 2.0f * 3.14159265f;
+        float a1 = (float)(i + 1) / segments * 2.0f * 3.14159265f;
+
+        QVector3D v0 =
+            QVector3D(radius * std::cos(a0), radius * std::sin(a0), 0.0f);
+        QVector3D v1 =
+            QVector3D(radius * std::cos(a1), radius * std::sin(a1), 0.0f);
+
+        QVector3D p0 = shieldCenter + rot.map(v0);
+        QVector3D p1 = shieldCenter + rot.map(v1);
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, p0, p1, thickness), color, nullptr,
+                 1.0f);
+      }
+    };
+
+    drawRingRotated(R, 0.008f * scaleFactor,
+                    QVector3D(0.78f, 0.79f, 0.83f) * 0.95f);
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shieldCenter + n * (0.018f * scaleFactor));
+      m.scale(0.038f * scaleFactor);
+      out.mesh(getUnitSphere(), m, QVector3D(0.76f, 0.77f, 0.81f), nullptr,
+               1.0f);
+    }
+
+    {
+      QVector3D gripA = shieldCenter - axisX * 0.030f - n * 0.025f;
+      QVector3D gripB = shieldCenter + axisX * 0.030f - n * 0.025f;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, gripA, gripB, 0.008f),
+               v.palette.leather, nullptr, 1.0f);
+    }
+  }
+};
+
+void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
+  static SpearmanRenderer renderer;
+  registry.registerRenderer(
+      "spearman", [](const DrawContext &ctx, ISubmitter &out) {
+        static SpearmanRenderer staticRenderer;
+        Shader *spearmanShader = nullptr;
+        if (ctx.backend) {
+          spearmanShader = ctx.backend->shader(QStringLiteral("spearman"));
+        }
+        Renderer *sceneRenderer = dynamic_cast<Renderer *>(&out);
+        if (sceneRenderer && spearmanShader) {
+          sceneRenderer->setCurrentShader(spearmanShader);
+        }
+        staticRenderer.render(ctx, out);
+        if (sceneRenderer) {
+          sceneRenderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL

+ 9 - 0
render/entity/spearman_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "registry.h"
+
+namespace Render::GL {
+
+void registerSpearmanRenderer(EntityRendererRegistry &registry);
+
+}

+ 19 - 0
render/gl/backend.cpp

@@ -59,6 +59,7 @@ void Backend::initialize() {
   m_terrainShader = m_shaderCache->get(QStringLiteral("terrain_chunk"));
   m_archerShader = m_shaderCache->get(QStringLiteral("archer"));
   m_knightShader = m_shaderCache->get(QStringLiteral("knight"));
+  m_spearmanShader = m_shaderCache->get(QStringLiteral("spearman"));
   if (!m_basicShader)
     qWarning() << "Backend: basic shader missing";
   if (!m_gridShader)
@@ -85,10 +86,13 @@ void Backend::initialize() {
     qWarning() << "Backend: archer shader missing";
   if (!m_knightShader)
     qWarning() << "Backend: knight shader missing";
+  if (!m_spearmanShader)
+    qWarning() << "Backend: spearman shader missing";
 
   cacheBasicUniforms();
   cacheArcherUniforms();
   cacheKnightUniforms();
+  cacheSpearmanUniforms();
   cacheGridUniforms();
   cacheCylinderUniforms();
   cacheFogUniforms();
@@ -625,6 +629,8 @@ void Backend::execute(const DrawQueue &queue, const Camera &cam) {
         uniforms = &m_archerUniforms;
       else if (activeShader == m_knightShader)
         uniforms = &m_knightUniforms;
+      else if (activeShader == m_spearmanShader)
+        uniforms = &m_spearmanUniforms;
 
       if (m_lastBoundShader != activeShader) {
         activeShader->use();
@@ -789,6 +795,19 @@ void Backend::cacheKnightUniforms() {
   m_knightUniforms.alpha = m_knightShader->uniformHandle("u_alpha");
 }
 
+void Backend::cacheSpearmanUniforms() {
+  if (!m_spearmanShader)
+    return;
+
+  m_spearmanUniforms.mvp = m_spearmanShader->uniformHandle("u_mvp");
+  m_spearmanUniforms.model = m_spearmanShader->uniformHandle("u_model");
+  m_spearmanUniforms.texture = m_spearmanShader->uniformHandle("u_texture");
+  m_spearmanUniforms.useTexture =
+      m_spearmanShader->uniformHandle("u_useTexture");
+  m_spearmanUniforms.color = m_spearmanShader->uniformHandle("u_color");
+  m_spearmanUniforms.alpha = m_spearmanShader->uniformHandle("u_alpha");
+}
+
 void Backend::cacheGridUniforms() {
   if (!m_gridShader)
     return;

+ 3 - 0
render/gl/backend.h

@@ -87,6 +87,7 @@ private:
   Shader *m_terrainShader = nullptr;
   Shader *m_archerShader = nullptr;
   Shader *m_knightShader = nullptr;
+  Shader *m_spearmanShader = nullptr;
 
   struct BasicUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
@@ -99,6 +100,7 @@ private:
 
   BasicUniforms m_archerUniforms;
   BasicUniforms m_knightUniforms;
+  BasicUniforms m_spearmanUniforms;
 
   struct GridUniforms {
     Shader::UniformHandle mvp{Shader::InvalidUniform};
@@ -250,6 +252,7 @@ private:
   void cacheBasicUniforms();
   void cacheArcherUniforms();
   void cacheKnightUniforms();
+  void cacheSpearmanUniforms();
   void cacheGridUniforms();
   void cacheCylinderUniforms();
   void initializeCylinderPipeline();

+ 4 - 0
render/gl/shader_cache.h

@@ -98,6 +98,10 @@ public:
     const QString knightVert = kShaderBase + QStringLiteral("knight.vert");
     const QString knightFrag = kShaderBase + QStringLiteral("knight.frag");
     load(QStringLiteral("knight"), knightVert, knightFrag);
+
+    const QString spearmanVert = kShaderBase + QStringLiteral("spearman.vert");
+    const QString spearmanFrag = kShaderBase + QStringLiteral("spearman.frag");
+    load(QStringLiteral("spearman"), spearmanVert, spearmanFrag);
   }
 
   void clear() {