Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
public sealed class AttackTokenManager : MonoBehaviour
|
||||
{
|
||||
private int maxMeleeAttackers => GameplayConstants.Current.Enemies.MaxMeleeAttackers;
|
||||
private int maxRangedAttackers => GameplayConstants.Current.Enemies.MaxRangedAttackers;
|
||||
private int maxEarlyCrowdAttackers => GameplayConstants.Current.Enemies.MaxEarlyCrowdAttackers;
|
||||
private int maxLateCrowdAttackers => GameplayConstants.Current.Enemies.MaxLateCrowdAttackers;
|
||||
private float crowdAttackExpansionTime => GameplayConstants.Current.Enemies.CrowdAttackExpansionTime;
|
||||
private float crowdAttackGrantInterval => GameplayConstants.Current.Enemies.CrowdAttackGrantInterval;
|
||||
|
||||
private readonly HashSet<int> meleeOwners = new();
|
||||
private readonly HashSet<int> rangedOwners = new();
|
||||
private readonly HashSet<int> crowdOwners = new();
|
||||
private float nextCrowdGrantTime;
|
||||
|
||||
public static AttackTokenManager Instance { get; private set; }
|
||||
public int ActiveCrowdAttackers => crowdOwners.Count;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryAcquire(EnemyController enemy)
|
||||
{
|
||||
if (enemy.IsEventEnemy)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (enemy.IsCrowd)
|
||||
{
|
||||
return TryAcquireCrowd(enemy);
|
||||
}
|
||||
|
||||
HashSet<int> owners = enemy.Definition.IsRanged ? rangedOwners : meleeOwners;
|
||||
int limit = enemy.Definition.IsRanged ? maxRangedAttackers : maxMeleeAttackers;
|
||||
int id = enemy.GetInstanceID();
|
||||
if (owners.Contains(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return owners.Count < limit && owners.Add(id);
|
||||
}
|
||||
|
||||
public void Release(EnemyController enemy)
|
||||
{
|
||||
int id = enemy.GetInstanceID();
|
||||
meleeOwners.Remove(id);
|
||||
rangedOwners.Remove(id);
|
||||
crowdOwners.Remove(id);
|
||||
}
|
||||
|
||||
public static int GetCrowdAttackLimit(
|
||||
float elapsedTime,
|
||||
int earlyLimit,
|
||||
int lateLimit,
|
||||
float expansionTime)
|
||||
{
|
||||
return elapsedTime < expansionTime ? earlyLimit : lateLimit;
|
||||
}
|
||||
|
||||
private bool TryAcquireCrowd(EnemyController enemy)
|
||||
{
|
||||
int id = enemy.GetInstanceID();
|
||||
if (crowdOwners.Contains(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f;
|
||||
int limit = GetCrowdAttackLimit(
|
||||
elapsedTime,
|
||||
maxEarlyCrowdAttackers,
|
||||
maxLateCrowdAttackers,
|
||||
crowdAttackExpansionTime);
|
||||
if (crowdOwners.Count >= limit || Time.time < nextCrowdGrantTime)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
crowdOwners.Add(id);
|
||||
nextCrowdGrantTime = Time.time + crowdAttackGrantInterval;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8760dc11e88608340866c05710e5995b
|
||||
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
public sealed class AttackWarningVisual : MonoBehaviour
|
||||
{
|
||||
private static Material sharedMaterial;
|
||||
private static Sprite circleSprite;
|
||||
private SpriteRenderer circle;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
circle = gameObject.AddComponent<SpriteRenderer>();
|
||||
circle.sharedMaterial = GetSharedMaterial();
|
||||
circle.sortingLayerName = "EnemyWarning";
|
||||
circle.color = new Color(1f, 1f, 1f, .15f);
|
||||
if (circleSprite == null)
|
||||
circleSprite = Resources.Load<Sprite>("Enemies/Warning-v3/Necromancer-Warning-Circle");
|
||||
circle.sprite = circleSprite;
|
||||
circle.enabled = false;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void Show(EnemyController controller, Vector2 origin,
|
||||
Vector2 direction, Vector2 targetPosition)
|
||||
{
|
||||
if (controller.AttackShape == EnemyAttackShape.TargetCircle)
|
||||
ShowTargetCircle(targetPosition, controller.AttackRadius);
|
||||
else
|
||||
Hide();
|
||||
}
|
||||
|
||||
public void SetActivePhase(bool active)
|
||||
{
|
||||
if (active) Hide();
|
||||
}
|
||||
|
||||
public void ShowTargetCircle(Vector2 center, float radius)
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
// Gameplay range is already scaled; only the captured landing point is shown.
|
||||
transform.SetParent(null, true);
|
||||
transform.position = center;
|
||||
transform.rotation = Quaternion.identity;
|
||||
transform.localScale = new Vector3(radius, radius, 1f);
|
||||
circle.enabled = true;
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
transform.localScale = Vector3.one;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void CollapseAndHide() => Hide();
|
||||
|
||||
private static Material GetSharedMaterial()
|
||||
{
|
||||
if (sharedMaterial == null)
|
||||
sharedMaterial = new Material(Shader.Find("Sprites/Default"))
|
||||
{
|
||||
name = "Attack Warning Material",
|
||||
};
|
||||
return sharedMaterial;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c973fa4116e48b245b848be02df52fb8
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5338d0101c8feca43bbf5a1f1a5fddc4
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 595424085784314478c7a9e0670d0ab3
|
||||
@@ -0,0 +1,399 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
public enum EnemyKind
|
||||
{
|
||||
Slime = 0,
|
||||
Skeleton = 1,
|
||||
Lancer = 2,
|
||||
GreatswordSkeleton = 3,
|
||||
Warlock = 4,
|
||||
Bat = 5,
|
||||
Necrofire = 6,
|
||||
SkeletonArcher = 7,
|
||||
ArmoredSkeleton = 8,
|
||||
Werewolf = 9,
|
||||
Werebear = 10,
|
||||
NecroGolem = 11,
|
||||
Necromancer = 12,
|
||||
}
|
||||
|
||||
public enum EnemyRole
|
||||
{
|
||||
Normal,
|
||||
Crowd,
|
||||
}
|
||||
|
||||
public enum EnemyAttackShape
|
||||
{
|
||||
Body,
|
||||
Box,
|
||||
Cone,
|
||||
TargetCircle,
|
||||
}
|
||||
|
||||
public enum EnemyAttackDirectionMode
|
||||
{
|
||||
LockedDirection,
|
||||
HorizontalRoot,
|
||||
}
|
||||
|
||||
public enum EnemyAttackGeometryScaleMode
|
||||
{
|
||||
WorldUnits,
|
||||
RootScale,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct EnemyAttackContactPhase
|
||||
{
|
||||
[SerializeField, Range(0f, 1f)] private float startNormalized;
|
||||
[SerializeField, Range(0f, 1f)] private float endNormalized;
|
||||
|
||||
public float StartNormalized => Mathf.Clamp01(startNormalized);
|
||||
public float EndNormalized => Mathf.Clamp01(Mathf.Max(
|
||||
startNormalized,
|
||||
endNormalized));
|
||||
|
||||
public static EnemyAttackContactPhase Create(float start, float end)
|
||||
{
|
||||
return new EnemyAttackContactPhase
|
||||
{
|
||||
startNormalized = Mathf.Clamp01(start),
|
||||
endNormalized = Mathf.Clamp01(end),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct EnemyAttackPattern
|
||||
{
|
||||
[SerializeField] private string animationState;
|
||||
[SerializeField] private EnemyAttackShape attackShape;
|
||||
[SerializeField] private EnemyAttackDirectionMode directionMode;
|
||||
[SerializeField] private EnemyAttackGeometryScaleMode geometryScaleMode;
|
||||
[SerializeField, Min(0f)] private float warningDuration;
|
||||
[SerializeField, Min(0f)] private float activeDuration;
|
||||
[SerializeField, Min(0f)] private float recoveryDuration;
|
||||
[SerializeField, Min(0f)] private float attackRange;
|
||||
[SerializeField, Min(0f)] private float attackLength;
|
||||
[SerializeField, Min(0f)] private float attackWidth;
|
||||
[SerializeField, Min(0f)] private float attackRadius;
|
||||
[SerializeField] private Vector2 attackOriginOffset;
|
||||
[SerializeField, Min(0f)] private float animationLeadTime;
|
||||
[SerializeField, Min(0f)] private float damageMultiplier;
|
||||
[SerializeField] private EnemyAttackContactPhase[] contactPhases;
|
||||
|
||||
public string AnimationState => animationState;
|
||||
public EnemyAttackShape AttackShape => attackShape;
|
||||
public EnemyAttackDirectionMode DirectionMode => directionMode;
|
||||
public EnemyAttackGeometryScaleMode GeometryScaleMode => geometryScaleMode;
|
||||
public float WarningDuration => Mathf.Max(0f, warningDuration);
|
||||
public float ActiveDuration => Mathf.Max(0f, activeDuration);
|
||||
public float RecoveryDuration => Mathf.Max(0f, recoveryDuration);
|
||||
public float AttackRange => Mathf.Max(0f, attackRange);
|
||||
public float AttackLength => Mathf.Max(0f, attackLength);
|
||||
public float AttackWidth => Mathf.Max(0f, attackWidth);
|
||||
public float AttackRadius => Mathf.Max(0f, attackRadius);
|
||||
public Vector2 AttackOriginOffset => attackOriginOffset;
|
||||
public float AnimationLeadTime => Mathf.Max(0f, animationLeadTime);
|
||||
public float DamageMultiplier => damageMultiplier <= 0f ? 1f : damageMultiplier;
|
||||
public int ContactPhaseCount => contactPhases?.Length ?? 0;
|
||||
|
||||
public EnemyAttackContactPhase GetContactPhase(int index)
|
||||
{
|
||||
if (contactPhases == null || contactPhases.Length == 0)
|
||||
{
|
||||
return EnemyAttackContactPhase.Create(0f, 1f);
|
||||
}
|
||||
|
||||
return contactPhases[Mathf.Clamp(index, 0, contactPhases.Length - 1)];
|
||||
}
|
||||
|
||||
public bool IsContactWindowActive(
|
||||
float activeElapsed,
|
||||
float effectiveActiveDuration)
|
||||
{
|
||||
if (effectiveActiveDuration <= 0.0001f
|
||||
|| activeElapsed < 0f
|
||||
|| activeElapsed >= effectiveActiveDuration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contactPhases == null || contactPhases.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float normalized = effectiveActiveDuration <= 0.0001f
|
||||
? 1f
|
||||
: Mathf.Clamp01(activeElapsed / effectiveActiveDuration);
|
||||
for (int i = 0; i < contactPhases.Length; i++)
|
||||
{
|
||||
EnemyAttackContactPhase phase = contactPhases[i];
|
||||
if (normalized >= phase.StartNormalized
|
||||
&& normalized < phase.EndNormalized)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static EnemyAttackPattern Create(
|
||||
string state,
|
||||
EnemyAttackShape shape,
|
||||
float warning,
|
||||
float active,
|
||||
float recovery,
|
||||
float range,
|
||||
float length,
|
||||
float width,
|
||||
float radius,
|
||||
float damage = 1f,
|
||||
float animationLead = 0f)
|
||||
{
|
||||
return new EnemyAttackPattern
|
||||
{
|
||||
animationState = state,
|
||||
attackShape = shape,
|
||||
directionMode = EnemyAttackDirectionMode.LockedDirection,
|
||||
geometryScaleMode = EnemyAttackGeometryScaleMode.WorldUnits,
|
||||
warningDuration = warning,
|
||||
activeDuration = active,
|
||||
recoveryDuration = recovery,
|
||||
attackRange = range,
|
||||
attackLength = length,
|
||||
attackWidth = width,
|
||||
attackRadius = radius,
|
||||
attackOriginOffset = Vector2.zero,
|
||||
animationLeadTime = animationLead,
|
||||
damageMultiplier = damage,
|
||||
contactPhases = null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct RunEventEnemyTuning
|
||||
{
|
||||
[SerializeField] private EnemyKind prefabKind;
|
||||
[SerializeField, Min(1f)] private float healthMultiplier;
|
||||
[SerializeField, Min(0)] private int experienceValue;
|
||||
[SerializeField, Min(1f)] private float scaleMultiplier;
|
||||
[SerializeField] private Color tint;
|
||||
[SerializeField, Min(0f)] private float moveSpeedMultiplier;
|
||||
[SerializeField, Min(0f)] private float attackDamageMultiplier;
|
||||
[SerializeField, Min(0f)] private float warningDurationMultiplier;
|
||||
[SerializeField, Min(0f)] private float activeDurationMultiplier;
|
||||
[SerializeField, Min(0f)] private float recoveryDurationMultiplier;
|
||||
[SerializeField, Min(0f)] private float attackRangeMultiplier;
|
||||
[SerializeField, Min(0f)] private float attackLengthMultiplier;
|
||||
[SerializeField, Min(0f)] private float attackWidthMultiplier;
|
||||
[SerializeField, Min(0f)] private float attackRadiusMultiplier;
|
||||
[SerializeField, Min(1)] private int attacksPerSequence;
|
||||
[SerializeField, Min(0f)] private float repeatWarningDurationMultiplier;
|
||||
[SerializeField, Min(0f)] private float betweenAttacksRecoveryMultiplier;
|
||||
[SerializeField, Min(1)] private int backHitsToInterrupt;
|
||||
[SerializeField, Range(0f, 1f)] private float knockbackMultiplier;
|
||||
[SerializeField] private bool canBeLaunched;
|
||||
|
||||
public EnemyKind PrefabKind => prefabKind;
|
||||
public float HealthMultiplier => Mathf.Max(1f, healthMultiplier);
|
||||
public int ExperienceValue => Mathf.Max(0, experienceValue);
|
||||
public float ScaleMultiplier => Mathf.Max(1f, scaleMultiplier);
|
||||
public Color Tint => tint;
|
||||
public float MoveSpeedMultiplier => Mathf.Max(0f, moveSpeedMultiplier);
|
||||
public float AttackDamageMultiplier => Mathf.Max(0f, attackDamageMultiplier);
|
||||
public float WarningDurationMultiplier => Mathf.Max(0f, warningDurationMultiplier);
|
||||
public float ActiveDurationMultiplier => Mathf.Max(0f, activeDurationMultiplier);
|
||||
public float RecoveryDurationMultiplier => Mathf.Max(0f, recoveryDurationMultiplier);
|
||||
public float AttackRangeMultiplier => Mathf.Max(0f, attackRangeMultiplier);
|
||||
public float AttackLengthMultiplier => Mathf.Max(0f, attackLengthMultiplier);
|
||||
public float AttackWidthMultiplier => Mathf.Max(0f, attackWidthMultiplier);
|
||||
public float AttackRadiusMultiplier => Mathf.Max(0f, attackRadiusMultiplier);
|
||||
public int AttacksPerSequence => Mathf.Max(1, attacksPerSequence);
|
||||
public float RepeatWarningDurationMultiplier =>
|
||||
Mathf.Max(0f, repeatWarningDurationMultiplier);
|
||||
public float BetweenAttacksRecoveryMultiplier =>
|
||||
Mathf.Max(0f, betweenAttacksRecoveryMultiplier);
|
||||
public int BackHitsToInterrupt => Mathf.Max(1, backHitsToInterrupt);
|
||||
public float KnockbackMultiplier => Mathf.Clamp01(knockbackMultiplier);
|
||||
public bool CanBeLaunched => canBeLaunched;
|
||||
|
||||
public static RunEventEnemyTuning Create(
|
||||
EnemyKind kind,
|
||||
float health,
|
||||
int experience,
|
||||
float scale,
|
||||
Color color,
|
||||
float moveSpeed,
|
||||
float attackDamage,
|
||||
float warningDuration,
|
||||
float activeDuration,
|
||||
float recoveryDuration,
|
||||
float attackRange,
|
||||
float attackLength,
|
||||
float attackWidth,
|
||||
float attackRadius,
|
||||
int attackCount,
|
||||
float repeatWarning,
|
||||
float betweenAttacksRecovery,
|
||||
int staggerHits,
|
||||
float knockback,
|
||||
bool launchable)
|
||||
{
|
||||
return new RunEventEnemyTuning
|
||||
{
|
||||
prefabKind = kind,
|
||||
healthMultiplier = health,
|
||||
experienceValue = experience,
|
||||
scaleMultiplier = scale,
|
||||
tint = color,
|
||||
moveSpeedMultiplier = moveSpeed,
|
||||
attackDamageMultiplier = attackDamage,
|
||||
warningDurationMultiplier = warningDuration,
|
||||
activeDurationMultiplier = activeDuration,
|
||||
recoveryDurationMultiplier = recoveryDuration,
|
||||
attackRangeMultiplier = attackRange,
|
||||
attackLengthMultiplier = attackLength,
|
||||
attackWidthMultiplier = attackWidth,
|
||||
attackRadiusMultiplier = attackRadius,
|
||||
attacksPerSequence = attackCount,
|
||||
repeatWarningDurationMultiplier = repeatWarning,
|
||||
betweenAttacksRecoveryMultiplier = betweenAttacksRecovery,
|
||||
backHitsToInterrupt = staggerHits,
|
||||
knockbackMultiplier = knockback,
|
||||
canBeLaunched = launchable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Enemy Definition")]
|
||||
public sealed class EnemyDefinition : ScriptableObject
|
||||
{
|
||||
[SerializeField] private EnemyKind kind;
|
||||
[SerializeField] private EnemyRole role;
|
||||
[SerializeField] private EnemyAttackShape attackShape;
|
||||
[SerializeField, Min(1f)] private float maxHealth;
|
||||
[SerializeField, Min(0f)] private float moveSpeed;
|
||||
[SerializeField, Min(0f)] private float attackDamage;
|
||||
[SerializeField, Min(0f)] private float warningDuration;
|
||||
[SerializeField, Min(0f)] private float activeDuration;
|
||||
[SerializeField, Min(0f)] private float recoveryDuration;
|
||||
[SerializeField, Min(0f)] private float attackRange;
|
||||
[SerializeField, Min(0f)] private float preferredRange;
|
||||
[SerializeField, Min(0f)] private float attackLength;
|
||||
[SerializeField, Min(0f)] private float attackWidth;
|
||||
[SerializeField, Min(0f)] private float attackRadius;
|
||||
[SerializeField, Min(0f)] private float attackAnimationLeadTime;
|
||||
[SerializeField, Min(0f)] private float projectileSpeed;
|
||||
[SerializeField, Min(0f)] private float projectileRadius;
|
||||
[SerializeField, Min(0)] private int experienceValue;
|
||||
[SerializeField] private EnemyAttackPattern[] attackPatterns;
|
||||
[SerializeField, Min(0f)] private float separationRadius = 0.55f;
|
||||
[SerializeField, Min(0f)] private float separationStrength = 0.6f;
|
||||
|
||||
public EnemyKind Kind => kind;
|
||||
public EnemyRole Role => role;
|
||||
public EnemyAttackShape AttackShape => attackShape;
|
||||
public float MaxHealth => maxHealth;
|
||||
public float MoveSpeed => moveSpeed;
|
||||
public float AttackDamage => attackDamage;
|
||||
public float WarningDuration => warningDuration;
|
||||
public float ActiveDuration => activeDuration;
|
||||
public float RecoveryDuration => recoveryDuration;
|
||||
public float AttackRange => attackRange;
|
||||
public float PreferredRange => preferredRange;
|
||||
public float AttackLength => attackLength;
|
||||
public float AttackWidth => attackWidth;
|
||||
public float AttackRadius => attackRadius;
|
||||
public float AttackAnimationLeadTime => Mathf.Max(0f, attackAnimationLeadTime);
|
||||
public float ProjectileSpeed => Mathf.Max(0f, projectileSpeed);
|
||||
public float ProjectileRadius => Mathf.Max(0f, projectileRadius);
|
||||
public int ExperienceValue => experienceValue;
|
||||
public float SeparationRadius => Mathf.Max(0f, separationRadius);
|
||||
public float SeparationStrength => Mathf.Max(0f, separationStrength);
|
||||
public int AttackPatternCount => attackPatterns?.Length ?? 0;
|
||||
public bool IsRanged => kind == EnemyKind.Warlock
|
||||
|| kind == EnemyKind.Necrofire
|
||||
|| kind == EnemyKind.SkeletonArcher
|
||||
|| kind == EnemyKind.Necromancer;
|
||||
public bool IsCrowd => role == EnemyRole.Crowd;
|
||||
public bool HasContactDamage => kind == EnemyKind.Slime
|
||||
|| kind == EnemyKind.Bat;
|
||||
|
||||
public EnemyAttackPattern GetAttackPattern(int index)
|
||||
{
|
||||
if (attackPatterns == null || attackPatterns.Length == 0)
|
||||
{
|
||||
return EnemyAttackPattern.Create(
|
||||
"Attack",
|
||||
attackShape,
|
||||
warningDuration,
|
||||
activeDuration,
|
||||
recoveryDuration,
|
||||
attackRange,
|
||||
attackLength,
|
||||
attackWidth,
|
||||
attackRadius,
|
||||
1f,
|
||||
attackAnimationLeadTime);
|
||||
}
|
||||
|
||||
return attackPatterns[Mathf.Clamp(index, 0, attackPatterns.Length - 1)];
|
||||
}
|
||||
|
||||
public EnemyAttackPattern[] AttackPatterns => attackPatterns;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void Configure(
|
||||
EnemyKind enemyKind,
|
||||
EnemyAttackShape shape,
|
||||
float health,
|
||||
float speed,
|
||||
float damage,
|
||||
float warning,
|
||||
float active,
|
||||
float recovery,
|
||||
float range,
|
||||
float preferred,
|
||||
float length,
|
||||
float width,
|
||||
float radius,
|
||||
int experience,
|
||||
float animationLeadTime = 0f)
|
||||
{
|
||||
kind = enemyKind;
|
||||
attackShape = shape;
|
||||
maxHealth = health;
|
||||
moveSpeed = speed;
|
||||
attackDamage = damage;
|
||||
warningDuration = warning;
|
||||
activeDuration = active;
|
||||
recoveryDuration = recovery;
|
||||
attackRange = range;
|
||||
preferredRange = preferred;
|
||||
attackLength = length;
|
||||
attackWidth = width;
|
||||
attackRadius = radius;
|
||||
attackAnimationLeadTime = animationLeadTime;
|
||||
experienceValue = experience;
|
||||
}
|
||||
|
||||
public void ConfigureRole(EnemyRole enemyRole)
|
||||
{
|
||||
role = enemyRole;
|
||||
}
|
||||
|
||||
public void ConfigureAttackPatterns(EnemyAttackPattern[] patterns)
|
||||
{
|
||||
attackPatterns = patterns;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f124e8255c489cc4eb7345424f04a5da
|
||||
@@ -0,0 +1,63 @@
|
||||
using BumpCombat.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class EnemyModel : CharacterModel
|
||||
{
|
||||
private EnemyDefinition definition;
|
||||
private float maxHealthMultiplier = 1f;
|
||||
private float moveSpeedMultiplier = 1f;
|
||||
private float attackDamageMultiplier = 1f;
|
||||
|
||||
public EnemyDefinition Definition => definition;
|
||||
public override float MaxHealth => definition == null
|
||||
? 0f
|
||||
: Evaluate(
|
||||
CharacterStat.MaxHealth,
|
||||
definition.MaxHealth * maxHealthMultiplier);
|
||||
public override float MoveSpeed => definition == null
|
||||
? 0f
|
||||
: Evaluate(
|
||||
CharacterStat.MoveSpeed,
|
||||
definition.MoveSpeed * moveSpeedMultiplier);
|
||||
public float AttackDamage => definition == null
|
||||
? 0f
|
||||
: CalculateAttackDamage(
|
||||
definition.AttackDamage * attackDamageMultiplier);
|
||||
|
||||
public void ConfigureDefinition(EnemyDefinition enemyDefinition)
|
||||
{
|
||||
definition = enemyDefinition;
|
||||
}
|
||||
|
||||
public void ConfigureEventMultipliers(
|
||||
float healthMultiplier,
|
||||
float speedMultiplier,
|
||||
float damageMultiplier)
|
||||
{
|
||||
maxHealthMultiplier = Mathf.Max(1f, healthMultiplier);
|
||||
moveSpeedMultiplier = Mathf.Max(0f, speedMultiplier);
|
||||
attackDamageMultiplier = Mathf.Max(0f, damageMultiplier);
|
||||
}
|
||||
|
||||
public float CalculatePatternDamage(
|
||||
float patternMultiplier,
|
||||
bool applyEventTuning)
|
||||
{
|
||||
if (definition == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float damage = definition.AttackDamage * patternMultiplier;
|
||||
if (applyEventTuning)
|
||||
{
|
||||
damage *= attackDamageMultiplier;
|
||||
}
|
||||
|
||||
return CalculateAttackDamage(damage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70ba651b3c0e4317b4e4eb978e69d7ba
|
||||
@@ -0,0 +1,142 @@
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// A small gameplay projectile used by the stage ranged enemies. The
|
||||
/// projectile owns its travel and lifetime; the enemy owns its warning
|
||||
/// and attack timing.
|
||||
/// </summary>
|
||||
public sealed class EnemyProjectile : MonoBehaviour
|
||||
{
|
||||
private PlayerHealth target;
|
||||
private Vector2 direction;
|
||||
private float damage;
|
||||
private float speed;
|
||||
private float remainingDistance;
|
||||
private float hitRadius;
|
||||
private bool resolved;
|
||||
|
||||
public EnemyController Owner { get; private set; }
|
||||
|
||||
public static EnemyProjectile Launch(
|
||||
EnemyController owner,
|
||||
Vector2 origin,
|
||||
Vector2 direction,
|
||||
float damage,
|
||||
float speed,
|
||||
float maxDistance,
|
||||
float hitRadius,
|
||||
bool isBeam)
|
||||
{
|
||||
GameObject root = new GameObject(
|
||||
isBeam ? "Necrofire Beam Projectile" : "Skeleton Archer Arrow");
|
||||
root.transform.position = origin;
|
||||
root.transform.right = direction.sqrMagnitude > 0.0001f
|
||||
? direction.normalized
|
||||
: Vector2.right;
|
||||
EnemyProjectile projectile = root.AddComponent<EnemyProjectile>();
|
||||
projectile.Owner = owner;
|
||||
projectile.direction = root.transform.right;
|
||||
projectile.damage = Mathf.Max(0f, damage);
|
||||
projectile.speed = Mathf.Max(0f, speed);
|
||||
projectile.remainingDistance = Mathf.Max(0.1f, maxDistance);
|
||||
projectile.hitRadius = Mathf.Max(0.03f, hitRadius);
|
||||
projectile.target = FindAnyObjectByType<PlayerHealth>();
|
||||
StageEnemyEffectVisual.AttachProjectile(
|
||||
root,
|
||||
isBeam,
|
||||
isBeam ? 1.2f : 0.6f,
|
||||
Mathf.Max(0.04f, projectile.hitRadius * 2f));
|
||||
return projectile;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (resolved
|
||||
|| target == null
|
||||
|| Owner == null
|
||||
|| Owner.IsDead
|
||||
|| !Owner.isActiveAndEnabled)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
if (!RunManager.GameplayInputEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Time.deltaTime <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float distance = speed * Time.deltaTime;
|
||||
Vector2 previousPosition = transform.position;
|
||||
if (distance > 0f)
|
||||
{
|
||||
transform.position += (Vector3)(direction * distance);
|
||||
remainingDistance -= distance;
|
||||
}
|
||||
|
||||
bool hitTarget = false;
|
||||
if (distance > 0f)
|
||||
{
|
||||
RaycastHit2D[] hits = Physics2D.CircleCastAll(
|
||||
previousPosition,
|
||||
hitRadius,
|
||||
direction,
|
||||
distance,
|
||||
Physics2D.AllLayers);
|
||||
for (int i = 0; i < hits.Length; i++)
|
||||
{
|
||||
if (hits[i].collider != null
|
||||
&& hits[i].collider.GetComponentInParent<PlayerHealth>() == target)
|
||||
{
|
||||
hitTarget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Collider2D[] overlaps = Physics2D.OverlapCircleAll(
|
||||
transform.position,
|
||||
hitRadius,
|
||||
Physics2D.AllLayers);
|
||||
for (int i = 0; i < overlaps.Length; i++)
|
||||
{
|
||||
if (overlaps[i] != null
|
||||
&& overlaps[i].GetComponentInParent<PlayerHealth>() == target)
|
||||
{
|
||||
hitTarget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitTarget)
|
||||
{
|
||||
resolved = true;
|
||||
target.TryTakeDamage(
|
||||
damage,
|
||||
((Vector2)target.transform.position - (Vector2)transform.position).normalized,
|
||||
Owner.PlayerKnockbackDistance);
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingDistance <= 0f)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e5b3bbcd7a34fb5bcb821c3da8d7c09
|
||||
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// The authored timing contract for the first Lancer thrust pilot.
|
||||
/// The tip is the only moving collision sample; the shaft is visual only.
|
||||
/// </summary>
|
||||
public static class LancerAttackMotion
|
||||
{
|
||||
public const float WarningStartReachFraction = 0.65f;
|
||||
public const float WarningEndReachFraction = 0.35f;
|
||||
public const float ActiveExtensionEndFraction = 0.65f;
|
||||
public const float HandAnchorPixelsX = 10f;
|
||||
public const float HandAnchorPixelsY = 6f;
|
||||
public const float PixelsPerUnit = 32f;
|
||||
|
||||
public static float EvaluateWarningReachFraction(float normalizedWarning)
|
||||
{
|
||||
return Mathf.Lerp(
|
||||
WarningStartReachFraction,
|
||||
WarningEndReachFraction,
|
||||
Mathf.Clamp01(normalizedWarning));
|
||||
}
|
||||
|
||||
public static float EvaluateActiveReachFraction(float normalizedActive)
|
||||
{
|
||||
float progress = Mathf.Clamp01(normalizedActive);
|
||||
if (progress <= ActiveExtensionEndFraction)
|
||||
{
|
||||
return Mathf.Lerp(
|
||||
WarningEndReachFraction,
|
||||
1f,
|
||||
progress / ActiveExtensionEndFraction);
|
||||
}
|
||||
|
||||
return Mathf.Lerp(
|
||||
1f,
|
||||
WarningEndReachFraction,
|
||||
(progress - ActiveExtensionEndFraction)
|
||||
/ (1f - ActiveExtensionEndFraction));
|
||||
}
|
||||
|
||||
public static Vector2 GetHandAnchorOffset(
|
||||
Vector2 lockedDirection,
|
||||
float worldScale = 1f)
|
||||
{
|
||||
float side = lockedDirection.x < 0f ? -1f : 1f;
|
||||
return new Vector2(
|
||||
side * HandAnchorPixelsX / PixelsPerUnit,
|
||||
HandAnchorPixelsY / PixelsPerUnit) * worldScale;
|
||||
}
|
||||
|
||||
public static Vector2 GetTipPosition(
|
||||
Vector2 rootPosition,
|
||||
Vector2 lockedDirection,
|
||||
float attackLength,
|
||||
float normalizedReach,
|
||||
float worldScale = 1f)
|
||||
{
|
||||
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
|
||||
? lockedDirection.normalized
|
||||
: Vector2.right;
|
||||
Vector2 hand = rootPosition + GetHandAnchorOffset(
|
||||
direction,
|
||||
worldScale);
|
||||
float rootReach = Mathf.Max(0f, attackLength)
|
||||
* Mathf.Clamp01(normalizedReach);
|
||||
float handLength = Mathf.Max(
|
||||
0f,
|
||||
rootReach - Vector2.Dot(hand - rootPosition, direction));
|
||||
return hand + direction * handLength;
|
||||
}
|
||||
|
||||
public static bool IsDamageWindow(float normalizedActive)
|
||||
{
|
||||
return Mathf.Clamp01(normalizedActive)
|
||||
<= ActiveExtensionEndFraction + 0.0001f;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5fd9a9ed2e949a9a56cde6711a6321c
|
||||
@@ -0,0 +1,197 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
/// <summary>Original Lancer pixels, posed from the same clock and tip as combat.</summary>
|
||||
[DisallowMultipleComponent]
|
||||
[DefaultExecutionOrder(1000)]
|
||||
public sealed class LancerThrustVisual : MonoBehaviour
|
||||
{
|
||||
private const string Lease = "LancerThrust";
|
||||
private const float RecoveryPoseDuration = 0.08f;
|
||||
private static readonly float[] TipPixels = { 22f, 20f, 19f, 30f, 28f, 26f };
|
||||
private static Sprite[] bodyFrames;
|
||||
private static Sprite[] spearFrames;
|
||||
private EnemyController controller;
|
||||
private EnemyAttack attack;
|
||||
private SpriteRenderer source;
|
||||
private SpriteRenderer bodyVisual;
|
||||
private SpriteRenderer spearVisual;
|
||||
private bool showing;
|
||||
private bool ownsLease;
|
||||
private float recoveryRemaining;
|
||||
|
||||
public SpriteRenderer BodyRenderer => bodyVisual;
|
||||
public SpriteRenderer SpearRenderer => spearVisual;
|
||||
public bool IsVisible => showing && bodyVisual != null && bodyVisual.enabled;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetFrames()
|
||||
{
|
||||
ReleaseFrames(bodyFrames);
|
||||
ReleaseFrames(spearFrames);
|
||||
bodyFrames = null;
|
||||
spearFrames = null;
|
||||
}
|
||||
|
||||
private static void ReleaseFrames(Sprite[] frames)
|
||||
{
|
||||
if (frames == null) return;
|
||||
foreach (Sprite frame in frames)
|
||||
{
|
||||
if (frame != null) Destroy(frame);
|
||||
}
|
||||
}
|
||||
|
||||
public void Begin(EnemyAttack owner, EnemyController enemy)
|
||||
{
|
||||
attack = owner;
|
||||
controller = enemy;
|
||||
source = GetComponent<SpriteRenderer>();
|
||||
bodyFrames ??= LoadFrames("Body", new Vector2(0.5f, 0.5f));
|
||||
spearFrames ??= LoadFrames("Spear", new Vector2(0.6f, 0.56f));
|
||||
if (source == null || bodyFrames == null || spearFrames == null) return;
|
||||
if (bodyVisual == null) bodyVisual = CreateRenderer("Lancer Thrust Body");
|
||||
if (spearVisual == null) spearVisual = CreateRenderer("Lancer Thrust Spear");
|
||||
recoveryRemaining = 0f;
|
||||
showing = true;
|
||||
if (!ownsLease)
|
||||
{
|
||||
controller.AcquireSpriteVisualLease(Lease);
|
||||
ownsLease = true;
|
||||
}
|
||||
ApplyPose(0, LancerAttackMotion.WarningStartReachFraction);
|
||||
}
|
||||
|
||||
public void Stop(bool cancelled)
|
||||
{
|
||||
if (!showing) return;
|
||||
if (cancelled || !isActiveAndEnabled || controller == null
|
||||
|| controller.IsDead || controller.IsStunned)
|
||||
{
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
recoveryRemaining = RecoveryPoseDuration;
|
||||
ApplyPose(5, LancerAttackMotion.WarningEndReachFraction);
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (!showing) return;
|
||||
if (controller == null || controller.IsDead || controller.IsStunned
|
||||
|| !attack.isActiveAndEnabled)
|
||||
{
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
if (controller.IsKnockbackActive || controller.IsLaunchVisualActive)
|
||||
{
|
||||
// Knockback can pause an uncancelled attack. Let Hurt/Launch show,
|
||||
// then resume this same state clock when the controller resumes.
|
||||
SuspendRenderers();
|
||||
return;
|
||||
}
|
||||
if (recoveryRemaining > 0f)
|
||||
{
|
||||
recoveryRemaining -= Time.deltaTime;
|
||||
if (recoveryRemaining <= 0f) Hide();
|
||||
else ApplyPose(5, LancerAttackMotion.WarningEndReachFraction);
|
||||
return;
|
||||
}
|
||||
float progress = controller.StateNormalizedTime;
|
||||
if (controller.State == EnemyState.Warning)
|
||||
{
|
||||
ApplyPose(Mathf.Min(2, Mathf.FloorToInt(progress * 3f)),
|
||||
LancerAttackMotion.EvaluateWarningReachFraction(progress));
|
||||
}
|
||||
else if (controller.State == EnemyState.Active)
|
||||
{
|
||||
ApplyPose(progress <= LancerAttackMotion.ActiveExtensionEndFraction ? 3 : 4,
|
||||
LancerAttackMotion.EvaluateActiveReachFraction(progress));
|
||||
}
|
||||
else Hide();
|
||||
}
|
||||
|
||||
private void ApplyPose(int frame, float reach)
|
||||
{
|
||||
if (!ownsLease)
|
||||
{
|
||||
controller.AcquireSpriteVisualLease(Lease);
|
||||
ownsLease = true;
|
||||
}
|
||||
Vector2 direction = attack.LockedDirection.sqrMagnitude > 0.0001f
|
||||
? attack.LockedDirection.normalized : Vector2.right;
|
||||
float scale = Mathf.Max(0.0001f, Mathf.Abs(transform.lossyScale.x));
|
||||
Vector2 hand = (Vector2)transform.position
|
||||
+ LancerAttackMotion.GetHandAnchorOffset(direction, scale);
|
||||
Vector2 tip = LancerAttackMotion.GetTipPosition(
|
||||
transform.position, direction, controller.AttackLength, reach, scale);
|
||||
bodyVisual.sprite = bodyFrames[frame];
|
||||
bodyVisual.flipX = direction.x < 0f;
|
||||
bodyVisual.color = source.color;
|
||||
bodyVisual.sortingLayerID = source.sortingLayerID;
|
||||
bodyVisual.sortingOrder = source.sortingOrder;
|
||||
bodyVisual.enabled = true;
|
||||
spearVisual.sprite = spearFrames[frame];
|
||||
spearVisual.color = source.color;
|
||||
spearVisual.sortingLayerID = source.sortingLayerID;
|
||||
spearVisual.sortingOrder = source.sortingOrder + 1;
|
||||
spearVisual.enabled = true;
|
||||
spearVisual.transform.SetPositionAndRotation(hand,
|
||||
Quaternion.Euler(0f, 0f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg));
|
||||
// Spear has no inherited scale: elite reach/width are already combat values.
|
||||
spearVisual.transform.localScale = new Vector3(
|
||||
Vector2.Distance(hand, tip) * 32f / TipPixels[frame],
|
||||
controller.AttackWidth * 32f / 8f, 1f);
|
||||
}
|
||||
|
||||
private SpriteRenderer CreateRenderer(string objectName)
|
||||
{
|
||||
GameObject child = new(objectName);
|
||||
SpriteRenderer renderer = child.AddComponent<SpriteRenderer>();
|
||||
if (objectName.EndsWith("Body")) child.transform.SetParent(transform, false);
|
||||
// An independent transform preserves world rotation/width under scaled elites.
|
||||
renderer.sharedMaterial = source.sharedMaterial;
|
||||
return renderer;
|
||||
}
|
||||
|
||||
private static Sprite[] LoadFrames(string part, Vector2 pivot)
|
||||
{
|
||||
Texture2D texture = Resources.Load<Texture2D>("Enemies/Lancer/Lancer-Thrust-" + part + "-v1");
|
||||
if (texture == null || texture.width != 600 || texture.height != 100) return null;
|
||||
Sprite[] frames = new Sprite[6];
|
||||
for (int i = 0; i < frames.Length; i++)
|
||||
{
|
||||
frames[i] = Sprite.Create(texture, new Rect(i * 100, 0, 100, 100), pivot, 32f,
|
||||
0, SpriteMeshType.FullRect);
|
||||
frames[i].name = "Lancer-Thrust-" + part + "-" + i;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
private void Hide()
|
||||
{
|
||||
showing = false;
|
||||
recoveryRemaining = 0f;
|
||||
SuspendRenderers();
|
||||
}
|
||||
|
||||
private void SuspendRenderers()
|
||||
{
|
||||
if (bodyVisual != null) bodyVisual.enabled = false;
|
||||
if (spearVisual != null) spearVisual.enabled = false;
|
||||
if (ownsLease && controller != null) controller.ReleaseSpriteVisualLease(Lease);
|
||||
ownsLease = false;
|
||||
}
|
||||
|
||||
private void OnDisable() => Hide();
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
Hide();
|
||||
if (bodyVisual != null) Destroy(bodyVisual.gameObject);
|
||||
if (spearVisual != null) Destroy(spearVisual.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2eb45ff7e03b43e9a8a8978339c50002
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Spawning;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns the Necromancer's one-time desperation summon phase. Timed events
|
||||
/// and ordinary attack patterns remain owned by RunManager and
|
||||
/// EnemyController.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(EnemyController))]
|
||||
public sealed class NecromancerBossController : MonoBehaviour,
|
||||
IEnemyDamageGate,
|
||||
IEnemyAttackPatternGate,
|
||||
IEnemyActionGate
|
||||
{
|
||||
private float generalSummonCooldown => GameplayConstants.Current.Enemies.GeneralSummonCooldown;
|
||||
private float crowdSummonCooldown => GameplayConstants.Current.Enemies.CrowdSummonCooldown;
|
||||
private float multiAoeCooldown => GameplayConstants.Current.Enemies.MultiAoeCooldown;
|
||||
private float summonVisualDuration => GameplayConstants.Current.Enemies.SummonVisualDuration;
|
||||
private float desperationHealth => GameplayConstants.Current.Enemies.DesperationHealthFraction;
|
||||
|
||||
private readonly List<EnemyController> phaseSummons = new();
|
||||
private EnemyController controller;
|
||||
private SpawnDirector spawnDirector;
|
||||
private bool desperationStarted;
|
||||
private bool desperationPending;
|
||||
private bool desperationPhaseStarted;
|
||||
private bool desperationCompleted;
|
||||
private float nextGeneralSummonTime;
|
||||
private float nextCrowdSummonTime;
|
||||
private float nextMultiAoeTime;
|
||||
private float summonLockUntil;
|
||||
|
||||
public bool IsDamageBlocked => desperationPending
|
||||
|| phaseSummons.Count > 0;
|
||||
public bool IsPhaseActive => phaseSummons.Count > 0;
|
||||
public int PhaseSummonCount => phaseSummons.Count;
|
||||
public float MultiAoeCooldown => Mathf.Max(0.5f, multiAoeCooldown);
|
||||
public bool IsAttackLocked => desperationPending
|
||||
|| Time.time < summonLockUntil;
|
||||
public bool DesperationStarted => desperationStarted;
|
||||
|
||||
public bool CanUseAttackPattern(int patternIndex)
|
||||
{
|
||||
return patternIndex != 2 || Time.time >= nextMultiAoeTime;
|
||||
}
|
||||
|
||||
public void NotifyAttackPatternUsed(int patternIndex)
|
||||
{
|
||||
if (patternIndex == 2)
|
||||
{
|
||||
nextMultiAoeTime = Time.time + MultiAoeCooldown;
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
controller = GetComponent<EnemyController>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (controller != null)
|
||||
{
|
||||
controller.OnDamageApplied += HandleDamageApplied;
|
||||
controller.OnDied += HandleOwnerDied;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
||||
nextGeneralSummonTime = Time.time + generalSummonCooldown;
|
||||
nextCrowdSummonTime = Time.time + crowdSummonCooldown;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (controller != null)
|
||||
{
|
||||
controller.OnDamageApplied -= HandleDamageApplied;
|
||||
controller.OnDied -= HandleOwnerDied;
|
||||
}
|
||||
ClearPhaseSummons();
|
||||
StageEnemyEffectVisual.SetProtection(gameObject, false);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (controller != null)
|
||||
{
|
||||
controller.OnDamageApplied -= HandleDamageApplied;
|
||||
controller.OnDied -= HandleOwnerDied;
|
||||
}
|
||||
spawnDirector?.DespawnSummonsOwnedBy(controller, true);
|
||||
DespawnOwnedPhaseSummons();
|
||||
StageEnemyEffectVisual.ClearOwnedEffects(gameObject);
|
||||
StageEnemyEffectVisual.SetProtection(gameObject, false);
|
||||
desperationStarted = false;
|
||||
desperationPending = false;
|
||||
desperationPhaseStarted = false;
|
||||
desperationCompleted = false;
|
||||
summonLockUntil = 0f;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (controller == null || controller.IsDead)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RunManager.GameplayInputEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.IsGroggy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveDeadPhaseSummons();
|
||||
if (controller.IsGroggy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (desperationPending)
|
||||
{
|
||||
TryBeginDesperationPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsAttackLocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.IsStunned
|
||||
|| controller.IsKnockbackActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.IsAttackSequenceInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.time >= nextGeneralSummonTime)
|
||||
{
|
||||
bool spawned = spawnDirector != null
|
||||
&& spawnDirector.TrySpawnAmbientSummons(
|
||||
controller,
|
||||
1,
|
||||
false,
|
||||
out List<EnemyController> generalSummons)
|
||||
&& generalSummons.Count > 0;
|
||||
nextGeneralSummonTime = Time.time + generalSummonCooldown;
|
||||
if (spawned)
|
||||
{
|
||||
PlaySummonTelegraph(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Time.time >= nextCrowdSummonTime)
|
||||
{
|
||||
bool spawned = spawnDirector != null
|
||||
&& spawnDirector.TrySpawnAmbientSummons(
|
||||
controller,
|
||||
3,
|
||||
true,
|
||||
out List<EnemyController> crowdSummons)
|
||||
&& crowdSummons.Count > 0;
|
||||
nextCrowdSummonTime = Time.time + crowdSummonCooldown;
|
||||
if (spawned)
|
||||
{
|
||||
PlaySummonTelegraph(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float FilterDamage(float currentHealth, float requestedDamage)
|
||||
{
|
||||
if (requestedDamage <= 0f || controller == null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
if (desperationPending || phaseSummons.Count > 0)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (controller.IsGroggy && !desperationStarted)
|
||||
{
|
||||
float desperationTarget = Mathf.Max(
|
||||
0.0001f,
|
||||
controller.MaximumHealth * desperationHealth);
|
||||
if (currentHealth > desperationTarget)
|
||||
{
|
||||
return Mathf.Min(
|
||||
requestedDamage,
|
||||
currentHealth - desperationTarget);
|
||||
}
|
||||
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return requestedDamage;
|
||||
}
|
||||
|
||||
private void HandleDamageApplied(float healthBefore, float damageApplied)
|
||||
{
|
||||
if (controller == null || controller.IsDead)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float healthFraction = controller.CurrentHealth
|
||||
/ Mathf.Max(0.0001f, controller.MaximumHealth);
|
||||
if (controller.IsGroggy
|
||||
&& !desperationStarted
|
||||
&& healthFraction <= desperationHealth + 0.0001f)
|
||||
{
|
||||
desperationStarted = true;
|
||||
desperationPending = true;
|
||||
controller.EndGroggyForProtection();
|
||||
TryBeginDesperationPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
if (desperationPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryBeginDesperationPhase()
|
||||
{
|
||||
if (!RunManager.GameplayInputEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (spawnDirector == null)
|
||||
{
|
||||
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
||||
}
|
||||
if (spawnDirector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!spawnDirector.TrySpawnPhaseSummons(
|
||||
controller,
|
||||
2,
|
||||
true,
|
||||
out List<EnemyController> spawned))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
phaseSummons.AddRange(spawned);
|
||||
for (int i = 0; i < spawned.Count; i++)
|
||||
{
|
||||
spawned[i].OnDied += HandlePhaseSummonDied;
|
||||
}
|
||||
desperationPhaseStarted = true;
|
||||
desperationPending = false;
|
||||
StageEnemyEffectVisual.SetProtection(gameObject, true);
|
||||
PlaySummonTelegraph(true);
|
||||
}
|
||||
|
||||
private void PlaySummonTelegraph(bool interruptAttack)
|
||||
{
|
||||
if (interruptAttack)
|
||||
{
|
||||
controller.CancelCurrentAttack();
|
||||
}
|
||||
|
||||
float visualDuration = controller.GetSummonAnimationDuration();
|
||||
if (visualDuration <= 0f)
|
||||
{
|
||||
visualDuration = summonVisualDuration;
|
||||
}
|
||||
summonLockUntil = Time.time + visualDuration;
|
||||
StageEnemyEffectVisual.PlaySummon(
|
||||
gameObject,
|
||||
transform.position,
|
||||
visualDuration);
|
||||
controller.PlaySummonAnimation();
|
||||
}
|
||||
|
||||
private void HandlePhaseSummonDied(EnemyController summon)
|
||||
{
|
||||
if (summon == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
summon.OnDied -= HandlePhaseSummonDied;
|
||||
phaseSummons.Remove(summon);
|
||||
if (phaseSummons.Count == 0
|
||||
&& desperationPhaseStarted
|
||||
&& !desperationPending
|
||||
&& !desperationCompleted)
|
||||
{
|
||||
CompleteDesperationPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleOwnerDied(EnemyController owner)
|
||||
{
|
||||
spawnDirector ??= FindAnyObjectByType<SpawnDirector>();
|
||||
spawnDirector?.DespawnSummonsOwnedBy(owner, true);
|
||||
DespawnOwnedPhaseSummons();
|
||||
StageEnemyEffectVisual.ClearOwnedEffects(gameObject);
|
||||
StageEnemyEffectVisual.SetProtection(gameObject, false);
|
||||
}
|
||||
|
||||
private void RemoveDeadPhaseSummons()
|
||||
{
|
||||
for (int i = phaseSummons.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (phaseSummons[i] == null || phaseSummons[i].IsDead)
|
||||
{
|
||||
if (phaseSummons[i] != null)
|
||||
{
|
||||
phaseSummons[i].OnDied -= HandlePhaseSummonDied;
|
||||
}
|
||||
phaseSummons.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
if (phaseSummons.Count == 0
|
||||
&& desperationPhaseStarted
|
||||
&& !desperationPending
|
||||
&& !desperationCompleted)
|
||||
{
|
||||
CompleteDesperationPhase();
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteDesperationPhase()
|
||||
{
|
||||
if (controller == null
|
||||
|| controller.IsDead
|
||||
|| desperationCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.BeginGroggyAfterProtection())
|
||||
{
|
||||
desperationCompleted = true;
|
||||
StageEnemyEffectVisual.SetProtection(gameObject, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearPhaseSummons()
|
||||
{
|
||||
for (int i = 0; i < phaseSummons.Count; i++)
|
||||
{
|
||||
if (phaseSummons[i] != null)
|
||||
{
|
||||
phaseSummons[i].OnDied -= HandlePhaseSummonDied;
|
||||
}
|
||||
}
|
||||
phaseSummons.Clear();
|
||||
}
|
||||
|
||||
private void DespawnOwnedPhaseSummons()
|
||||
{
|
||||
for (int i = 0; i < phaseSummons.Count; i++)
|
||||
{
|
||||
EnemyController summon = phaseSummons[i];
|
||||
if (summon != null && !summon.IsDead)
|
||||
{
|
||||
// OnDisable also runs during scene unload. Suppress the
|
||||
// retired-event feedback there so unload cannot create
|
||||
// transient visual objects after the scene is closing.
|
||||
summon.RetireFromRun(true, true);
|
||||
}
|
||||
}
|
||||
ClearPhaseSummons();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e5b3bbcd7a34fb5bcb821c3da8d7c0a
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
// Authored exterior pixels only; this component never changes the damage gate.
|
||||
[DefaultExecutionOrder(10000)]
|
||||
public sealed class NecromancerProtectionVisual : MonoBehaviour
|
||||
{
|
||||
private static readonly Dictionary<Sprite, Sprite> Masks = new();
|
||||
private EnemyController controller;
|
||||
private SpriteRenderer body;
|
||||
private SpriteRenderer outline;
|
||||
private SpriteRenderer groggySpiral;
|
||||
private float orbitTime;
|
||||
private TextMesh shieldLabel;
|
||||
private MeshRenderer shieldLabelRenderer;
|
||||
private static readonly Color GroggyColor = new Color32(255, 238, 182, 255);
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
controller = GetComponent<EnemyController>();
|
||||
body = GetComponent<SpriteRenderer>();
|
||||
var root = new GameObject("Necromancer Protection");
|
||||
root.transform.SetParent(transform, false);
|
||||
outline = root.AddComponent<SpriteRenderer>();
|
||||
outline.enabled = false;
|
||||
var labelRoot = new GameObject("Artifact Shield Label");
|
||||
labelRoot.transform.SetParent(transform, false);
|
||||
shieldLabel = labelRoot.AddComponent<TextMesh>();
|
||||
shieldLabel.fontSize = 32;
|
||||
shieldLabel.characterSize = 0.06f;
|
||||
shieldLabel.anchor = TextAnchor.MiddleCenter;
|
||||
shieldLabel.alignment = TextAlignment.Center;
|
||||
Font font = Resources.Load<Font>("Presentation/Fonts/Galmuri9");
|
||||
shieldLabelRenderer = labelRoot.GetComponent<MeshRenderer>();
|
||||
if (font != null)
|
||||
{
|
||||
shieldLabel.font = font;
|
||||
shieldLabelRenderer.sharedMaterial = font.material;
|
||||
}
|
||||
shieldLabelRenderer.enabled = false;
|
||||
var marker = new GameObject("Groggy Spiral");
|
||||
marker.transform.SetParent(transform, false);
|
||||
groggySpiral = marker.AddComponent<SpriteRenderer>();
|
||||
groggySpiral.enabled = false;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (controller != null && controller.IsGroggy) orbitTime += Time.deltaTime;
|
||||
else orbitTime = 0f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
if (outline == null) return;
|
||||
bool visible = controller != null && controller.isActiveAndEnabled && !controller.IsDead
|
||||
&& (controller.IsDamageInvulnerable || controller.IsGroggy)
|
||||
&& body != null && body.enabled && body.sprite != null;
|
||||
outline.enabled = visible;
|
||||
RefreshGroggyMarker(visible && controller.IsGroggy);
|
||||
RefreshShieldLabel(visible && controller.IsDamageInvulnerable && !controller.IsGroggy);
|
||||
if (!visible) return;
|
||||
if (!Masks.TryGetValue(body.sprite, out var mask))
|
||||
{
|
||||
var candidates = Resources.LoadAll<Sprite>("Enemies/Protection-v2/" + body.sprite.texture.name + "-outline");
|
||||
foreach (var candidate in candidates)
|
||||
if (candidate.rect == body.sprite.rect) { mask = candidate; break; }
|
||||
Masks[body.sprite] = mask;
|
||||
}
|
||||
outline.sprite = mask;
|
||||
outline.enabled = mask != null;
|
||||
outline.flipX = body.flipX;
|
||||
outline.flipY = body.flipY;
|
||||
Color tint = controller.IsGroggy ? GroggyColor : GetShieldColor();
|
||||
tint.a = body.color.a;
|
||||
outline.color = tint;
|
||||
outline.sharedMaterial = body.sharedMaterial;
|
||||
outline.sortingLayerID = body.sortingLayerID;
|
||||
outline.sortingOrder = body.sortingOrder + 1;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (outline != null) outline.enabled = false;
|
||||
if (shieldLabelRenderer != null) shieldLabelRenderer.enabled = false;
|
||||
RefreshGroggyMarker(false);
|
||||
orbitTime = 0f;
|
||||
}
|
||||
|
||||
private bool HasProtectionSummons => GetComponent<NecromancerBossController>()?.IsDamageBlocked == true;
|
||||
|
||||
private Color GetShieldColor()
|
||||
{
|
||||
if (HasProtectionSummons) return new Color32(205, 211, 224, 255);
|
||||
return ActiveArtifactDefinition.GetArtifactPaletteColor(controller.ShieldColor);
|
||||
}
|
||||
|
||||
private void RefreshShieldLabel(bool visible)
|
||||
{
|
||||
if (shieldLabelRenderer == null) return;
|
||||
shieldLabelRenderer.enabled = visible;
|
||||
if (!visible) return;
|
||||
string group = controller.ShieldColor == ArtifactColor.Green ? "초록"
|
||||
: controller.ShieldColor == ArtifactColor.Red ? "빨강" : "파랑";
|
||||
shieldLabel.text = HasProtectionSummons ? "보호 소환" : group + " 실드 " + controller.ShieldHitsRemaining;
|
||||
shieldLabel.color = GetShieldColor();
|
||||
shieldLabel.transform.position = transform.TransformPoint(new Vector3(0, GetHeadHeight(), 0)) + Vector3.up * 0.38f;
|
||||
Vector3 scale = transform.lossyScale;
|
||||
shieldLabel.transform.localScale = new Vector3(1f / Mathf.Max(0.001f, Mathf.Abs(scale.x)),
|
||||
1f / Mathf.Max(0.001f, Mathf.Abs(scale.y)), 1f);
|
||||
shieldLabelRenderer.sortingLayerID = body.sortingLayerID;
|
||||
shieldLabelRenderer.sortingOrder = body.sortingOrder + 101;
|
||||
}
|
||||
|
||||
private float GetHeadHeight()
|
||||
{
|
||||
return CrowdControlMarkerArt.HeadLocalHeight(body.sprite);
|
||||
}
|
||||
|
||||
private void RefreshGroggyMarker(bool visible)
|
||||
{
|
||||
if (groggySpiral == null) return;
|
||||
groggySpiral.enabled = visible;
|
||||
if (!visible) return;
|
||||
groggySpiral.sprite = CrowdControlMarkerArt.Frame(orbitTime, true);
|
||||
groggySpiral.transform.position = CrowdControlMarkerArt.MarkerWorldPosition(body);
|
||||
groggySpiral.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(transform.lossyScale);
|
||||
groggySpiral.sortingLayerID = body.sortingLayerID;
|
||||
groggySpiral.sortingOrder = body.sortingOrder + 100;
|
||||
groggySpiral.color = new Color(1f, 1f, 1f, body.color.a);
|
||||
groggySpiral.enabled = groggySpiral.sprite != null;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared authored CC art and head landmarks. No combat state is modified here.
|
||||
public static class CrowdControlMarkerArt
|
||||
{
|
||||
public const float MarkerScale = 0.5f;
|
||||
public const float MarkerHalfHeight = 10f / 32f * MarkerScale;
|
||||
private static Sprite[] stunFrames;
|
||||
private static Sprite[] groggyFrames;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetFrames()
|
||||
{
|
||||
foreach (Sprite[] frames in new[] { stunFrames, groggyFrames })
|
||||
if (frames != null)
|
||||
foreach (Sprite frame in frames)
|
||||
if (frame != null) Object.Destroy(frame);
|
||||
stunFrames = null;
|
||||
groggyFrames = null;
|
||||
}
|
||||
|
||||
public static Sprite Frame(float elapsed, bool groggy)
|
||||
{
|
||||
Sprite[] frames = groggy ? groggyFrames : stunFrames;
|
||||
if (frames == null)
|
||||
{
|
||||
Texture2D texture = Resources.Load<Texture2D>(
|
||||
"Artifacts/CrowdControl-v1/CC-Spiral-" + (groggy ? "Groggy" : "Stun") + "-v1");
|
||||
if (texture == null) return null;
|
||||
frames = new Sprite[8];
|
||||
for (int i = 0; i < frames.Length; i++)
|
||||
frames[i] = Sprite.Create(texture, new Rect(i * 32, 0, 32, 24), new Vector2(.5f, .5f), 32f);
|
||||
if (groggy) groggyFrames = frames;
|
||||
else stunFrames = frames;
|
||||
}
|
||||
return frames[Mathf.FloorToInt(Mathf.Max(0f, elapsed) / .06f) % frames.Length];
|
||||
}
|
||||
|
||||
public static float HeadLocalHeight(Sprite sprite)
|
||||
{
|
||||
if (sprite == null) return 0f;
|
||||
// First idle frame's central body strip. Excludes weapons, shadows and transparent padding.
|
||||
string name = sprite.texture.name;
|
||||
int top = name.StartsWith("ArmoredSkeleton") ? 39
|
||||
: name.StartsWith("SkeletonArcher") ? 38
|
||||
: name.StartsWith("GreatswordSkeleton") ? 38
|
||||
: name.StartsWith("Skeleton") ? 42
|
||||
: name.StartsWith("Slime") ? 46
|
||||
: name.StartsWith("Bat") ? 40
|
||||
: name.StartsWith("NecroGolem") ? 29
|
||||
: name.StartsWith("Necromancer") ? 30
|
||||
: name.StartsWith("Necrofire") ? 30
|
||||
: name.StartsWith("Werebear") ? 40
|
||||
: name.StartsWith("Werewolf") ? 41
|
||||
: name.StartsWith("Warlock") ? 34
|
||||
: name.StartsWith("Lancer") ? 28 : -1;
|
||||
return top < 0 ? sprite.bounds.max.y
|
||||
: (sprite.rect.height - top - sprite.pivot.y) / sprite.pixelsPerUnit;
|
||||
}
|
||||
|
||||
public static Vector3 MarkerWorldPosition(SpriteRenderer body, float gap = 4f / 32f)
|
||||
{
|
||||
Vector3 head = body.transform.TransformPoint(new Vector3(0f, HeadLocalHeight(body.sprite), 0f));
|
||||
return head + Vector3.up * (Mathf.Max(0f, gap)
|
||||
+ MarkerHalfHeight * Mathf.Abs(body.transform.lossyScale.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c3829a856e40ae8198eba14e4432e5f
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
// The code-owned phase clock samples existing sprite clips; it never deals damage.
|
||||
public sealed class StageAttackAnimationVisual : MonoBehaviour
|
||||
{
|
||||
private readonly Dictionary<string, AnimationClip> clips = new();
|
||||
private EnemyController controller;
|
||||
private Animator animator;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
controller = GetComponent<EnemyController>();
|
||||
animator = GetComponent<Animator>();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (controller == null || !controller.isActiveAndEnabled
|
||||
|| controller.Definition == null || controller.Definition.AttackPatternCount == 0
|
||||
|| controller.IsDead || controller.IsStunned || controller.IsKnockbackActive
|
||||
|| controller.IsLaunchVisualActive || animator == null || !animator.enabled
|
||||
|| animator.runtimeAnimatorController == null)
|
||||
return;
|
||||
var state = animator.GetCurrentAnimatorStateInfo(0);
|
||||
if (state.IsName("Hurt") || state.IsName("Summon") || (animator.IsInTransition(0)
|
||||
&& animator.GetNextAnimatorStateInfo(0).IsName("Hurt")))
|
||||
return;
|
||||
|
||||
var pattern = controller.CurrentAttackPattern;
|
||||
var clip = GetClip(pattern.AnimationState);
|
||||
if (clip == null) return;
|
||||
float impactTime = Mathf.Min(pattern.AnimationLeadTime, clip.length);
|
||||
float activeTime = Mathf.Min(pattern.ActiveDuration, Mathf.Max(0f, clip.length - impactTime));
|
||||
float sampleTime;
|
||||
if (controller.State == EnemyState.Warning)
|
||||
{
|
||||
float remaining = controller.StateDuration * (1f - controller.StateNormalizedTime);
|
||||
// Necrofire visibly charges throughout its long warning, reaching the
|
||||
// authored beam frame only when the gameplay clock enters Active.
|
||||
float lead = controller.Definition.Kind == EnemyKind.Necrofire
|
||||
? controller.StateDuration : Mathf.Min(impactTime, controller.StateDuration);
|
||||
if (remaining > lead || lead <= .0001f) return;
|
||||
sampleTime = impactTime * (1f - remaining / lead);
|
||||
}
|
||||
else if (controller.State == EnemyState.Active)
|
||||
{
|
||||
sampleTime = impactTime + activeTime * controller.StateNormalizedTime;
|
||||
}
|
||||
else if (controller.State == EnemyState.Recovery)
|
||||
{
|
||||
float start = impactTime + activeTime;
|
||||
sampleTime = Mathf.Lerp(start, clip.length, controller.StateNormalizedTime);
|
||||
}
|
||||
else return;
|
||||
clip.SampleAnimation(gameObject, Mathf.Clamp(sampleTime, 0f, Mathf.Max(0f, clip.length - .0001f)));
|
||||
}
|
||||
|
||||
private AnimationClip GetClip(string state)
|
||||
{
|
||||
if (string.IsNullOrEmpty(state)) return null;
|
||||
if (clips.TryGetValue(state, out var found)) return found;
|
||||
foreach (var clip in animator.runtimeAnimatorController.animationClips)
|
||||
{
|
||||
if (clip.name.EndsWith("_" + state, StringComparison.Ordinal)
|
||||
|| clip.name.Contains("_" + state + "-stage-v1"))
|
||||
{
|
||||
clips[state] = clip;
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
clips[state] = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 150ef0547df44492ba209ca7d7c06341
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Enemies
|
||||
{
|
||||
// Presentation only: existing sprite frames follow gameplay-owned lifetimes/positions.
|
||||
public sealed class StageEnemyEffectVisual : MonoBehaviour
|
||||
{
|
||||
private static readonly Dictionary<string, Sprite[]> Frames = new();
|
||||
private SpriteRenderer spriteRenderer;
|
||||
private Sprite[] frames;
|
||||
private GameObject owner;
|
||||
private float elapsed;
|
||||
private float duration;
|
||||
private bool repeat;
|
||||
private bool followsOwner;
|
||||
private EnemyController aoeOwner;
|
||||
private SpriteRenderer summonBody;
|
||||
private const float AoeFallDuration = .35f;
|
||||
|
||||
public static StageEnemyEffectVisual PlayAoe(
|
||||
GameObject owner, Vector2 groundCenter, float radius, float duration)
|
||||
{
|
||||
var effect = Create(owner, "Necromancer AOE", "Necromancer_Aoe",
|
||||
groundCenter, duration, false, false);
|
||||
// The existing warning remains the authoritative range indicator.
|
||||
effect.transform.localScale = Vector3.one * (radius * 64f / 46f);
|
||||
effect.aoeOwner = owner.GetComponent<EnemyController>();
|
||||
effect.spriteRenderer.enabled = false;
|
||||
return effect;
|
||||
}
|
||||
|
||||
public static StageEnemyEffectVisual PlaySummon(
|
||||
GameObject owner, Vector2 groundCenter, float duration)
|
||||
{
|
||||
var effect = Create(owner, "Necromancer Summon", "Necromancer_Summon",
|
||||
groundCenter, duration, false, false);
|
||||
effect.summonBody = owner.GetComponent<SpriteRenderer>();
|
||||
effect.LateUpdate();
|
||||
return effect;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (summonBody == null || summonBody.sprite == null) return;
|
||||
var sprite = summonBody.sprite;
|
||||
// shadow-v2: all ten summon frames share shadow bounds x43..58,
|
||||
// y55..60 (top-down) in the 100px cell. Align to its center.
|
||||
Vector2 local = (new Vector2(50.5f, 42.5f) - sprite.pivot)
|
||||
/ sprite.pixelsPerUnit;
|
||||
if (summonBody.flipX) local.x = -local.x;
|
||||
if (summonBody.flipY) local.y = -local.y;
|
||||
transform.position = summonBody.transform.TransformPoint(local);
|
||||
spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(transform.position.y * 100f);
|
||||
}
|
||||
|
||||
public static StageEnemyEffectVisual PlayRay(GameObject owner, Vector2 origin,
|
||||
Vector2 direction, float length, float width, float duration)
|
||||
{
|
||||
var effect = Create(owner, "Necrofire Ray", "Necrofire_Beam",
|
||||
origin, duration, false, false);
|
||||
effect.transform.rotation = Quaternion.Euler(0f, 0f,
|
||||
Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg);
|
||||
effect.transform.localScale = new Vector3(length * 32f / 100f,
|
||||
width * 32f / 22f, 1f);
|
||||
return effect;
|
||||
}
|
||||
|
||||
public static void SetProtection(GameObject owner, bool active)
|
||||
{
|
||||
var visual = owner.GetComponent<NecromancerProtectionVisual>();
|
||||
if (visual == null && active) visual = owner.AddComponent<NecromancerProtectionVisual>();
|
||||
if (visual != null) visual.Refresh();
|
||||
}
|
||||
|
||||
public static void ClearOwnedEffects(GameObject owner)
|
||||
{
|
||||
foreach (var effect in FindObjectsByType<StageEnemyEffectVisual>(
|
||||
FindObjectsInactive.Include, FindObjectsSortMode.None))
|
||||
if (effect.owner == owner) Destroy(effect.gameObject);
|
||||
}
|
||||
|
||||
public static StageEnemyEffectVisual AttachProjectile(
|
||||
GameObject projectile, bool isBeam, float length, float width)
|
||||
{
|
||||
var effect = Create(projectile, "Enemy Projectile Sprite",
|
||||
isBeam ? "Necrofire_Beam" : "SkeletonArcher_Arrow",
|
||||
projectile.transform.position, .3f, isBeam, true);
|
||||
effect.transform.SetParent(projectile.transform, false);
|
||||
if (isBeam)
|
||||
{
|
||||
effect.transform.localScale = new Vector3(length * 32f / 100f,
|
||||
width * 32f / 22f, 1f);
|
||||
// Gameplay sweeps the leading tip; the existing beam pixels trail it.
|
||||
effect.transform.localPosition = new Vector3(-length, 0f, 0f);
|
||||
}
|
||||
else
|
||||
{
|
||||
effect.transform.localScale = new Vector3(length * 32f / 20f,
|
||||
width * 32f / 7f, 1f);
|
||||
effect.transform.localPosition = new Vector3(-length * .5f, 0f, 0f);
|
||||
}
|
||||
effect.repeat = true;
|
||||
return effect;
|
||||
}
|
||||
|
||||
private static StageEnemyEffectVisual Create(GameObject owner, string name,
|
||||
string resource, Vector2 position, float duration, bool repeat, bool follow)
|
||||
{
|
||||
var root = new GameObject(name);
|
||||
root.transform.position = position;
|
||||
var effect = root.AddComponent<StageEnemyEffectVisual>();
|
||||
effect.owner = owner;
|
||||
effect.duration = Mathf.Max(.01f, duration);
|
||||
effect.repeat = repeat;
|
||||
effect.followsOwner = follow;
|
||||
effect.frames = LoadFrames(resource);
|
||||
effect.spriteRenderer = root.AddComponent<SpriteRenderer>();
|
||||
effect.spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(position.y * 100f);
|
||||
if (effect.frames.Length > 0) effect.spriteRenderer.sprite = effect.frames[0];
|
||||
return effect;
|
||||
}
|
||||
|
||||
private static Sprite[] LoadFrames(string resource)
|
||||
{
|
||||
if (Frames.TryGetValue(resource, out var cached)) return cached;
|
||||
var texture = Resources.Load<Texture2D>("Enemies/Stage-v1/" + resource);
|
||||
if (texture == null) return Array.Empty<Sprite>();
|
||||
var result = new Sprite[texture.width / 100];
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
{
|
||||
Rect rect;
|
||||
Vector2 pivot;
|
||||
if (resource == "SkeletonArcher_Arrow")
|
||||
{
|
||||
rect = new Rect(i * 100 + 41, 46, 20, 7);
|
||||
pivot = new Vector2(.5f, .5f);
|
||||
}
|
||||
else if (resource == "Necrofire_Beam")
|
||||
{
|
||||
rect = new Rect(i * 100, 46, 100, 22);
|
||||
pivot = new Vector2(0f, .5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
rect = new Rect(i * 100, 0, 100, 100);
|
||||
pivot = resource == "Necromancer_Summon"
|
||||
? new Vector2(.52f, .435f) : new Vector2(.53f, .44f);
|
||||
}
|
||||
result[i] = Sprite.Create(texture, rect, pivot, 32f);
|
||||
result[i].name = resource + "_visual_" + i;
|
||||
}
|
||||
Frames[resource] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (owner == null || !owner.activeInHierarchy)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
if (aoeOwner != null)
|
||||
{
|
||||
UpdateAoe();
|
||||
return;
|
||||
}
|
||||
elapsed += Time.deltaTime;
|
||||
if (!repeat && elapsed >= duration)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
if (frames.Length > 0)
|
||||
{
|
||||
float progress = repeat ? elapsed % duration / duration : elapsed / duration;
|
||||
int frame = Mathf.FloorToInt(progress * frames.Length);
|
||||
spriteRenderer.sprite = frames[Mathf.Min(frames.Length - 1, frame)];
|
||||
}
|
||||
if (followsOwner)
|
||||
spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(transform.position.y * 100f);
|
||||
}
|
||||
|
||||
private void UpdateAoe()
|
||||
{
|
||||
// Use the damage state clock, including knockback and pause, rather than
|
||||
// allowing an independent effect timer to explode before the attack.
|
||||
int frame;
|
||||
if (aoeOwner.State == EnemyState.Warning)
|
||||
{
|
||||
float remaining = aoeOwner.StateTimeRemaining;
|
||||
spriteRenderer.enabled = remaining <= AoeFallDuration;
|
||||
frame = Mathf.Clamp(Mathf.FloorToInt(
|
||||
(1f - remaining / AoeFallDuration) * 4f), 0, 3);
|
||||
}
|
||||
else if (aoeOwner.State == EnemyState.Active)
|
||||
{
|
||||
spriteRenderer.enabled = true;
|
||||
frame = 4 + Mathf.Min(1, Mathf.FloorToInt(aoeOwner.StateNormalizedTime * 2f));
|
||||
}
|
||||
else
|
||||
{
|
||||
spriteRenderer.enabled = false;
|
||||
return;
|
||||
}
|
||||
if (frames.Length > 0)
|
||||
spriteRenderer.sprite = frames[Mathf.Min(frames.Length - 1, frame)];
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (spriteRenderer != null) spriteRenderer.enabled = false;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (spriteRenderer != null) spriteRenderer.enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e6f84c020e454809f0158b2b3c8021e
|
||||
Reference in New Issue
Block a user