- 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>
277 lines
10 KiB
C#
277 lines
10 KiB
C#
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);
|
||
}
|
||
}
|