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:
pelpanagiotis
2026-09-25 08:56:35 +03:00
co-authored by Claude Opus 5.5
parent c674d76549
commit 5fc9ebf1e6
211 changed files with 34492 additions and 177 deletions
+276
View File
@@ -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);
}
}