Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -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