Files
Test-Multiplayer/Assets/Scripts/Shooting/ShootingTarget.cs
T
pelpanagiotisandClaude Opus 5.5 5fc9ebf1e6 Add practice range: shooting, AK47, intro cinematic, dressed warehouse scene
- PortalTestDestination practice range: Valorant-style hitscan rifle (recoil,
  spray/bloom, reload, controller support), debug HUD, practice-dummy targets
  with head/body hitboxes, hit/death/respawn animations, procedural SFX/FX
- AK47 model (Quaternius, CC0) held via Animator IK, with fire/reload/equip anims
- Intro cinematic (camera shots, title card, target beam-in, GO hand-off)
- Scene dressing editor pass: CC0 textures, props, warehouse roof, lighting and
  post-processing; custom stylized skybox shader
- CharacterMovement: camera recoil, shoulder offset, cinematic control lock
- Unity CLI pipeline package; plus in-progress chat/Rive/player changes

Asset credits: Assets/Models/Props/PROPS_CREDITS.txt (3 CC-BY 3.0 props),
Assets/Textures/Range/TEXTURES_LICENSE.txt, Assets/Resources/*/..._LICENSE.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:56:35 +03:00

277 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ShootingTarget>();
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<GameObject>(ModelPath);
if (model != null) BuildFromModel(model);
else BuildPrimitive();
renderers = visual.GetComponentsInChildren<Renderer>();
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<Renderer>();
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<TextMesh>();
healthLabel.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
label.GetComponent<MeshRenderer>().sharedMaterial = healthLabel.font.material;
healthLabel.characterSize = 0.08f;
healthLabel.fontSize = 48;
healthLabel.anchor = TextAnchor.MiddleCenter;
healthLabel.alignment = TextAlignment.Center;
colliders = visual.GetComponentsInChildren<Collider>();
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<Collider>()) Destroy(c);
foreach (var r in instance.GetComponentsInChildren<Renderer>()) 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<BoxCollider>().size = size;
go.AddComponent<ShootingTargetHitbox>().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<ShootingTargetHitbox>().Init(this, isHead: false);
body.GetComponent<Renderer>().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<ShootingTargetHitbox>().Init(this, isHead: true);
head.GetComponent<Renderer>().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);
}
}