using UnityEngine; // Greybox practice bot built from primitives: a capsule body and a sphere head, each with a // ShootingTargetHitbox so the shooter knows which zone was hit. Dies at 0 HP and respawns // after a delay, like the Valorant range bots. Purely local (not networked). // Procedural animations: flinch away from each hit, fall over on death, pop back up on respawn. public class ShootingTarget : MonoBehaviour { public float maxHealth = 150f; public float respawnDelay = 2f; [Header("Strafing (0 = static)")] public float strafeDistance = 0f; public float strafeSpeed = 3f; static readonly Color BodyColor = new Color(0.85f, 0.25f, 0.25f); static readonly Color HeadColor = new Color(0.95f, 0.8f, 0.3f); static readonly Color FlashColor = Color.white; const float DeathFallTime = 0.35f; const float RespawnPopTime = 0.25f; public float Health { get; private set; } public bool IsDead { get; private set; } Transform visual; // pivot at the feet; every animation rotates/scales this Renderer[] renderers; Material[][] originalMaterials; Material[][] flashMaterials; bool flashApplied; Renderer plateRenderer; bool hiddenForIntro; Material flashMaterial; Collider[] colliders; TextMesh healthLabel; Vector3 startPosition; float flashUntil; float respawnAt; float diedAt; float respawnedAt = -10f; // Flinch: a tilt axis/amount that springs back to 0. Vector3 flinchAxis = Vector3.right; float flinchAngle; float flinchVelocity; Vector3 deathAxis = Vector3.right; public static ShootingTarget Create(Transform parent, string name, Vector3 position, float strafeDistance = 0f) { var root = new GameObject(name); root.transform.SetParent(parent, false); root.transform.localPosition = position; var target = root.AddComponent(); target.strafeDistance = strafeDistance; target.Build(); return target; } void Build() { flashMaterial = ShootingFx.GetMaterial(FlashColor, 1.5f); visual = new GameObject("Visual").transform; visual.SetParent(transform, false); // Real model (Quaternius CC0 practice dummy) with invisible box hitboxes fitted to its // head/torso; falls back to the capsule + sphere bot if the model is missing. var model = Resources.Load(ModelPath); if (model != null) BuildFromModel(model); else BuildPrimitive(); renderers = visual.GetComponentsInChildren(); originalMaterials = new Material[renderers.Length][]; flashMaterials = new Material[renderers.Length][]; for (int i = 0; i < renderers.Length; i++) { originalMaterials[i] = renderers[i].sharedMaterials; flashMaterials[i] = new Material[originalMaterials[i].Length]; for (int m = 0; m < flashMaterials[i].Length; m++) flashMaterials[i][m] = flashMaterial; } // Base plate so the bot reads as a range dummy. var plate = ShootingFx.CreateVisual(PrimitiveType.Cylinder, "Base", transform, new Vector3(0f, 0.02f, 0f), new Vector3(0.9f, 0.02f, 0.9f), ShootingFx.GetMaterial(new Color(0.2f, 0.2f, 0.22f))); plateRenderer = plate.GetComponent(); plateRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On; var label = new GameObject("HealthLabel"); label.transform.SetParent(transform, false); label.transform.localPosition = new Vector3(0f, 2.25f, 0f); healthLabel = label.AddComponent(); healthLabel.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); label.GetComponent().sharedMaterial = healthLabel.font.material; healthLabel.characterSize = 0.08f; healthLabel.fontSize = 48; healthLabel.anchor = TextAnchor.MiddleCenter; healthLabel.alignment = TextAlignment.Center; colliders = visual.GetComponentsInChildren(); startPosition = transform.localPosition; Respawn(); } const string ModelPath = "Targets/PracticeDummy"; // Hitbox zones measured from the dummy mesh (1.85 m tall): torso 0.46–1.57 m, head 1.57–1.85 m. void BuildFromModel(GameObject model) { var instance = Instantiate(model, visual, false); instance.name = model.name; foreach (var c in instance.GetComponentsInChildren()) Destroy(c); foreach (var r in instance.GetComponentsInChildren()) r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On; AddBoxHitbox("Body", new Vector3(0f, 1.02f, 0f), new Vector3(0.66f, 1.1f, 0.5f), isHead: false); AddBoxHitbox("Head", new Vector3(0f, 1.71f, 0f), new Vector3(0.36f, 0.3f, 0.34f), isHead: true); } void AddBoxHitbox(string name, Vector3 center, Vector3 size, bool isHead) { var go = new GameObject(name); go.transform.SetParent(visual, false); go.transform.localPosition = center; go.AddComponent().size = size; go.AddComponent().Init(this, isHead); } void BuildPrimitive() { var body = GameObject.CreatePrimitive(PrimitiveType.Capsule); body.name = "Body"; body.transform.SetParent(visual, false); body.transform.localPosition = new Vector3(0f, 0.75f, 0f); body.transform.localScale = new Vector3(0.7f, 0.75f, 0.7f); body.AddComponent().Init(this, isHead: false); body.GetComponent().sharedMaterial = ShootingFx.GetMaterial(BodyColor); var head = GameObject.CreatePrimitive(PrimitiveType.Sphere); head.name = "Head"; head.transform.SetParent(visual, false); head.transform.localPosition = new Vector3(0f, 1.73f, 0f); head.transform.localScale = Vector3.one * 0.36f; head.AddComponent().Init(this, isHead: true); head.GetComponent().sharedMaterial = ShootingFx.GetMaterial(HeadColor); } void ApplyFlash(bool flashing) { if (flashing == flashApplied) return; flashApplied = flashing; for (int i = 0; i < renderers.Length; i++) renderers[i].sharedMaterials = flashing ? flashMaterials[i] : originalMaterials[i]; } // Returns true if this hit killed the target. hitDirection is the bullet's travel direction. public bool TakeDamage(float damage, Vector3 hitDirection, bool headshot) { if (IsDead) return false; Health = Mathf.Max(0f, Health - damage); flashUntil = Time.time + 0.08f; // Tilt away from the shot: rotate around the horizontal axis perpendicular to it. Vector3 flat = Vector3.ProjectOnPlane(hitDirection, Vector3.up); if (flat.sqrMagnitude > 0.0001f) { flinchAxis = Vector3.Cross(Vector3.up, flat.normalized); deathAxis = flinchAxis; } flinchVelocity += headshot ? 260f : 140f; if (Health <= 0f) { IsDead = true; diedAt = Time.time; respawnAt = Time.time + respawnDelay; foreach (var c in colliders) c.enabled = false; healthLabel.gameObject.SetActive(false); return true; } return false; } // Intro cinematic: the bot starts fully hidden, then PopIn() beams it in. public void HideForIntro() { hiddenForIntro = true; visual.localScale = Vector3.one * 0.0001f; plateRenderer.enabled = false; healthLabel.gameObject.SetActive(false); foreach (var c in colliders) c.enabled = false; } public void PopIn() { if (!hiddenForIntro) return; hiddenForIntro = false; plateRenderer.enabled = true; Respawn(); ShootingFx.SpawnBeam(transform.position); } void Update() { if (hiddenForIntro) return; if (IsDead) { AnimateDeath(); if (Time.time >= respawnAt) Respawn(); return; } if (strafeDistance > 0f) { float offset = Mathf.PingPong(Time.time * strafeSpeed, strafeDistance * 2f) - strafeDistance; transform.localPosition = startPosition + Vector3.right * offset; } // Damped spring back to upright. const float stiffness = 180f; const float damping = 14f; flinchVelocity += (-stiffness * flinchAngle - damping * flinchVelocity) * Time.deltaTime; flinchAngle += flinchVelocity * Time.deltaTime; float pop = Mathf.Clamp01((Time.time - respawnedAt) / RespawnPopTime); float scale = EaseOutBack(pop); visual.localScale = new Vector3(1f, Mathf.Max(0.01f, scale), 1f); visual.rotation = Quaternion.AngleAxis(flinchAngle, flinchAxis); bool flashing = Time.time < flashUntil; ApplyFlash(flashing); healthLabel.text = Mathf.CeilToInt(Health).ToString(); healthLabel.color = Color.Lerp(Color.red, Color.green, Health / maxHealth); var cam = Camera.main; if (cam != null) healthLabel.transform.rotation = cam.transform.rotation; } // Topple over backwards (away from the shooter), then sink into the floor. void AnimateDeath() { float t = Time.time - diedAt; float fall = Mathf.Clamp01(t / DeathFallTime); float angle = Mathf.Lerp(0f, 90f, fall * fall); // accelerate like it's falling visual.rotation = Quaternion.AngleAxis(angle, deathAxis); float sink = Mathf.Clamp01((t - DeathFallTime - 0.6f) / 0.5f); visual.localPosition = Vector3.down * sink * 0.8f; bool flashing = Time.time < flashUntil; ApplyFlash(flashing); } void Respawn() { Health = maxHealth; IsDead = false; respawnedAt = Time.time; flinchAngle = 0f; flinchVelocity = 0f; visual.localPosition = Vector3.zero; visual.rotation = Quaternion.identity; foreach (var c in colliders) c.enabled = true; healthLabel.gameObject.SetActive(true); } static float EaseOutBack(float x) { const float c1 = 1.70158f; const float c3 = c1 + 1f; return 1f + c3 * Mathf.Pow(x - 1f, 3f) + c1 * Mathf.Pow(x - 1f, 2f); } }