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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c674d76549
commit
5fc9ebf1e6
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user