544 lines
18 KiB
C#
544 lines
18 KiB
C#
using UnityEngine;
|
|
using System.Collections;
|
|
|
|
[RequireComponent(typeof(CharacterController))]
|
|
public class HumanoidEnemy : MonoBehaviour
|
|
{
|
|
public enum EnemyType { Grunt, Brute, Runner }
|
|
|
|
[Header("Enemy Type")]
|
|
public EnemyType enemyType = EnemyType.Grunt;
|
|
|
|
[Header("Movement")]
|
|
public float moveSpeed = 5f;
|
|
public float chaseRange = 25f;
|
|
public float attackRange = 2f;
|
|
public float gravity = -20f;
|
|
|
|
[Header("Melee Combat")]
|
|
public float attackDamage = 10f;
|
|
public float attackCooldown = 1f;
|
|
|
|
[Header("Ranged Combat")]
|
|
public bool canShoot = true;
|
|
public float shootRange = 18f; // within this range (but outside attackRange) the enemy shoots
|
|
public float shootDamage = 8f;
|
|
public float shootCooldown = 1.5f;
|
|
public float bulletSpeed = 40f; // visual tracer speed
|
|
public float shootAccuracyAngle = 5f; // random spread in degrees
|
|
|
|
[Header("Patrol (optional)")]
|
|
public float patrolRadius = 8f;
|
|
public float patrolWaitTime = 2f;
|
|
|
|
// Internal
|
|
private Transform player;
|
|
private CharacterController controller;
|
|
private EnemyHealth health;
|
|
private Vector3 spawnPoint;
|
|
private Vector3 patrolTarget;
|
|
private float nextAttackTime;
|
|
private float nextShootTime;
|
|
private float verticalVelocity;
|
|
private float patrolWaitTimer;
|
|
private bool isAggro = false;
|
|
|
|
// Animation state
|
|
private Transform bodyRoot;
|
|
private Transform headTransform;
|
|
private Transform torsoTransform;
|
|
private Transform leftArm;
|
|
private Transform rightArm;
|
|
private Transform leftLeg;
|
|
private Transform rightLeg;
|
|
private float animTimer = 0f;
|
|
|
|
void Start()
|
|
{
|
|
controller = GetComponent<CharacterController>();
|
|
health = GetComponent<EnemyHealth>();
|
|
|
|
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
|
|
if (playerObj == null) playerObj = GameObject.Find("Player");
|
|
if (playerObj != null)
|
|
player = playerObj.transform;
|
|
|
|
spawnPoint = transform.position;
|
|
patrolTarget = GetRandomPatrolPoint();
|
|
|
|
ApplyTypeStats();
|
|
BuildHumanoidModel();
|
|
}
|
|
|
|
void ApplyTypeStats()
|
|
{
|
|
switch (enemyType)
|
|
{
|
|
case EnemyType.Grunt:
|
|
moveSpeed = 4f;
|
|
attackDamage = 10f;
|
|
attackCooldown = 1.2f;
|
|
chaseRange = 20f;
|
|
canShoot = true;
|
|
shootRange = 16f;
|
|
shootDamage = 8f;
|
|
shootCooldown = 1.4f;
|
|
shootAccuracyAngle = 6f;
|
|
if (health != null) health.maxHealth = 80f;
|
|
break;
|
|
|
|
case EnemyType.Brute:
|
|
moveSpeed = 2.5f;
|
|
attackDamage = 25f;
|
|
attackCooldown = 2f;
|
|
chaseRange = 18f;
|
|
canShoot = false; // Brute is pure melee
|
|
if (health != null) health.maxHealth = 200f;
|
|
break;
|
|
|
|
case EnemyType.Runner:
|
|
moveSpeed = 8f;
|
|
attackDamage = 8f;
|
|
attackCooldown = 0.6f;
|
|
chaseRange = 30f;
|
|
canShoot = true;
|
|
shootRange = 22f;
|
|
shootDamage = 6f;
|
|
shootCooldown = 0.8f;
|
|
shootAccuracyAngle = 10f; // less accurate but fast
|
|
if (health != null) health.maxHealth = 50f;
|
|
break;
|
|
}
|
|
|
|
if (health != null)
|
|
health.currentHealth = health.maxHealth;
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
if (health != null && health.currentHealth <= 0f) return;
|
|
if (player == null) return;
|
|
|
|
float distToPlayer = Vector3.Distance(transform.position, player.position);
|
|
|
|
if (distToPlayer <= chaseRange)
|
|
isAggro = true;
|
|
|
|
if (isAggro)
|
|
{
|
|
if (distToPlayer <= attackRange)
|
|
{
|
|
// Close enough to punch
|
|
MeleeAttack();
|
|
}
|
|
else if (canShoot && distToPlayer <= shootRange)
|
|
{
|
|
// In shoot range — strafe / stand and fire
|
|
FacePlayer();
|
|
ShootAtPlayer();
|
|
}
|
|
else
|
|
{
|
|
ChasePlayer();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Patrol();
|
|
}
|
|
|
|
// Gravity
|
|
if (controller.isGrounded && verticalVelocity < 0f)
|
|
verticalVelocity = -2f;
|
|
verticalVelocity += gravity * Time.deltaTime;
|
|
controller.Move(Vector3.up * verticalVelocity * Time.deltaTime);
|
|
|
|
bool isWalking = isAggro && distToPlayer > attackRange && !(canShoot && distToPlayer <= shootRange);
|
|
AnimateModel(isWalking);
|
|
}
|
|
|
|
void FacePlayer()
|
|
{
|
|
Vector3 direction = (player.position - transform.position);
|
|
direction.y = 0f;
|
|
if (direction != Vector3.zero)
|
|
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 8f);
|
|
}
|
|
|
|
void ChasePlayer()
|
|
{
|
|
Vector3 direction = (player.position - transform.position);
|
|
direction.y = 0f;
|
|
direction.Normalize();
|
|
|
|
controller.Move(direction * moveSpeed * Time.deltaTime);
|
|
|
|
if (direction != Vector3.zero)
|
|
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 8f);
|
|
}
|
|
|
|
void Patrol()
|
|
{
|
|
float distToTarget = Vector3.Distance(transform.position, patrolTarget);
|
|
|
|
if (distToTarget < 1.5f)
|
|
{
|
|
patrolWaitTimer += Time.deltaTime;
|
|
if (patrolWaitTimer >= patrolWaitTime)
|
|
{
|
|
patrolTarget = GetRandomPatrolPoint();
|
|
patrolWaitTimer = 0f;
|
|
}
|
|
return;
|
|
}
|
|
|
|
Vector3 direction = (patrolTarget - transform.position);
|
|
direction.y = 0f;
|
|
direction.Normalize();
|
|
|
|
controller.Move(direction * (moveSpeed * 0.4f) * Time.deltaTime);
|
|
|
|
if (direction != Vector3.zero)
|
|
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * 4f);
|
|
}
|
|
|
|
Vector3 GetRandomPatrolPoint()
|
|
{
|
|
Vector2 randomCircle = Random.insideUnitCircle * patrolRadius;
|
|
return spawnPoint + new Vector3(randomCircle.x, 0f, randomCircle.y);
|
|
}
|
|
|
|
// ─── Melee ───
|
|
|
|
void MeleeAttack()
|
|
{
|
|
FacePlayer();
|
|
|
|
if (Time.time >= nextAttackTime)
|
|
{
|
|
nextAttackTime = Time.time + attackCooldown;
|
|
StartCoroutine(PunchAnimation());
|
|
|
|
Player playerHealth = player.GetComponent<Player>();
|
|
if (playerHealth != null)
|
|
{
|
|
playerHealth.health -= attackDamage;
|
|
Debug.Log($"[Enemy] Melee hit! Player health: {playerHealth.health}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Shooting ───
|
|
|
|
void ShootAtPlayer()
|
|
{
|
|
if (Time.time < nextShootTime) return;
|
|
nextShootTime = Time.time + shootCooldown;
|
|
|
|
StartCoroutine(ShootAnimation());
|
|
|
|
// Aim at player's centre-ish (chest height) with some spread
|
|
Vector3 aimOrigin = headTransform != null
|
|
? headTransform.position
|
|
: transform.position + Vector3.up * 1.4f;
|
|
|
|
Vector3 aimTarget = player.position + Vector3.up * 0.9f; // aim for chest
|
|
Vector3 aimDir = (aimTarget - aimOrigin).normalized;
|
|
|
|
// Apply accuracy spread
|
|
aimDir = Quaternion.Euler(
|
|
Random.Range(-shootAccuracyAngle, shootAccuracyAngle),
|
|
Random.Range(-shootAccuracyAngle, shootAccuracyAngle),
|
|
0f) * aimDir;
|
|
|
|
RaycastHit hit;
|
|
if (Physics.Raycast(aimOrigin, aimDir, out hit, shootRange * 1.5f))
|
|
{
|
|
// Spawn a visual tracer
|
|
StartCoroutine(SpawnBulletTracer(aimOrigin, hit.point));
|
|
|
|
Player playerHealth = hit.transform.GetComponentInParent<Player>();
|
|
if (playerHealth != null)
|
|
{
|
|
playerHealth.health -= shootDamage;
|
|
Debug.Log($"[Enemy] Shot hit player! Player health: {playerHealth.health}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Missed — still show tracer going into the distance
|
|
StartCoroutine(SpawnBulletTracer(aimOrigin, aimOrigin + aimDir * shootRange));
|
|
}
|
|
}
|
|
|
|
IEnumerator SpawnBulletTracer(Vector3 from, Vector3 to)
|
|
{
|
|
GameObject tracer = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
|
tracer.name = "EnemyBulletTracer";
|
|
Destroy(tracer.GetComponent<Collider>());
|
|
|
|
float length = Vector3.Distance(from, to);
|
|
tracer.transform.localScale = new Vector3(0.02f, 0.02f, Mathf.Min(length, 0.3f));
|
|
|
|
Renderer r = tracer.GetComponent<Renderer>();
|
|
Material mat = new Material(Shader.Find("Standard"));
|
|
mat.color = new Color(1f, 0.9f, 0.3f);
|
|
// Make it emissive so it's visible
|
|
mat.EnableKeyword("_EMISSION");
|
|
mat.SetColor("_EmissionColor", new Color(1f, 0.8f, 0.1f) * 2f);
|
|
r.material = mat;
|
|
|
|
float travelTime = length / bulletSpeed;
|
|
float elapsed = 0f;
|
|
|
|
while (elapsed < travelTime)
|
|
{
|
|
elapsed += Time.deltaTime;
|
|
float t = Mathf.Clamp01(elapsed / travelTime);
|
|
Vector3 pos = Vector3.Lerp(from, to, t);
|
|
tracer.transform.position = pos;
|
|
tracer.transform.rotation = Quaternion.LookRotation((to - from).normalized);
|
|
yield return null;
|
|
}
|
|
|
|
Destroy(tracer);
|
|
}
|
|
|
|
// ─── Procedural Humanoid Model ───
|
|
|
|
void BuildHumanoidModel()
|
|
{
|
|
float scale = 1f;
|
|
Color skinColor = new Color(0.6f, 0.45f, 0.35f);
|
|
Color shirtColor;
|
|
Color pantsColor = new Color(0.2f, 0.2f, 0.25f);
|
|
|
|
switch (enemyType)
|
|
{
|
|
case EnemyType.Grunt:
|
|
scale = 1f;
|
|
shirtColor = new Color(0.4f, 0.25f, 0.2f);
|
|
break;
|
|
case EnemyType.Brute:
|
|
scale = 1.3f;
|
|
shirtColor = new Color(0.5f, 0.1f, 0.1f);
|
|
skinColor = new Color(0.5f, 0.35f, 0.3f);
|
|
break;
|
|
case EnemyType.Runner:
|
|
scale = 0.9f;
|
|
shirtColor = new Color(0.2f, 0.35f, 0.2f);
|
|
break;
|
|
default:
|
|
shirtColor = Color.gray;
|
|
break;
|
|
}
|
|
|
|
bodyRoot = new GameObject("BodyRoot").transform;
|
|
bodyRoot.SetParent(transform);
|
|
bodyRoot.localPosition = Vector3.zero;
|
|
bodyRoot.localRotation = Quaternion.identity;
|
|
|
|
// Head — kept with a collider and EnemyHeadHitbox for headshots
|
|
headTransform = CreatePart("Head", bodyRoot,
|
|
new Vector3(0f, 1.65f, 0f) * scale,
|
|
new Vector3(0.3f, 0.3f, 0.3f) * scale,
|
|
skinColor, PrimitiveType.Sphere, keepCollider: true);
|
|
|
|
// Wire up head hitbox
|
|
EnemyHeadHitbox headHitbox = headTransform.gameObject.AddComponent<EnemyHeadHitbox>();
|
|
headHitbox.enemyHealth = health;
|
|
|
|
// Eyes
|
|
CreatePart("LeftEye", headTransform,
|
|
new Vector3(-0.08f, 0.03f, 0.12f),
|
|
new Vector3(0.08f, 0.08f, 0.05f),
|
|
new Color(0.9f, 0.1f, 0.1f), PrimitiveType.Sphere);
|
|
|
|
CreatePart("RightEye", headTransform,
|
|
new Vector3(0.08f, 0.03f, 0.12f),
|
|
new Vector3(0.08f, 0.08f, 0.05f),
|
|
new Color(0.9f, 0.1f, 0.1f), PrimitiveType.Sphere);
|
|
|
|
// Torso
|
|
torsoTransform = CreatePart("Torso", bodyRoot,
|
|
new Vector3(0f, 1.15f, 0f) * scale,
|
|
new Vector3(0.45f, 0.6f, 0.25f) * scale,
|
|
shirtColor, PrimitiveType.Cube, keepCollider: true);
|
|
|
|
// Arms
|
|
leftArm = CreatePart("LeftArm", bodyRoot,
|
|
new Vector3(-0.35f, 1.15f, 0f) * scale,
|
|
new Vector3(0.15f, 0.55f, 0.15f) * scale,
|
|
skinColor, PrimitiveType.Cube, keepCollider: true);
|
|
|
|
rightArm = CreatePart("RightArm", bodyRoot,
|
|
new Vector3(0.35f, 1.15f, 0f) * scale,
|
|
new Vector3(0.15f, 0.55f, 0.15f) * scale,
|
|
skinColor, PrimitiveType.Cube, keepCollider: true);
|
|
|
|
// Legs
|
|
leftLeg = CreatePart("LeftLeg", bodyRoot,
|
|
new Vector3(-0.12f, 0.4f, 0f) * scale,
|
|
new Vector3(0.18f, 0.6f, 0.18f) * scale,
|
|
pantsColor, PrimitiveType.Cube, keepCollider: true);
|
|
|
|
rightLeg = CreatePart("RightLeg", bodyRoot,
|
|
new Vector3(0.12f, 0.4f, 0f) * scale,
|
|
new Vector3(0.18f, 0.6f, 0.18f) * scale,
|
|
pantsColor, PrimitiveType.Cube, keepCollider: true);
|
|
|
|
controller.height = 1.9f * scale;
|
|
controller.radius = 0.3f * scale;
|
|
controller.center = new Vector3(0f, 0.95f * scale, 0f);
|
|
}
|
|
|
|
/// <param name="keepCollider">True to leave the collider on so raycasts can hit this part.</param>
|
|
Transform CreatePart(string name, Transform parent, Vector3 localPos, Vector3 localScale, Color color,
|
|
PrimitiveType shape = PrimitiveType.Cube, bool keepCollider = false)
|
|
{
|
|
GameObject part = GameObject.CreatePrimitive(shape);
|
|
part.name = name;
|
|
part.transform.SetParent(parent);
|
|
part.transform.localPosition = localPos;
|
|
part.transform.localScale = localScale;
|
|
part.transform.localRotation = Quaternion.identity;
|
|
|
|
if (!keepCollider)
|
|
{
|
|
Collider col = part.GetComponent<Collider>();
|
|
if (col != null) Destroy(col);
|
|
}
|
|
|
|
Renderer rend = part.GetComponent<Renderer>();
|
|
rend.material = new Material(Shader.Find("Standard"));
|
|
rend.material.color = color;
|
|
|
|
return part.transform;
|
|
}
|
|
|
|
// ─── Simple Limb Animation ───
|
|
|
|
void AnimateModel(bool isWalking)
|
|
{
|
|
if (bodyRoot == null) return;
|
|
|
|
if (isWalking)
|
|
{
|
|
animTimer += Time.deltaTime * moveSpeed * 1.5f;
|
|
|
|
float limbSwing = Mathf.Sin(animTimer) * 30f;
|
|
|
|
if (leftArm != null)
|
|
leftArm.localRotation = Quaternion.Euler(limbSwing, 0f, 0f);
|
|
if (rightArm != null)
|
|
rightArm.localRotation = Quaternion.Euler(-limbSwing, 0f, 0f);
|
|
if (leftLeg != null)
|
|
leftLeg.localRotation = Quaternion.Euler(-limbSwing, 0f, 0f);
|
|
if (rightLeg != null)
|
|
rightLeg.localRotation = Quaternion.Euler(limbSwing, 0f, 0f);
|
|
|
|
if (torsoTransform != null)
|
|
{
|
|
Vector3 torsoPos = torsoTransform.localPosition;
|
|
float scale = enemyType == EnemyType.Brute ? 1.3f : (enemyType == EnemyType.Runner ? 0.9f : 1f);
|
|
torsoPos.y = 1.15f * scale + Mathf.Abs(Mathf.Sin(animTimer * 2f)) * 0.03f;
|
|
torsoTransform.localPosition = torsoPos;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
animTimer = 0f;
|
|
if (leftArm != null)
|
|
leftArm.localRotation = Quaternion.Slerp(leftArm.localRotation, Quaternion.identity, Time.deltaTime * 5f);
|
|
if (rightArm != null)
|
|
rightArm.localRotation = Quaternion.Slerp(rightArm.localRotation, Quaternion.identity, Time.deltaTime * 5f);
|
|
if (leftLeg != null)
|
|
leftLeg.localRotation = Quaternion.Slerp(leftLeg.localRotation, Quaternion.identity, Time.deltaTime * 5f);
|
|
if (rightLeg != null)
|
|
rightLeg.localRotation = Quaternion.Slerp(rightLeg.localRotation, Quaternion.identity, Time.deltaTime * 5f);
|
|
}
|
|
}
|
|
|
|
IEnumerator PunchAnimation()
|
|
{
|
|
if (rightArm == null) yield break;
|
|
|
|
float timer = 0f;
|
|
float punchDuration = 0.15f;
|
|
|
|
while (timer < punchDuration)
|
|
{
|
|
timer += Time.deltaTime;
|
|
float t = timer / punchDuration;
|
|
rightArm.localRotation = Quaternion.Euler(Mathf.Lerp(0f, -90f, t), 0f, 0f);
|
|
yield return null;
|
|
}
|
|
|
|
timer = 0f;
|
|
while (timer < punchDuration)
|
|
{
|
|
timer += Time.deltaTime;
|
|
float t = timer / punchDuration;
|
|
rightArm.localRotation = Quaternion.Euler(Mathf.Lerp(-90f, 0f, t), 0f, 0f);
|
|
yield return null;
|
|
}
|
|
|
|
rightArm.localRotation = Quaternion.identity;
|
|
}
|
|
|
|
IEnumerator ShootAnimation()
|
|
{
|
|
if (rightArm == null) yield break;
|
|
|
|
float timer = 0f;
|
|
float duration = 0.1f;
|
|
|
|
// Raise arm to aim
|
|
while (timer < duration)
|
|
{
|
|
timer += Time.deltaTime;
|
|
float t = timer / duration;
|
|
rightArm.localRotation = Quaternion.Euler(Mathf.Lerp(0f, -70f, t), 0f, 0f);
|
|
yield return null;
|
|
}
|
|
|
|
yield return new WaitForSeconds(0.05f);
|
|
|
|
// Recoil kick back
|
|
rightArm.localRotation = Quaternion.Euler(-60f, 0f, 0f);
|
|
yield return new WaitForSeconds(0.08f);
|
|
|
|
// Return
|
|
timer = 0f;
|
|
while (timer < duration * 2f)
|
|
{
|
|
timer += Time.deltaTime;
|
|
float t = timer / (duration * 2f);
|
|
rightArm.localRotation = Quaternion.Euler(Mathf.Lerp(-70f, 0f, t), 0f, 0f);
|
|
yield return null;
|
|
}
|
|
|
|
rightArm.localRotation = Quaternion.identity;
|
|
}
|
|
|
|
// ─── Gizmos ───
|
|
|
|
void OnDrawGizmosSelected()
|
|
{
|
|
Gizmos.color = new Color(1f, 0f, 0f, 0.15f);
|
|
Gizmos.DrawWireSphere(transform.position, chaseRange);
|
|
|
|
Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f);
|
|
Gizmos.DrawWireSphere(transform.position, attackRange);
|
|
|
|
if (canShoot)
|
|
{
|
|
Gizmos.color = new Color(1f, 1f, 0f, 0.2f);
|
|
Gizmos.DrawWireSphere(Application.isPlaying ? transform.position : transform.position, shootRange);
|
|
}
|
|
|
|
Gizmos.color = new Color(0f, 0.5f, 1f, 0.15f);
|
|
Gizmos.DrawWireSphere(Application.isPlaying ? spawnPoint : transform.position, patrolRadius);
|
|
}
|
|
}
|