2.5d test

This commit is contained in:
2026-09-27 21:08:05 +09:00
parent 84dd12414a
commit abd2da2057
201 changed files with 24297 additions and 295 deletions
@@ -0,0 +1,453 @@
using System;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public enum ComparisonActorKind { Player, Enemy, Summon }
[DisallowMultipleComponent]
public sealed class Defense25DActor : CharacterModel
{
private Defense25DComparison world;
private CapsuleCollider sensor;
private readonly RaycastHit[] movementHits = new RaycastHit[64];
private Vector2 knockbackVelocity;
private Vector2 knockbackStartPosition;
private float knockbackRemaining;
private float hurtUntil;
private float invulnerableUntil;
private float shieldAmount, shieldExpiresAt;
private float guardUntil, guardCooldownUntil;
private bool guardHasBlocked;
private bool knockbackCompletionPending;
private float enemyMaximumHealthOverride;
private Func<Vector2, Vector2> closestPoint;
private Vector2 facing = Vector2.right;
private Vector2 unflippedBodyCenterOffset;
public ComparisonActorKind Kind { get; private set; }
public Rigidbody Body { get; private set; }
public CapsuleCollider BodyCollider { get; private set; }
public Defense25DSprite Visual { get; private set; }
public Vector2 Facing
{
get => facing;
set
{
bool wasLeft = facing.x < 0f;
facing = value;
if (BodyCollider != null && wasLeft != (facing.x < 0f)) ApplyColliderCenters();
}
}
public Vector2 Position => Ground(Body.position);
public Vector2 Center => Position + Offset + BodyCenterOffset;
public float Radius { get; private set; }
public Vector2 Offset { get; private set; }
public Vector2 BodyCenterOffset => Facing.x < 0f
? new Vector2(-unflippedBodyCenterOffset.x, unflippedBodyCenterOffset.y)
: unflippedBodyCenterOffset;
public float FootOffset { get; private set; }
public float CurrentHealth { get; private set; }
public float CurrentShield => world.GameTime < shieldExpiresAt ? shieldAmount : 0f;
public bool IsAlive => isActiveAndEnabled && CurrentHealth > 0f;
public bool IsHurt => world.GameTime < hurtUntil;
public bool IsGuarding => Kind == ComparisonActorKind.Player && IsAlive
&& world.GameTime < guardUntil;
public float GuardCooldownRemaining => Mathf.Max(0f, guardCooldownUntil - world.GameTime);
public float GuardReadinessNormalized => GameplayConstants.Current.Player.GuardCooldownDuration > 0f
? 1f - Mathf.Clamp01(GuardCooldownRemaining / GameplayConstants.Current.Player.GuardCooldownDuration)
: 1f;
public bool HasKnockback => knockbackVelocity.sqrMagnitude > .0025f;
public int DamageEvents { get; private set; }
public int ContactCallbacks { get; private set; }
public bool MovementBlocked { get; private set; }
public Vector2 LastMove { get; private set; }
public Collider LastMovementBlocker { get; private set; }
public Func<Vector2, Vector2> ClosestPointFunction => closestPoint;
public override float MoveSpeed => Evaluate(CharacterStat.MoveSpeed, Kind switch
{
ComparisonActorKind.Player => GameplayConstants.Current.Player.BaseMoveSpeed,
ComparisonActorKind.Enemy => world.Settings.Enemy.MoveSpeed,
_ => GameplayConstants.Current.Defense.SummonUnitMoveSpeed,
});
public override float MaxHealth => Evaluate(CharacterStat.MaxHealth, Kind switch
{
ComparisonActorKind.Player => GameplayConstants.Current.Player.BaseMaxHealth,
ComparisonActorKind.Enemy => enemyMaximumHealthOverride > 0f
? enemyMaximumHealthOverride : world.Settings.Enemy.MaxHealth,
_ => GameplayConstants.Current.Defense.SummonUnitMaximumHealth,
});
public static Vector2 Ground(Vector3 point) => new(point.x, point.z);
public static Vector3 OnGround(Vector2 point, float y = 0f) => new(point.x, y, point.y);
public void Configure(Defense25DComparison owner, ComparisonActorKind kind)
{
world = owner;
Kind = kind;
IsMinion = kind == ComparisonActorKind.Summon;
Defense25DComparisonSettings s = owner.Settings;
Radius = kind == ComparisonActorKind.Player ? s.PlayerRadius
: kind == ComparisonActorKind.Enemy ? s.EnemyRadius
: s.SummonVisualScale * Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels
/ Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f;
Offset = kind == ComparisonActorKind.Player ? s.PlayerColliderOffset
: kind == ComparisonActorKind.Enemy ? s.EnemyColliderOffset : Vector2.zero;
Vector2 initialCenterPixels = kind == ComparisonActorKind.Enemy
? new Vector2(0f, s.EnemyBodyCenterCorrectionPixels)
: kind == ComparisonActorKind.Summon
? Defense25DComparisonSettings.SlimeBodyCenterFromFootPixels
: Vector2.zero;
float initialScale = kind == ComparisonActorKind.Player ? s.PlayerVisualScale
: kind == ComparisonActorKind.Enemy ? s.EnemyVisualScale : s.SummonVisualScale;
float initialHeight = kind == ComparisonActorKind.Summon ? 2f * Radius : Mathf.Max(s.ActorHeight, 2f * Radius);
Vector2 initialScreenCenter = initialCenterPixels
* (kind == ComparisonActorKind.Enemy
? 2f * s.CameraSize / Defense25DView.Height
: initialScale / Defense25DComparisonSettings.SummonBodyPixelsPerUnit);
Camera camera = owner.View != null ? owner.View.Camera : null;
Quaternion projectionRotation = BodyProjectionRotation(camera, s.CameraPitch);
Vector3 cameraUp = projectionRotation * Vector3.up;
if (kind == ComparisonActorKind.Enemy)
initialScreenCenter.y += cameraUp.y * initialHeight * .5f;
unflippedBodyCenterOffset = kind == ComparisonActorKind.Player
? Vector2.zero
: ScreenCenterOffset(initialScreenCenter, initialHeight * .5f, camera, s.CameraPitch);
FootOffset = kind == ComparisonActorKind.Player ? s.PlayerFootOffset
: kind == ComparisonActorKind.Enemy ? s.EnemyFootOffset : s.SummonFootOffset;
gameObject.layer = Defense25DComparisonSettings.ActorLayer;
Body = gameObject.AddComponent<Rigidbody>();
Body.isKinematic = true;
Body.useGravity = false;
Body.constraints = RigidbodyConstraints.FreezePositionY | RigidbodyConstraints.FreezeRotation;
Body.interpolation = RigidbodyInterpolation.Interpolate;
Body.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
BodyCollider = gameObject.AddComponent<CapsuleCollider>();
BodyCollider.radius = Radius;
BodyCollider.height = kind == ComparisonActorKind.Summon ? 2f * Radius : Mathf.Max(s.ActorHeight, 2f * Radius);
ApplyColliderCenters();
sensor = gameObject.AddComponent<CapsuleCollider>();
sensor.center = BodyCollider.center;
sensor.radius = Radius + s.ContactSensorMargin;
sensor.height = BodyCollider.height + 2f * s.ContactSensorMargin;
sensor.isTrigger = true;
closestPoint = ClosestGroundPoint;
Visual = gameObject.AddComponent<Defense25DSprite>();
Visual.Configure(world, this);
ResetAt(Ground(transform.position));
}
public void ResetAt(Vector2 position)
{
foreach (AppliedStatModifier modifier in GetAppliedModifiers()) RemoveModifiersFromSource(modifier.SourceId);
gameObject.SetActive(true);
enabled = true;
CurrentHealth = MaxHealth;
shieldAmount = shieldExpiresAt = 0f;
guardUntil = guardCooldownUntil = 0f;
guardHasBlocked = false;
DamageEvents = ContactCallbacks = 0;
hurtUntil = invulnerableUntil = 0f;
knockbackVelocity = LastMove = Vector2.zero;
knockbackRemaining = 0f;
knockbackCompletionPending = false;
MovementBlocked = false;
Facing = Vector2.right;
Body.position = OnGround(position, world.Settings.GroundY);
Body.rotation = Quaternion.identity;
transform.position = Body.position;
BodyCollider.enabled = sensor.enabled = true;
Visual.ResetVisual();
}
public void SetEnemyMaximumHealth(float maximumHealth)
{
if (Kind == ComparisonActorKind.Enemy)
enemyMaximumHealthOverride = Mathf.Max(0f, maximumHealth);
}
public void SetSummonVariantScale(float scale, float coreDiameterPixels, Vector2 bodyCenterFromFootPixels)
{
if (Kind != ComparisonActorKind.Summon) return;
float pixelsPerUnit = Defense25DComparisonSettings.SummonBodyPixelsPerUnit;
Radius = coreDiameterPixels * scale / pixelsPerUnit * .5f;
unflippedBodyCenterOffset = ScreenCenterOffset(bodyCenterFromFootPixels * (scale / pixelsPerUnit), Radius,
world.View != null ? world.View.Camera : null, world.Settings.CameraPitch);
BodyCollider.radius = Radius;
BodyCollider.height = 2f * Radius;
ApplyColliderCenters();
sensor.radius = Radius + world.Settings.ContactSensorMargin;
sensor.height = BodyCollider.height + 2f * world.Settings.ContactSensorMargin;
}
private void ApplyColliderCenters()
{
Vector3 center = new(Offset.x + BodyCenterOffset.x, BodyCollider.height * .5f,
Offset.y + BodyCenterOffset.y);
BodyCollider.center = center;
if (sensor != null) sensor.center = center;
}
private static Vector2 ScreenCenterOffset(Vector2 screenOffset, float colliderCenterY,
Camera camera, float cameraPitch)
{
Quaternion rotation = BodyProjectionRotation(camera, cameraPitch);
Vector3 right = rotation * Vector3.right;
Vector3 up = rotation * Vector3.up;
float targetRight = screenOffset.x;
float targetUp = screenOffset.y - up.y * colliderCenterY;
float determinant = right.x * up.z - right.z * up.x;
return new Vector2(
(targetRight * up.z - right.z * targetUp) / determinant,
(right.x * targetUp - targetRight * up.x) / determinant);
}
private static Quaternion BodyProjectionRotation(Camera camera, float cameraPitch)
{
Quaternion fallback = Quaternion.Euler(cameraPitch, 0f, 0f);
if (camera == null) return fallback;
Quaternion current = camera.transform.rotation;
Vector3 right = current * Vector3.right;
Vector3 up = current * Vector3.up;
float determinant = right.x * up.z - right.z * up.x;
return Mathf.Abs(determinant) > .0001f ? current : fallback;
}
public Vector2 ClosestGroundPoint(Vector2 point) => Center + Vector2.ClampMagnitude(point - Center, Radius);
public float GapTo(Defense25DActor other) => Vector2.Distance(Center, other.Center) - Radius - other.Radius;
public bool TakeDamage(float rawDamage, Vector2 direction, float knockback = 0f,
EnemyDamageSource source = EnemyDamageSource.Player, bool ordinaryBump = false)
{
return TakeDamageWithResult(rawDamage, direction, knockback, source, ordinaryBump, out _);
}
public bool TakeDamageWithResult(float rawDamage, Vector2 direction, float knockback,
EnemyDamageSource source, bool ordinaryBump, out float actualDamage)
{
actualDamage = 0f;
if (!IsAlive || !world.IsRunning || world.Paused
|| (Kind == ComparisonActorKind.Summon && !world.TowersOperational)) return false;
Defense25DEnemy enemy = Kind == ComparisonActorKind.Enemy
? GetComponent<Defense25DEnemy>() : null;
float incoming = CalculateIncomingDamage(rawDamage);
if (enemy != null) incoming = enemy.ApplyDamageTakenEffects(incoming, ordinaryBump);
float applied = DamageCalculator.FinalizeDamage(incoming);
if (applied <= 0f) return false;
if (Kind == ComparisonActorKind.Player && IsGuarding)
{
if (!guardHasBlocked)
{
guardHasBlocked = true;
PlayerConstants player = GameplayConstants.Current.Player;
float recharge = player.GuardCooldownDuration
* Mathf.Clamp01(player.GuardSuccessCooldownRechargePercent / 100f);
guardCooldownUntil = Mathf.Max(world.GameTime, guardCooldownUntil - recharge);
}
world.CombatFeedback?.NotifyGuardImpact(direction);
return false;
}
if (Kind == ComparisonActorKind.Player && world.GameTime < invulnerableUntil)
return false;
enemy?.SetLastDamageSource(source);
if (Kind == ComparisonActorKind.Player && CurrentShield > 0f)
{
float absorbed = Mathf.Min(shieldAmount, applied);
shieldAmount -= absorbed;
applied -= absorbed;
if (applied <= 0f)
{
GrantInvulnerability(GameplayConstants.Current.Player.InvulnerabilityDuration);
return true;
}
}
float healthBefore = CurrentHealth;
CurrentHealth = Mathf.Max(0f, CurrentHealth - applied);
actualDamage = healthBefore - CurrentHealth;
DamageEvents++;
if (Kind == ComparisonActorKind.Player)
{
invulnerableUntil = world.GameTime + GameplayConstants.Current.Player.InvulnerabilityDuration;
hurtUntil = world.GameTime + GameplayConstants.Current.Player.HurtMovementLockDuration;
world.CancelCyclone();
world.NotifyPlayerDamageTaken(healthBefore - CurrentHealth);
if (actualDamage > 0f)
{
Vector3 popupPosition = transform.position;
if (world.View != null) popupPosition += world.View.transform.up * .75f;
world.NotifyPlayerDamagePopup(actualDamage, popupPosition);
world.CombatFeedback?.NotifyPlayerHurt(transform.position, direction, knockback);
}
}
ApplyKnockback(direction, knockback);
Visual.ShowHurt();
if (CurrentHealth <= 0f)
{
StopBody();
if (Kind == ComparisonActorKind.Player || Kind == ComparisonActorKind.Enemy) Visual.ShowDeath();
else Visual.Hide();
BodyCollider.enabled = sensor.enabled = false;
world.NotifyDeath(this);
}
return true;
}
public void GrantInvulnerability(float duration) => invulnerableUntil = world.GameTime + duration;
public bool TryActivateGuard()
{
PlayerConstants tuning = GameplayConstants.Current.Player;
if (Kind != ComparisonActorKind.Player || !isActiveAndEnabled || !IsAlive
|| Time.timeScale <= 0f || !world.IsRunning || world.Paused || IsHurt || IsGuarding
|| GuardCooldownRemaining > 0f)
return false;
guardHasBlocked = false;
guardUntil = world.GameTime + tuning.GuardDuration;
guardCooldownUntil = world.GameTime + tuning.GuardCooldownDuration;
return true;
}
public bool Heal(float amount)
{
if (Kind != ComparisonActorKind.Player || !IsAlive || amount <= 0f) return false;
float before = CurrentHealth;
CurrentHealth = Mathf.Min(MaxHealth, CurrentHealth + amount);
return CurrentHealth > before;
}
public bool GrantShield(float amount, float duration)
{
if (Kind != ComparisonActorKind.Player || !IsAlive || amount <= 0f || duration <= 0f)
return false;
shieldAmount = amount;
shieldExpiresAt = world.GameTime + duration;
return true;
}
public void ApplyKnockback(Vector2 direction, float magnitude)
{
if (magnitude <= 0f) return;
knockbackStartPosition = Position;
knockbackCompletionPending = true;
// Existing enemy force is a velocity; the player's value is a distance.
float speed = Kind == ComparisonActorKind.Player
? magnitude / Mathf.Max(.001f, GameplayConstants.Current.Player.KnockbackDuration) : magnitude;
knockbackVelocity = direction.normalized * speed;
knockbackRemaining = GameplayConstants.Current.Player.KnockbackDuration;
}
public Vector2 ConsumeKnockback(float dt)
{
Vector2 delta = knockbackVelocity * (Kind == ComparisonActorKind.Player ? Mathf.Min(dt, knockbackRemaining) : dt);
if (Kind == ComparisonActorKind.Player)
{
knockbackRemaining = Mathf.Max(0f, knockbackRemaining - dt);
if (knockbackRemaining <= 0f) knockbackVelocity = Vector2.zero;
}
else knockbackVelocity = Vector2.MoveTowards(knockbackVelocity, Vector2.zero,
GameplayConstants.Current.Enemies.KnockbackDeceleration * dt);
return delta;
}
public Vector2 Move(Vector2 displacement, bool slide = true)
{
Vector2 start = Position;
Vector2 next = start;
Vector2 remainder = displacement;
MovementBlocked = false;
LastMovementBlocker = null;
for (int pass = 0; pass < (slide ? 3 : 1) && remainder.sqrMagnitude > .00000001f; pass++)
{
float length = remainder.magnitude;
Vector3 direction = OnGround(remainder / length);
float cylinder = Mathf.Max(0f, BodyCollider.height * .5f - Radius);
Vector3 center = OnGround(next + Offset + BodyCenterOffset,
world.Settings.GroundY + BodyCollider.center.y);
int count = Physics.CapsuleCastNonAlloc(center + Vector3.up * cylinder,
center - Vector3.up * cylinder, Radius, direction, movementHits,
length + world.Settings.MovementSkin,
Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask,
QueryTriggerInteraction.Ignore);
float allowed = length;
Vector2 normal = Vector2.zero;
for (int i = 0; i < count; i++)
{
RaycastHit hit = movementHits[i];
if (hit.collider.attachedRigidbody == Body) continue;
Defense25DActor other = hit.collider.GetComponentInParent<Defense25DActor>();
if (other != null && (Kind == ComparisonActorKind.Enemy || other.Kind == ComparisonActorKind.Enemy)) continue;
if (other != null && Ignores(other)) continue;
Vector2 hitNormal = Ground(hit.normal).normalized;
if (Vector2.Dot(remainder, hitNormal) >= -.00001f) continue;
float distance = Mathf.Max(0f, hit.distance - world.Settings.MovementSkin);
if (distance < allowed)
{
allowed = distance;
normal = hitNormal;
LastMovementBlocker = hit.collider;
}
}
next += remainder / length * allowed;
if (allowed >= length) break;
MovementBlocked = true;
Vector2 remaining = remainder / length * (length - allowed);
remainder = remaining - normal * Mathf.Min(0f, Vector2.Dot(remaining, normal));
}
Body.MovePosition(OnGround(next, world.Settings.GroundY));
LastMove = next - start;
if (knockbackCompletionPending && !HasKnockback)
{
knockbackCompletionPending = false;
world.CombatFeedback?.NotifyKnockbackCompleted(this, knockbackStartPosition, next);
}
return LastMove;
}
public bool Ignores(Defense25DActor other) =>
(Kind == ComparisonActorKind.Player && other.IsMinion)
|| (IsMinion && other.Kind == ComparisonActorKind.Player);
public void IgnorePhysicalPair(Defense25DActor other)
{
foreach (Collider mine in GetComponents<Collider>())
foreach (Collider theirs in other.GetComponents<Collider>()) Physics.IgnoreCollision(mine, theirs);
}
public void StopBody()
{
knockbackVelocity = LastMove = Vector2.zero;
knockbackRemaining = 0f;
knockbackCompletionPending = false;
BodyCollider.enabled = sensor.enabled = false;
}
public void RestoreLiveCollisionAndVisuals()
{
if (!isActiveAndEnabled || CurrentHealth <= 0f) return;
BodyCollider.enabled = sensor.enabled = true;
Visual.RestoreVisibility();
}
private void OnTriggerEnter(Collider other)
{
if (other.isTrigger || !IsAlive || !world.IsRunning || world.Paused) return;
Defense25DActor actor = other.GetComponentInParent<Defense25DActor>();
if (actor == this || (actor != null && Ignores(actor))) return;
ContactCallbacks++;
}
private void OnDisable()
{
if (Kind == ComparisonActorKind.Player) guardUntil = 0f;
if (BodyCollider != null) StopBody();
if (Visual != null) Visual.Hide();
if (world != null && Kind == ComparisonActorKind.Player) world.CancelCyclone();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38cdcdac9597465fa33d491290a6e574
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7f4860a4f6547628f229a4ef4a81231
@@ -0,0 +1,156 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DArtifactExecutor
{
public bool TryReplay(Defense25DArtifactUse use, Vector3 origin, Vector3 direction, float damageMultiplier)
{
ActiveArtifactDefinition definition = use.Definition;
if (world == null || definition == null || !world.IsRunning || world.Paused
|| world.Player == null || !world.Player.IsAlive || damageMultiplier <= 0f
|| use.Damage <= 0f || use.Effect == ActiveArtifactEffect.Pulse)
return false;
Vector2 replayOrigin = new(origin.x, origin.z);
Vector2 replayDirection = new(direction.x, direction.z);
if (replayDirection.sqrMagnitude < DirectionEpsilon)
replayDirection = use.Effect == ActiveArtifactEffect.ThunderCrash
? GetFacingSector(world.Player.Facing) : world.Player.Facing;
if (replayDirection.sqrMagnitude < DirectionEpsilon) replayDirection = Vector2.right;
replayDirection.Normalize();
var replay = new Cast
{
Use = use,
Identity = castIdentity == int.MaxValue ? 1 : castIdentity + 1,
Origin = replayOrigin,
Direction = replayDirection,
};
castIdentity = replay.Identity;
if (use.Effect == ActiveArtifactEffect.ChainLightning)
return ReplayChain(replay, damageMultiplier);
float range = use.Range;
Vector2 center = replayOrigin;
Vector2 radii = Vector2.one * range;
bool ray = use.Effect is ActiveArtifactEffect.Dash or ActiveArtifactEffect.Phoenix;
float halfWidth = Mathf.Max(.05f, use.Width * .5f);
if (use.Effect == ActiveArtifactEffect.Cyclone)
{
center += CycloneGeometry.CenterOffset(range, use.Charged, world.Settings.PlayerVisualScale);
radii = CycloneGeometry.Radii(range, use.Charged);
}
else if (use.Effect == ActiveArtifactEffect.ThunderCrash)
{
center = replayOrigin + replayDirection * .45f;
radii = new Vector2(17f, 7.25f) / 32f * (ThunderCrashScale * range);
}
int hits = 0;
float igniteDamage = world.Player.CalculateDamage(
(use.Charged ? definition.ChargedIgniteTickDamage : definition.NormalIgniteTickDamage)
* damageMultiplier, definition.DamageTag);
foreach (Defense25DActor target in GetReplayCandidates(
ray ? replayOrigin + replayDirection * (range * .5f) : center,
Mathf.Max(radii.x, radii.y, range)))
{
if (hits >= use.MaxTargets) break;
Vector2 offset = target.Position - replayOrigin;
bool inside = ray
? Vector2.Dot(offset, replayDirection) >= 0f
&& Vector2.Dot(offset, replayDirection) <= range
&& Mathf.Abs(Vector2.Dot(offset, new Vector2(-replayDirection.y, replayDirection.x))) <= halfWidth
: EllipseDistance(target.Position - center, radii) <= 1.0001f;
if (!inside) continue;
Vector2 hitDirection = ray ? replayDirection : target.Position - center;
if (hitDirection.sqrMagnitude < DirectionEpsilon) hitDirection = replayDirection;
if (ApplyReplayDamage(replay, target, use.Damage * damageMultiplier,
hitDirection.normalized, use.Knockback, igniteDamage))
hits++;
}
return hits > 0;
}
private bool ReplayChain(Cast cast, float damageMultiplier)
{
if (!PlanChain(cast)) return false;
ActiveArtifactDefinition definition = cast.Use.Definition;
float igniteDamage = world.Player.CalculateDamage(
(cast.Use.Charged ? definition.ChargedIgniteTickDamage : definition.NormalIgniteTickDamage)
* damageMultiplier, definition.DamageTag);
var strikes = new List<ChainStrike>(cast.Strikes);
bool applied = false;
foreach (ChainStrike strike in strikes)
{
Vector2 hitDirection = strike.End - strike.Start;
if (hitDirection.sqrMagnitude < DirectionEpsilon) hitDirection = cast.Direction;
applied |= ApplyReplayDamage(cast, strike.Target, strike.Damage * damageMultiplier,
hitDirection, cast.Use.Knockback, igniteDamage);
}
DestroyCast(cast);
return applied;
}
private List<Defense25DActor> GetReplayCandidates(Vector2 center, float radius)
{
var result = new List<Defense25DActor>(Query(center, radius, null));
result.Sort((left, right) =>
(left.Position - center).sqrMagnitude.CompareTo((right.Position - center).sqrMagnitude));
return result;
}
private bool ApplyReplayDamage(Cast cast, Defense25DActor target, float rawDamage,
Vector2 direction, float knockback, float igniteTickDamage)
{
if (target == null || !target.IsAlive || rawDamage <= 0f) return false;
ActiveArtifactDefinition d = cast.Use.Definition;
direction = direction.sqrMagnitude > DirectionEpsilon ? direction.normalized : cast.Direction;
float scaledDamage = world.Player.CalculateDamage(rawDamage, d.DamageTag);
Defense25DEnemy enemy = target.GetComponent<Defense25DEnemy>();
if (enemy != null && RegisterArtifactContact != null)
{
Defense25DArtifactContact contact = RegisterArtifactContact(enemy, d.ArtifactColor, cast.Identity);
if (contact.Result == EnemyArtifactContactResult.Ignored) return false;
if (contact.Result == EnemyArtifactContactResult.Shielded) return false;
}
if (!target.TakeDamage(scaledDamage, direction, knockback)) return false;
float incomingDamage = DamageCalculator.FinalizeDamage(target.CalculateIncomingDamage(scaledDamage));
if (enemy != null)
{
bool secondary = d.Effect switch
{
ActiveArtifactEffect.Phoenix =>
(cast.Use.Charged ? d.ChargedIgniteDuration : d.NormalIgniteDuration) > 0f,
ActiveArtifactEffect.ThunderCrash =>
(cast.Use.Charged ? d.ChargedVulnerabilityDuration : d.NormalVulnerabilityDuration) > 0f,
ActiveArtifactEffect.ChainLightning =>
UnityEngine.Random.value < (cast.Use.Charged ? d.ChargedShockChance : d.NormalShockChance),
_ => false,
};
if (secondary)
ApplySecondaryEffect?.Invoke(enemy, d, cast.Use.Charged, igniteTickDamage);
CombatEvents.RaiseValidHit(new CombatHitResult(
world.Player.gameObject, target.gameObject, false, HitSide.Front,
incomingDamage, direction, knockback,
target.Position, damageTag: d.DamageTag,
sourceId: Defense25DRelicRuntime.EchoSourceId, usesPositionBonus: false,
showsHitFeedback: incomingDamage > 0f, isArtifactHit: true,
artifactEffect: d.Effect, isChargedArtifact: cast.Use.Charged,
baseBumpDamage: world.Player.CalculateDamage(
GameplayConstants.Current.Combat.BaseDamage, DamageTag.Collision)));
}
target.Visual?.ShowHurt();
return true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 09c2eaf519a34c1e9e61bc3598716b7b
@@ -0,0 +1,882 @@
using System;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
/// <summary>Restores the original combat's sprite feedback on the Physics3D presentation.</summary>
[DisallowMultipleComponent]
public sealed class Defense25DCombatFeedback : MonoBehaviour
{
private sealed class SpriteEffect
{
public GameObject Root;
public SpriteRenderer Renderer;
public Sprite[] Frames;
public IReadOnlyList<float> Durations;
public int Frame;
public float Elapsed;
public bool Loop;
public float Alpha = 1f;
public EffectKind Kind;
public LineRenderer Line;
public float Duration;
public float Hold;
public float Fade;
public bool Heavy;
public bool Unscaled;
public bool PoolLineWhenRemoved;
public bool FollowPlayer;
public bool FollowVisualGroundAnchor;
public Vector2 FollowGroundOffset;
public bool Expand;
public Vector3 MoveStart;
public Vector3 MoveEnd;
public float FollowHeight;
public SpriteRenderer[] Ghosts;
public float[] GhostAlphas;
}
private readonly List<SpriteEffect> effects = new();
private readonly List<SpriteEffect> heavyEffects = new();
private readonly Stack<GameObject> lineEffectPool = new();
private readonly Dictionary<string, Sprite[]> frameCache = new();
private Defense25DComparison world;
private SpriteEffect aura;
private SpriteEffect readyAura;
private SpriteEffect chargeFlash;
private SpriteEffect switchPulse;
private SpriteEffect guardImpact;
private SpriteEffect blockedBump;
private SpriteRenderer playerRenderer;
private float flashRemaining;
private float blinkRemaining;
private float blinkElapsed;
private float hitStopUntil;
private float cameraShakeUntil;
private float cameraShakeDuration;
private Color flashColor = Color.white;
private bool directionalPunch;
private float lastRearPunchAt = float.NegativeInfinity;
private float nextGuardImpactAt = float.NegativeInfinity;
private float capturedTimeScale = 1f;
private bool ownsTimeFreeze;
private Vector2 shakeDirection;
private float shakePixels;
private Material transparentEffectMaterial;
private bool bound;
public int ActiveEffectCount => effects.Count;
public int ActiveHeavyEffectCount => heavyEffects.Count;
public int ActiveBumpImpactCount { get; private set; }
public int ActiveArtifactImpactCount { get; private set; }
public int ActivePlayerHurtImpactCount { get; private set; }
public bool IsChargingVisualVisible => aura != null || readyAura != null;
public bool IsReadyFlashVisible => chargeFlash != null;
public Material TransparentEffectMaterial => transparentEffectMaterial;
public void Bind(Defense25DComparison owner)
{
if (bound) Unbind();
world = owner;
if (world == null || !world.isActiveAndEnabled || world.Player == null) return;
bound = true;
capturedTimeScale = Time.timeScale > 0f ? Time.timeScale : 1f;
playerRenderer = world.Player.Visual != null ? world.Player.Visual.Renderer : null;
if (world.Settings.TransparentMaterial != null)
{
transparentEffectMaterial = new Material(world.Settings.TransparentMaterial)
{
name = "Defense25D Combat Feedback Transparent",
};
if (transparentEffectMaterial.HasProperty("_BaseColor"))
transparentEffectMaterial.SetColor("_BaseColor", Color.white);
if (transparentEffectMaterial.HasProperty("_Cutoff"))
transparentEffectMaterial.SetFloat("_Cutoff", .001f);
}
CombatEvents.OnValidHit += HandleValidHit;
}
private void OnEnable()
{
if (world != null && world.Player != null && !bound) Bind(world);
}
private void Update()
{
TickFeedback(Time.deltaTime, Time.unscaledDeltaTime);
}
/// <summary>Advances scaled sprite effects and unscaled camera/hit-stop timers.</summary>
public void TickFeedback(float scaledDeltaTime, float unscaledDeltaTime)
{
if (!bound || world == null) return;
if (!world.isActiveAndEnabled)
{
Unbind();
return;
}
scaledDeltaTime = Mathf.Max(0f, scaledDeltaTime);
if (world.Paused) scaledDeltaTime = 0f;
unscaledDeltaTime = Mathf.Max(0f, unscaledDeltaTime);
AdvanceEffects(scaledDeltaTime, unscaledDeltaTime);
AdvanceChargeVisuals(scaledDeltaTime);
if (flashRemaining > 0f) flashRemaining = Mathf.Max(0f, flashRemaining - unscaledDeltaTime);
if (blinkRemaining > 0f)
{
blinkRemaining = Mathf.Max(0f, blinkRemaining - scaledDeltaTime);
blinkElapsed += scaledDeltaTime;
}
ApplyPlayerTint();
if (hitStopUntil > 0f && Time.unscaledTime >= hitStopUntil)
{
hitStopUntil = 0f;
RestoreOwnedTimeFreeze();
}
if (cameraShakeUntil > 0f)
{
if (Time.unscaledTime >= cameraShakeUntil)
{
cameraShakeUntil = 0f;
if (world.View != null) world.View.ClearFeedbackCameraOffset();
}
else if (world.View != null)
{
float fade = Mathf.Clamp01((cameraShakeUntil - Time.unscaledTime)
/ Mathf.Max(.001f, cameraShakeDuration));
if (directionalPunch)
{
Vector2 punch = shakeDirection * shakePixels * fade;
world.View.SetFeedbackCameraOffset(new Vector2(Mathf.Round(punch.x), Mathf.Round(punch.y)));
}
else
{
Vector2 jitter = UnityEngine.Random.insideUnitCircle * shakePixels * fade;
world.View.SetFeedbackCameraOffset(new Vector2(Mathf.Round(jitter.x), Mathf.Round(jitter.y)));
}
}
}
}
public void NotifyValidHit(CombatHitResult result)
{
if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null
|| result.Attacker != world.Player.gameObject || result.Target == null) return;
// Per-contact artifact flashes are deliberately emitted before this feedback gate,
// matching CombatFeedback.HandleValidHit for zero-damage/hidden contacts.
bool artifactVisualCreated = result.IsArtifactHit && CreateArtifactImpact(result);
if (!result.ShowsHitFeedback) return;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
if (result.IsOrdinaryBump && result.HasBackBonus)
{
float now = Time.time;
if (now - lastRearPunchAt >= tuning.RearHitCameraPunchRearmDelay)
{
if (cameraShakeUntil <= Time.unscaledTime
&& result.KnockbackDirection.sqrMagnitude > Mathf.Epsilon)
{
shakePixels = tuning.RearHitCameraPunchPixels;
shakeDirection = result.KnockbackDirection.normalized;
cameraShakeDuration = tuning.RearHitCameraPunchDuration;
cameraShakeUntil = Time.unscaledTime + tuning.RearHitCameraPunchDuration;
directionalPunch = true;
}
}
lastRearPunchAt = now;
}
Color flash = HitColor(result);
flashColor = Color.Lerp(Color.white, flash, .55f);
if (!artifactVisualCreated && result.IsOrdinaryBump)
CreateImpact(result.HitPosition, BumpFrames(result.Side), Defense25DCombatFeedbackConstants.BumpFrames,
2f, Color.white, result.KnockbackDirection, "Bump Impact", EffectKind.Bump,
true, 102);
if (result.KnockbackForce > 0f)
CreateFloorStreak(result.HitPosition, result.KnockbackDirection, flash);
flashRemaining = Mathf.Max(flashRemaining, tuning.FlashDuration);
RequestHitStop(result.IsStrongDash ? tuning.StrongDashHitStopDuration
: result.IsDash ? tuning.DashHitStopDuration
: result.HasBackBonus ? tuning.BackHitStopDuration
: tuning.NormalHitStopDuration);
if (result.IsStrongDash) RequestCameraShake(tuning.HeavyCameraShakePixels, result.KnockbackDirection);
}
public void NotifyPlayerHurt(Vector2 groundPosition, bool heavy) =>
NotifyPlayerHurt(groundPosition, Vector2.zero, heavy);
public void NotifyPlayerHurt(Vector2 groundPosition, Vector2 knockbackDirection, bool heavy)
{
if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null) return;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
Vector2 origin = groundPosition;
if (origin == Vector2.zero)
origin = world.Player.Position;
if (knockbackDirection.sqrMagnitude > 0f) origin -= knockbackDirection.normalized * .18f;
CreateImpact(origin, LoadFrames(CombatFeedback.GetPlayerHurtImpactResourcePath()),
Defense25DCombatFeedbackConstants.PlayerHurtFrames, 1f, Color.white, Vector2.zero, "Player Hurt Impact", EffectKind.Hurt);
flashRemaining = Mathf.Max(flashRemaining, tuning.DamageFlashDuration);
flashColor = Color.Lerp(Color.white, new Color(1f, .18f, .12f, 1f), .55f);
blinkRemaining = Mathf.Max(blinkRemaining, GameplayConstants.Current.Player.InvulnerabilityDuration);
blinkElapsed = 0f;
RequestHitStop(tuning.DamageHitStopDuration);
RequestCameraShake(heavy ? tuning.HeavyCameraShakePixels : tuning.CameraShakePixels, Vector2.zero);
}
public void NotifyPlayerHurt(Vector3 worldPosition, bool heavy) =>
NotifyPlayerHurt(new Vector2(worldPosition.x, worldPosition.z), heavy);
public void NotifyPlayerHurt(Vector3 worldPosition, Vector2 knockbackDirection, float knockbackDistance)
{
float threshold = GameplayConstants.Current.CombatFeedback.HeavyKnockbackThreshold;
NotifyPlayerHurt(new Vector2(worldPosition.x, worldPosition.z), knockbackDirection,
knockbackDistance >= threshold);
}
public void NotifyChargeChanged(ActiveArtifactDefinition definition, float normalized, bool becameFull)
{
if (!bound || world == null || !world.isActiveAndEnabled || definition == null || world.Player == null) return;
normalized = Mathf.Clamp01(normalized);
if (normalized <= 0f)
{
ClearChargeVisuals();
return;
}
if (normalized >= 1f)
{
ClearEffect(ref aura);
EnsureChargeSlot(ref readyAura, definition, Defense25DCombatFeedbackConstants.ChargeReadyAuraSuffix, true, Defense25DCombatFeedbackConstants.ChargeAuraFrames, -1);
if (becameFull)
{
EnsureChargeSlot(ref chargeFlash, definition, Defense25DCombatFeedbackConstants.ChargeFlashSuffix, false, Defense25DCombatFeedbackConstants.ChargeFlashFrames, 1,
ArtifactChargeVisual.FlashFrameCount);
if (chargeFlash != null) chargeFlash.Loop = false;
}
return;
}
ClearEffect(ref readyAura);
ClearEffect(ref chargeFlash);
EnsureChargeSlot(ref aura, definition, Defense25DCombatFeedbackConstants.ChargeAuraSuffix, true, Defense25DCombatFeedbackConstants.ChargeAuraFrames, -1,
ArtifactChargeVisual.FrameCount);
if (aura != null) aura.Alpha = Mathf.Lerp(.55f, .85f, normalized);
}
public void NotifyArtifactReleased(ActiveArtifactDefinition definition, Vector2 groundOrigin, bool charged)
{
if (!bound || world == null || !world.isActiveAndEnabled || definition == null) return;
string path = Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + Defense25DCombatFeedbackConstants.ChargeReleaseSuffix;
Sprite[] frames = LoadFrames(path);
if (frames.Length == ArtifactChargeVisual.FrameCount)
CreateImpact(groundOrigin, frames, Defense25DCombatFeedbackConstants.ChargeReleaseFrames, 1f, Color.white,
Vector2.zero, "Artifact Release", EffectKind.Release, false, 1);
ClearChargeVisuals();
}
public void NotifyArtifactSwitched(ActiveArtifactDefinition definition)
{
if (!bound || world == null || !world.isActiveAndEnabled || definition == null || playerRenderer == null) return;
EnsureChargeSlot(ref switchPulse, definition, Defense25DCombatFeedbackConstants.ChargeReleaseSuffix, false, Defense25DCombatFeedbackConstants.ChargeAuraFrames, 1,
ArtifactChargeVisual.FrameCount);
if (switchPulse != null) switchPulse.Loop = false;
}
public void NotifyGuardImpact(Vector2 incomingDirection)
{
if (!bound || world == null || !world.isActiveAndEnabled || world.Player == null || playerRenderer == null
|| Time.time < nextGuardImpactAt) return;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
Sprite sprite = Resources.Load<Sprite>(CombatFeedback.GetGuardBlockImpactResourcePath());
if (sprite == null) return;
nextGuardImpactAt = Time.time + tuning.GuardBlockImpactInterval;
Vector2 direction = incomingDirection.sqrMagnitude > 0f
? incomingDirection.normalized : Vector2.zero;
Vector2 position = world.Player.Center - direction * tuning.GuardBlockImpactOffset;
SpriteEffect effect = CreateStaticSprite("Player Guard Block Impact", sprite,
ToWorld(position), playerRenderer.sortingOrder + 20, EffectKind.Guard);
if (effect != null)
{
effect.FollowPlayer = true;
effect.FollowGroundOffset = -direction * tuning.GuardBlockImpactOffset;
effect.Hold = tuning.GuardBlockImpactHoldDuration;
effect.Fade = tuning.GuardBlockImpactFadeDuration;
effect.Duration = effect.Hold + effect.Fade;
guardImpact = effect;
}
}
public void NotifyBlockedBump(Vector2 hitPosition, Vector2 approachDirection)
{
if (!bound || world == null || !world.isActiveAndEnabled || playerRenderer == null) return;
CombatFeedbackSettings tuning = GameplayConstants.Current.CombatFeedback;
Sprite sprite = Resources.Load<Sprite>(Defense25DCombatFeedbackConstants.BlockedBumpResource);
if (sprite == null) return;
Vector3 position = ToWorld(hitPosition) + Vector3.up * tuning.BlockedBumpVerticalOffset;
SpriteEffect effect = CreateStaticSprite("Blocked Bump", sprite, position,
playerRenderer.sortingOrder + 102, EffectKind.Blocked);
if (effect != null)
{
effect.Renderer.flipX = approachDirection.x < 0f;
effect.Hold = tuning.BlockedBumpHoldDuration;
effect.Fade = tuning.BlockedBumpFadeDuration;
effect.Duration = effect.Hold + effect.Fade;
blockedBump = effect;
}
}
public void NotifyTowerImpact(DefenseTowerKind kind, Defense25DEnemy target, Vector2 hitPosition)
{
if (!bound || world == null || !world.isActiveAndEnabled || target == null) return;
string path = CombatFeedback.GetTowerImpactResourcePath(kind);
Sprite[] frames = LoadFrames(path);
if (frames.Length != CombatFeedback.BumpImpactFrameCount) return;
SpriteEffect effect = new();
if (!CreateImpact(hitPosition, frames, Defense25DCombatFeedbackConstants.ArtifactFrames, 2f, Color.white,
Vector2.zero, $"Tower Impact {kind}", EffectKind.Tower, false,
DefenseTowerArtBounds.TowerHitSortingOffset, effect)) return;
SpriteRenderer targetRenderer = target.Actor != null && target.Actor.Visual != null
? target.Actor.Visual.Renderer : null;
if (targetRenderer != null && effect.Renderer != null)
{
effect.Renderer.sortingLayerID = targetRenderer.sortingLayerID;
effect.Renderer.sortingOrder = targetRenderer.sortingOrder
+ DefenseTowerArtBounds.TowerHitSortingOffset;
}
}
public void NotifyShield(float duration)
{
if (!bound || world == null || !world.isActiveAndEnabled || duration <= 0f) return;
SpriteEffect ring = CreateRing(PlayerVisualGroundPosition, .6f,
new Color(.15f, .95f, .95f, 1f), duration, true);
if (ring != null)
{
ring.FollowPlayer = true;
ring.FollowVisualGroundAnchor = true;
}
}
public void NotifyPulseGather(Defense25DEnemy target, Vector2 previousPosition, Vector2 finalPosition)
{
if (!bound || world == null || !world.isActiveAndEnabled || target == null
|| target.Actor == null || target.Actor.Visual == null) return;
float distance = Vector2.Distance(previousPosition, finalPosition);
if (!CombatFeedback.ShouldShowPulseStreak(distance))
{
CreateRing(finalPosition, .26f, new Color(1f, .39f, .39f, 1f), .12f, true);
return;
}
SpriteRenderer source = target.Actor.Visual.Renderer;
if (source == null || source.sprite == null) return;
GameObject root = new("Pulse Gather Afterimage");
root.transform.rotation = world.View != null ? world.View.transform.rotation : Quaternion.identity;
root.transform.localScale = source.transform.lossyScale;
float height = source.transform.position.y - world.Settings.GroundY;
root.transform.position = ToWorld(previousPosition) + Vector3.up * height;
SpriteRenderer first = root.AddComponent<SpriteRenderer>();
CopyGhost(source, first, 2, new Color(1f, .39f, .39f, .65f));
GameObject secondObject = new("Pulse Gather Afterimage 2");
secondObject.transform.SetParent(root.transform, false);
secondObject.transform.localPosition = Vector3.left * .08f;
SpriteRenderer second = secondObject.AddComponent<SpriteRenderer>();
CopyGhost(source, second, 1, new Color(1f, .65f, .65f, .4f));
LineRenderer streak = AcquireLine("Pulse Gather Streak");
CombatFeedback.ConfigureWorldStreakRenderer(streak, transparentEffectMaterial,
new Color(1f, .39f, .39f, .8f), .055f, ToWorld(previousPosition), ToWorld(finalPosition));
SpriteEffect effect = new()
{
Root = root, Line = streak, Frames = Array.Empty<Sprite>(),
Durations = new[] { Defense25DCombatFeedbackConstants.PulseGatherDuration },
Duration = Defense25DCombatFeedbackConstants.PulseGatherDuration,
Unscaled = true, Heavy = true,
MoveStart = ToWorld(previousPosition) + Vector3.up * height,
MoveEnd = ToWorld(finalPosition) + Vector3.up * height,
FollowHeight = height,
Ghosts = new[] { first, second },
GhostAlphas = new[] { .65f, .4f },
};
AddEffect(effect, true);
}
public void NotifyKnockbackCompleted(Defense25DActor target, Vector2 start, Vector2 end)
{
if (!bound || world == null || !world.isActiveAndEnabled || target == null
|| target.Kind != ComparisonActorKind.Enemy
|| !IsOwnedEnemy(target)) return;
if (CombatFeedback.ShouldShowKnockbackDust(Vector2.Distance(start, end)))
CreateRing(end, .2f, new Color(.9f, .8f, 1f, 1f), .13f, true);
}
public void ResetFeedback()
{
ClearAllEffects();
ClearChargeVisuals();
flashRemaining = blinkRemaining = blinkElapsed = hitStopUntil = cameraShakeUntil = 0f;
ActiveBumpImpactCount = ActiveArtifactImpactCount = ActivePlayerHurtImpactCount = 0;
directionalPunch = false;
shakeDirection = Vector2.zero;
shakePixels = cameraShakeDuration = 0f;
if (world != null && world.Player != null && world.Player.Visual != null)
world.Player.Visual.ClearFeedbackTint();
if (world != null && world.View != null) world.View.ClearFeedbackCameraOffset();
RestoreOwnedTimeFreeze();
lastRearPunchAt = float.NegativeInfinity;
nextGuardImpactAt = float.NegativeInfinity;
}
private void HandleValidHit(CombatHitResult result) => NotifyValidHit(result);
private void Unbind()
{
if (bound) CombatEvents.OnValidHit -= HandleValidHit;
bound = false;
ResetFeedback();
while (lineEffectPool.Count > 0)
{
GameObject pooled = lineEffectPool.Pop();
if (pooled != null) Destroy(pooled);
}
frameCache.Clear();
playerRenderer = null;
if (transparentEffectMaterial != null)
{
Destroy(transparentEffectMaterial);
transparentEffectMaterial = null;
}
}
private void OnDisable() => Unbind();
private void OnDestroy() => Unbind();
private void RestoreOwnedTimeFreeze()
{
if (!ownsTimeFreeze) return;
Time.timeScale = capturedTimeScale;
ownsTimeFreeze = false;
}
private enum EffectKind { Generic, Bump, Artifact, Hurt, Release, Charge, Guard, Blocked, Tower }
private bool CreateArtifactImpact(CombatHitResult result)
{
Sprite[] frames = LoadFrames(CombatFeedback.GetArtifactImpactResourcePath(
result.ArtifactEffect, result.IsChargedArtifact));
if (frames.Length != CombatFeedback.ArtifactImpactFrameCount) return false;
Vector2 direction = result.KnockbackDirection.sqrMagnitude > 0f
? result.KnockbackDirection.normalized : Vector2.right;
bool created = CreateImpact(result.HitPosition, frames, Defense25DCombatFeedbackConstants.ArtifactFrames, 2f,
Color.white, direction, "Artifact Impact " + result.ArtifactEffect,
EffectKind.Artifact, true, 102);
return created;
}
private Sprite[] BumpFrames(HitSide side)
{
ActiveArtifactDefinition selected = world.Loadout != null ? world.Loadout.CurrentArtifact : null;
string path = selected != null
? $"Artifacts/ThreeColor-v1/Combat/BumpImpact-{(side == HitSide.Back ? "Strong" : side == HitSide.Side ? "Medium" : "Weak")}-{selected.ArtifactColor}-v1"
: CombatFeedback.GetBumpImpactResourcePath(side);
Sprite[] frames = LoadFrames(path);
return frames.Length == CombatFeedback.BumpImpactFrameCount
? frames : LoadFrames(CombatFeedback.GetBumpImpactResourcePath(side));
}
private bool CreateImpact(Vector2 groundPosition, Sprite[] frames, IReadOnlyList<float> durations,
float scale, Color color, Vector2 direction, string label, EffectKind kind,
bool rotate = false, int sortingOffset = 102, SpriteEffect capture = null)
{
if (frames == null || frames.Length == 0 || world == null || world.Player == null) return false;
SpriteEffect effect = new()
{
Root = new GameObject(label), Frames = frames, Durations = durations,
Loop = false, Renderer = null,
};
effect.Root.transform.position = ToWorld(groundPosition);
effect.Root.transform.localScale = Vector3.one * scale;
if (world.View != null)
{
effect.Root.transform.rotation = world.View.transform.rotation;
if (rotate && direction.sqrMagnitude > .0001f)
{
Vector3 worldDirection = new(direction.x, 0f, direction.y);
Vector3 projected = new(Vector3.Dot(worldDirection, world.View.transform.right),
Vector3.Dot(worldDirection, world.View.transform.up), 0f);
if (projected.sqrMagnitude > .0001f)
effect.Root.transform.rotation *= Quaternion.AngleAxis(
Mathf.Atan2(projected.y, projected.x) * Mathf.Rad2Deg, Vector3.forward);
}
}
effect.Renderer = effect.Root.AddComponent<SpriteRenderer>();
effect.Renderer.sprite = frames[0];
effect.Renderer.color = color;
if (transparentEffectMaterial != null) effect.Renderer.sharedMaterial = transparentEffectMaterial;
if (playerRenderer != null)
{
effect.Renderer.sortingLayerID = playerRenderer.sortingLayerID;
effect.Renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset;
}
effects.Add(effect);
effect.Kind = kind;
if (capture != null)
{
capture.Root = effect.Root;
capture.Renderer = effect.Renderer;
capture.Frames = effect.Frames;
capture.Durations = effect.Durations;
capture.Kind = effect.Kind;
}
if (kind == EffectKind.Bump) ActiveBumpImpactCount++;
if (kind == EffectKind.Artifact) ActiveArtifactImpactCount++;
if (kind == EffectKind.Hurt) ActivePlayerHurtImpactCount++;
return true;
}
private SpriteEffect CreateStaticSprite(string label, Sprite sprite, Vector3 position,
int sortingOffset, EffectKind kind)
{
if (sprite == null || world == null) return null;
GameObject root = new(label);
root.transform.position = position;
if (world.View != null) root.transform.rotation = world.View.transform.rotation;
SpriteRenderer renderer = root.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.color = Color.white;
if (transparentEffectMaterial != null) renderer.sharedMaterial = transparentEffectMaterial;
if (playerRenderer != null)
{
renderer.sortingLayerID = playerRenderer.sortingLayerID;
renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset;
}
SpriteEffect effect = new() { Root = root, Renderer = renderer,
Frames = Array.Empty<Sprite>(), Durations = new[] { .001f }, Kind = kind };
effects.Add(effect);
return effect;
}
private void CreateFloorStreak(Vector2 position, Vector2 direction, Color color)
{
if (direction.sqrMagnitude < .0001f || world == null) return;
LineRenderer line = AcquireLine("Interaction Streak");
GameObject root = line.gameObject;
CombatFeedback.ConfigureWorldStreakRenderer(line, transparentEffectMaterial,
color, .04f, ToWorld(position), ToWorld(position + direction.normalized * .34f));
SpriteEffect effect = new() { Root = root, Line = line,
Duration = Defense25DCombatFeedbackConstants.InteractionStreakDuration,
Durations = new[] { Defense25DCombatFeedbackConstants.InteractionStreakDuration },
Frames = Array.Empty<Sprite>(), Loop = false, Unscaled = true, Heavy = true,
PoolLineWhenRemoved = true };
AddEffect(effect, true);
}
private SpriteEffect CreateRing(Vector2 position, float radius, Color color,
float duration, bool useScaledTime)
{
if (!bound || world == null || duration <= 0f) return null;
LineRenderer line = AcquireLine("Interaction Ring");
GameObject root = line.gameObject;
root.transform.SetPositionAndRotation(ToWorld(position) + Vector3.up * .025f, Quaternion.identity);
root.transform.localScale = Vector3.one * .72f;
line.enabled = true;
line.loop = true;
line.useWorldSpace = false;
line.positionCount = 24;
line.startWidth = line.endWidth = 0f;
line.widthMultiplier = .045f;
line.numCornerVertices = 0;
line.numCapVertices = 2;
line.sharedMaterial = transparentEffectMaterial;
line.startColor = line.endColor = color;
line.sortingOrder = 210;
for (int i = 0; i < line.positionCount; i++)
{
float angle = i * Mathf.PI * 2f / line.positionCount;
line.SetPosition(i, new Vector3(Mathf.Cos(angle) * radius, 0f,
Mathf.Sin(angle) * radius));
}
SpriteEffect effect = new() { Root = root, Line = line, Duration = duration,
Durations = new[] { duration }, Frames = Array.Empty<Sprite>(),
Unscaled = !useScaledTime, Heavy = true, PoolLineWhenRemoved = true,
Expand = true };
AddEffect(effect, true);
return effects.Contains(effect) ? effect : null;
}
private LineRenderer AcquireLine(string label)
{
GameObject root = lineEffectPool.Count > 0 ? lineEffectPool.Pop() : new GameObject(label);
root.name = label;
root.SetActive(true);
LineRenderer line = root.GetComponent<LineRenderer>();
if (line == null) line = root.AddComponent<LineRenderer>();
line.enabled = true;
line.loop = false;
return line;
}
private void AddEffect(SpriteEffect effect, bool heavy)
{
effects.Add(effect);
effect.Heavy = heavy;
if (!heavy) return;
heavyEffects.Add(effect);
int limit = CombatFeedback.MaxConcurrentHeavyEffects;
while (heavyEffects.Count > limit)
{
SpriteEffect oldest = heavyEffects[0];
int index = effects.IndexOf(oldest);
if (index >= 0) RemoveEffectAt(index, oldest);
else heavyEffects.RemoveAt(0);
}
}
private bool IsOwnedEnemy(Defense25DActor actor)
{
foreach (Defense25DEnemy enemy in world.Enemies)
if (enemy != null && enemy.Actor == actor) return true;
return false;
}
private void CopyGhost(SpriteRenderer source, SpriteRenderer ghost, int sortingOffset, Color color)
{
ghost.sprite = source.sprite;
ghost.flipX = source.flipX;
ghost.flipY = source.flipY;
ghost.color = color;
ghost.sharedMaterial = transparentEffectMaterial;
ghost.sortingLayerID = source.sortingLayerID;
ghost.sortingOrder = source.sortingOrder + sortingOffset;
}
private void EnsureChargeSlot(ref SpriteEffect slot, ActiveArtifactDefinition definition,
string suffix, bool loop, IReadOnlyList<float> durations, int sortingOffset, int expectedFrameCount = ArtifactChargeVisual.FrameCount)
{
if (slot != null) return;
Sprite[] frames = LoadFrames(Defense25DCombatFeedbackConstants.ChargeResourceRoot + definition.Effect + suffix);
if (frames.Length != expectedFrameCount || playerRenderer == null) return;
GameObject root = new("Artifact Charge " + suffix);
root.transform.SetParent(playerRenderer.transform, false);
SpriteRenderer renderer = root.AddComponent<SpriteRenderer>();
renderer.sprite = frames[0];
renderer.color = Color.white;
renderer.sharedMaterial = playerRenderer.sharedMaterial;
renderer.sortingLayerID = playerRenderer.sortingLayerID;
renderer.sortingOrder = playerRenderer.sortingOrder + sortingOffset;
if (transparentEffectMaterial != null) renderer.sharedMaterial = transparentEffectMaterial;
slot = new SpriteEffect { Root = root, Renderer = renderer, Frames = frames,
Durations = durations, Loop = loop, Kind = EffectKind.Charge };
effects.Add(slot);
}
private void AdvanceChargeVisuals(float dt)
{
if (aura != null) aura.Renderer.color = new Color(1f, 1f, 1f, aura.Alpha);
}
private void AdvanceEffects(float scaledDeltaTime, float unscaledDeltaTime)
{
for (int i = effects.Count - 1; i >= 0; i--)
{
SpriteEffect effect = effects[i];
if (effect == null || effect.Root == null) { effects.RemoveAt(i); continue; }
float dt = effect.Unscaled ? unscaledDeltaTime : scaledDeltaTime;
if (dt <= 0f) continue;
if (effect.Frames.Length == 0)
{
effect.Elapsed += dt;
float progress = Mathf.Clamp01(effect.Elapsed / Mathf.Max(.001f, effect.Duration));
if (effect.FollowPlayer && world.Player != null)
{
Vector2 followPosition = effect.FollowVisualGroundAnchor
? PlayerVisualGroundPosition : world.Player.Position;
effect.Root.transform.position = ToWorld(followPosition + effect.FollowGroundOffset)
+ Vector3.up * .025f;
}
if (effect.MoveEnd != Vector3.zero)
effect.Root.transform.position = Vector3.Lerp(effect.MoveStart, effect.MoveEnd, progress);
if (effect.Expand)
effect.Root.transform.localScale = Vector3.one * Mathf.Lerp(.72f, 1.2f, progress);
if (effect.Ghosts != null)
for (int g = 0; g < effect.Ghosts.Length; g++)
if (effect.Ghosts[g] != null)
{
Color ghostColor = effect.Ghosts[g].color;
ghostColor.a = effect.GhostAlphas[g] * (1f - progress);
effect.Ghosts[g].color = ghostColor;
}
if (effect.Renderer != null && effect.Duration > 0f)
{
float fadeProgress = effect.Fade > 0f
? Mathf.Clamp01((effect.Elapsed - effect.Hold) / effect.Fade) : 1f;
Color spriteColor = effect.Renderer.color;
spriteColor.a = 1f - fadeProgress;
effect.Renderer.color = spriteColor;
if (effect.Elapsed >= effect.Duration) RemoveEffectAt(i, effect);
continue;
}
if (effect.Line != null)
{
Color color = effect.Line.startColor;
color.a = 1f - progress;
effect.Line.startColor = effect.Line.endColor = color;
}
if (effect.Elapsed >= effect.Duration) RemoveEffectAt(i, effect);
continue;
}
effect.Elapsed += dt;
while (effect.Elapsed >= effect.Durations[Mathf.Clamp(effect.Frame, 0, effect.Durations.Count - 1)])
{
effect.Elapsed -= effect.Durations[Mathf.Clamp(effect.Frame, 0, effect.Durations.Count - 1)];
int next = effect.Frame + 1;
if (next >= effect.Frames.Length)
{
if (!effect.Loop) { RemoveEffectAt(i, effect); break; }
next = 0;
}
effect.Frame = next;
if (effect.Renderer != null) effect.Renderer.sprite = effect.Frames[next];
}
}
}
private void RemoveEffectAt(int index, SpriteEffect effect)
{
if (effect == aura) aura = null;
if (effect == readyAura) readyAura = null;
if (effect == chargeFlash) chargeFlash = null;
if (effect == switchPulse) switchPulse = null;
if (effect == guardImpact) guardImpact = null;
if (effect == blockedBump) blockedBump = null;
if (effect.Kind == EffectKind.Bump) ActiveBumpImpactCount = Mathf.Max(0, ActiveBumpImpactCount - 1);
if (effect.Kind == EffectKind.Artifact) ActiveArtifactImpactCount = Mathf.Max(0, ActiveArtifactImpactCount - 1);
if (effect.Kind == EffectKind.Hurt) ActivePlayerHurtImpactCount = Mathf.Max(0, ActivePlayerHurtImpactCount - 1);
heavyEffects.Remove(effect);
if (effect.PoolLineWhenRemoved && effect.Root != null)
{
RecycleLineRoot(effect.Root, effect.Line);
}
else if (effect.Root != null) Destroy(effect.Root);
effects.RemoveAt(index);
}
private void RecycleLineRoot(GameObject root, LineRenderer line)
{
if (line != null) line.enabled = false;
root.SetActive(false);
lineEffectPool.Push(root);
}
private void ApplyPlayerTint()
{
if (world == null || world.Player == null || world.Player.Visual == null) return;
Color tint = Color.white;
if (blinkRemaining > 0f && GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval > 0f
&& Mathf.FloorToInt(blinkElapsed / GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkInterval) % 2 == 0)
{
float alpha = GameplayConstants.Current.CombatFeedback.InvulnerabilityBlinkAlpha;
tint = new Color(1f, 1f, 1f, alpha);
}
if (flashRemaining > 0f)
{
tint.r = flashColor.r;
tint.g = flashColor.g;
tint.b = flashColor.b;
}
world.Player.Visual.SetFeedbackTint(tint);
}
private Color HitColor(CombatHitResult? hit)
{
if (hit.HasValue && hit.Value.IsArtifactHit)
return ActiveArtifactDefinition.GetArtifactPaletteColor(ActiveArtifactDefinition.GetArtifactColor(hit.Value.ArtifactEffect));
if (world.Loadout != null && world.Loadout.CurrentArtifact != null)
return ActiveArtifactDefinition.GetArtifactPaletteColor(world.Loadout.CurrentArtifact.ArtifactColor);
if (!hit.HasValue) return new Color(1f, .18f, .12f, 1f);
if (hit.Value.IsStrongDash) return new Color(.95f, .5f, 1f, 1f);
if (hit.Value.IsDash) return new Color(.2f, .95f, 1f, 1f);
if (hit.Value.HasBackBonus) return new Color(1f, .55f, .12f, 1f);
if (hit.Value.HasPositionBonus) return new Color(.65f, 1f, .3f, 1f);
return new Color(.78f, .72f, 1f, 1f);
}
private void RequestHitStop(float duration)
{
if (duration <= 0f) return;
if (!ownsTimeFreeze)
{
if (Time.timeScale <= 0f) return;
capturedTimeScale = Time.timeScale;
ownsTimeFreeze = true;
Time.timeScale = 0f;
}
hitStopUntil = Mathf.Max(hitStopUntil, Time.unscaledTime + duration);
}
private void RequestCameraShake(float pixels, Vector2 direction)
{
if (pixels <= 0f || world.View == null) return;
shakePixels = pixels;
shakeDirection = direction.sqrMagnitude > 0f ? direction.normalized : Vector2.zero;
cameraShakeDuration = GameplayConstants.Current.CombatFeedback.CameraShakeDuration;
cameraShakeUntil = Time.unscaledTime + cameraShakeDuration;
directionalPunch = false;
}
private void ClearChargeVisuals()
{
ClearEffect(ref aura);
ClearEffect(ref readyAura);
ClearEffect(ref chargeFlash);
ClearEffect(ref switchPulse);
}
private void ClearEffect(ref SpriteEffect effect)
{
if (effect == null) return;
if (effect.Root != null) Destroy(effect.Root);
effects.Remove(effect);
effect = null;
}
private void ClearAllEffects()
{
foreach (SpriteEffect effect in effects)
if (effect != null && effect.Root != null) Destroy(effect.Root);
effects.Clear();
aura = readyAura = chargeFlash = switchPulse = null;
guardImpact = blockedBump = null;
}
private Sprite[] LoadFrames(string path)
{
if (string.IsNullOrEmpty(path)) return Array.Empty<Sprite>();
if (!frameCache.TryGetValue(path, out Sprite[] frames))
{
frames = Resources.LoadAll<Sprite>(path);
Array.Sort(frames, (a, b) => string.CompareOrdinal(a.name, b.name));
frameCache.Add(path, frames);
}
return frames;
}
private Vector2 PlayerVisualGroundPosition
{
get
{
if (world == null || world.Player == null || world.Player.Visual == null)
return world != null && world.Player != null ? world.Player.Center : Vector2.zero;
Vector3 foot = world.Player.Visual.FootPosition;
return new Vector2(foot.x, foot.z);
}
}
private Vector3 ToWorld(Vector2 point) => new(point.x, world.Settings.GroundY + .015f, point.y);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6a149c72e9544596b216326c1b27fbe4
@@ -0,0 +1,971 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Player;
using UnityEngine;
using UnityEngine.InputSystem;
namespace BumpCombat.Defense.Comparison
{
// An independent Physics3D experiment. No RunManager, PlayerController or 2D proxy.
public sealed partial class Defense25DComparison : MonoBehaviour
{
private const int PortalLaneCount = 2;
[SerializeField] private Defense25DComparisonSettings settings;
[SerializeField] private Defense25DView view;
private readonly List<Defense25DEnemy> enemies = new();
private readonly List<Defense25DSummon> summons = new();
private readonly Collider[] overlap = new Collider[128];
private readonly HashSet<Defense25DActor> unique = new();
private readonly List<Defense25DActor> candidates = new();
private readonly Dictionary<Defense25DActor, float> nextBump = new();
private readonly List<Transform> adjustedArenaTransforms = new();
private readonly List<Vector3> originalArenaPositions = new();
private readonly List<Vector3> originalArenaScales = new();
private readonly DefenseWaveLedger[] portalLedgers = new DefenseWaveLedger[PortalLaneCount];
private readonly int[] suppliedPerLane = new int[PortalLaneCount];
private readonly bool[] portalSupplyEnded = new bool[PortalLaneCount];
private readonly GameObject[] portalRewards = new GameObject[PortalLaneCount];
private readonly Vector2[][] routes = new Vector2[PortalLaneCount][];
private readonly List<ActiveArtifactDefinition> startupArtifactOptions = new();
private Defense25DCycloneVisual cycloneVisual;
private Defense25DCombatFeedback combatFeedback;
private Defense25DPlayerArt playerArt;
private float previousChargeProgress = -1f;
private MeshFilter adjustedSouthBoundary;
private Mesh originalSouthBoundaryMesh;
private Mesh adjustedSouthBoundaryMesh;
private Vector2 input;
private Vector2 aim = Vector2.right;
private Vector2 cycloneDirection;
private float nextSpawn, reviveAt, cycloneDuration, cycloneInterval;
private float cycloneDistance, cycloneRange, buffUntil;
private bool cycloneCharged, cycloneBlocked, initialized;
private int nextCycloneTick, scheduledCycloneTicks;
private int nextSupplyLane;
private int startupSelectionsRemaining, startupSelectionTotal, startupSelectionIndex;
private ArtifactColor startupArtifactColor;
private bool startupSelectionPending;
private float fallbackGauge;
private const string CycloneBuff = "defense-25d-cyclone";
public Defense25DComparisonSettings Settings => settings;
public Vector2 ArenaHalfExtents { get; private set; }
public Defense25DView View => view;
public Defense25DActor Player { get; private set; }
public IReadOnlyList<Defense25DEnemy> Enemies => enemies;
public IReadOnlyList<Defense25DSummon> Summons => summons;
public DefenseWaveLedger Ledger { get; private set; }
public Defense25DTowerManager TowerManager { get; private set; }
public Defense25DLoadout Loadout { get; private set; }
public Defense25DArtifactExecutor ArtifactExecutor { get; private set; }
public Defense25DRelicRuntime RelicRuntime { get; private set; }
public float GameTime { get; private set; }
public float Gauge => Loadout != null ? Loadout.Gauge : fallbackGauge;
public float BaseHealth { get; private set; }
public int KillCurrency { get; private set; }
public bool IsRunning { get; private set; }
public bool RunCleared { get; private set; }
public bool RunFailed { get; private set; }
public bool Paused { get; private set; }
public bool AutoSpawn { get; set; } = true;
public bool ReadInput { get; set; } = true;
public bool ScreenRelativeMovement { get; private set; } = true;
public bool CycloneActive { get; private set; }
public float CycloneElapsed { get; private set; }
public int CycloneTicks { get; private set; }
public int CycloneDamageEvents { get; private set; }
public int BumpEvents { get; private set; }
public bool ShowBoundaries { get; set; } = true;
public bool TowersOperational => !Paused && DefensePrototypeRules.CanOperateTowers(
Player != null && Player.IsAlive, IsRunning);
public float TowerEfficiency
{
get
{
if (Player == null || TowerManager == null) return 0f;
float best = 0f;
foreach (Defense25DTower tower in TowerManager.Towers)
if (tower != null) best = Mathf.Max(best, tower.SightEfficiency);
return best;
}
}
public Defense25DEnemy CommandTarget { get; private set; }
public Defense25DCycloneVisual CycloneVisual => cycloneVisual;
public Defense25DCombatFeedback CombatFeedback => combatFeedback;
public Defense25DPlayerArt PlayerArt => playerArt;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
public Vector2[] GetRouteForLane(int lane) => routes[Mathf.Clamp(lane, 0, PortalLaneCount - 1)];
public bool IsTowerImmuneToEnemy(DefenseTowerKind kind, Defense25DEnemy enemy) =>
enemy == null || DefensePrototypeRules.IsTowerImmune(kind, Tuning.GetPortalImmuneTowerKind(enemy.Lane));
private void Start()
{
if (initialized || settings == null) return;
// This scene has no RunManager to resume a paused project's clock.
Time.timeScale = 1f;
Initialize(settings, view);
}
public void Initialize(Defense25DComparisonSettings configuration, Defense25DView display = null)
{
if (initialized) return;
settings = configuration;
view = display;
initialized = true;
ArenaHalfExtents = settings.MapHalfExtents;
ReduceSouthBoundaryRenderHeight();
Player = CreateActor(ComparisonActorKind.Player, "Player (Physics3D)", settings.PlayerSpawn);
combatFeedback = gameObject.AddComponent<Defense25DCombatFeedback>();
combatFeedback.Bind(this);
playerArt = gameObject.AddComponent<Defense25DPlayerArt>();
playerArt.Bind(this);
for (int lane = 0; lane < PortalLaneCount; lane++)
{
routes[lane] = new[]
{
Tuning.GetPortalPosition(lane),
Tuning.GetWaypointPosition(lane, 0),
Tuning.GetWaypointPosition(lane, 1),
Tuning.GetBasePosition(lane),
};
for (int i = 0; i < Tuning.PortalEnemyCount; i++)
{
int id = lane * Tuning.PortalEnemyCount + i;
Defense25DActor actor = CreateActor(ComparisonActorKind.Enemy,
$"Portal {lane + 1} Enemy {i + 1}", routes[lane][0]);
Defense25DEnemy enemy = actor.gameObject.AddComponent<Defense25DEnemy>();
enemy.Configure(this, actor, id, lane);
AttachEnemyStatusVisual(enemy);
enemies.Add(enemy);
}
}
CreateRuntimeRouteVisuals();
TowerManager = gameObject.AddComponent<Defense25DTowerManager>();
TowerManager.Configure(this);
ArtifactConstants artifactTuning = GameplayConstants.Current.Artifacts;
Loadout = new Defense25DLoadout(artifactTuning.StartingGauge, artifactTuning.MaximumGauge,
artifactTuning.MaximumOwnedArtifacts, artifactTuning.MaximumArtifactsPerColor);
Loadout.ConfigureCatalog(artifactTuning.Definitions);
ArtifactExecutor = new Defense25DArtifactExecutor();
ArtifactExecutor.Configure(this, Loadout);
ConfigureRelicRuntime();
ConfigureRelicEffectCallbacks();
ConfigureRelicReplayCallbacks();
CreatePortalRewards();
cycloneVisual = new GameObject("Cyclone comparison").AddComponent<Defense25DCycloneVisual>();
cycloneVisual.transform.SetParent(transform, false);
cycloneVisual.Configure(this);
ResetRun();
}
private void ReduceSouthBoundaryRenderHeight()
{
GameObject boundary = GameObject.Find("South boundary");
if (boundary == null) return;
ExpandArenaForSummonSlots(boundary);
MeshFilter meshFilter = boundary != null ? boundary.GetComponent<MeshFilter>() : null;
if (meshFilter == null || meshFilter.sharedMesh == null) return;
originalSouthBoundaryMesh = meshFilter.sharedMesh;
adjustedSouthBoundaryMesh = Instantiate(originalSouthBoundaryMesh);
adjustedSouthBoundaryMesh.name = originalSouthBoundaryMesh.name + " (comparison render height)";
adjustedSouthBoundary = meshFilter;
Mesh mesh = adjustedSouthBoundaryMesh;
Vector3[] vertices = mesh.vertices;
float floor = mesh.bounds.min.y;
float fraction = Mathf.Clamp01(settings.SouthBoundaryRenderHeightFraction);
for (int i = 0; i < vertices.Length; i++)
vertices[i].y = floor + (vertices[i].y - floor) * fraction;
mesh.vertices = vertices;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
meshFilter.sharedMesh = mesh;
}
private void ExpandArenaForSummonSlots(GameObject southBoundary)
{
BoxCollider collider = southBoundary.GetComponent<BoxCollider>();
if (collider == null) return;
DefenseConstants tuning = GameplayConstants.Current.Defense;
int maximumSummons = tuning.SummonUnitCount + 4;
int lastSlot = Mathf.Max(0, maximumSummons - 1);
float lastSlotOffset = -.55f - .8f * (lastSlot / 3);
float southernmostSlot = float.PositiveInfinity;
for (int pad = 0; pad < 6; pad++)
southernmostSlot = Mathf.Min(southernmostSlot,
tuning.GetBuildPadPosition(pad).y + lastSlotOffset);
float maximumVariantRadius = Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels
* 1.55f / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f;
float requiredNorthFace = southernmostSlot - maximumVariantRadius - settings.MovementSkin;
float shiftSouth = Mathf.Min(0f, requiredNorthFace - collider.bounds.max.z);
if (shiftSouth >= 0f) return;
float extension = -shiftSouth;
float wallThickness = collider.bounds.size.z;
AdjustArenaTransform(southBoundary.transform,
southBoundary.transform.position + Vector3.forward * shiftSouth,
southBoundary.transform.localScale);
AdjustArenaObject("North boundary", Vector3.forward * extension, 0f);
float perimeterScaleOffset = extension * 2f + wallThickness;
AdjustArenaObject("XZ ground", Vector3.zero, perimeterScaleOffset);
AdjustArenaObject("East boundary", Vector3.zero, perimeterScaleOffset);
AdjustArenaObject("West boundary", Vector3.zero, perimeterScaleOffset);
ArenaHalfExtents += Vector2.up * extension;
}
private void AdjustArenaObject(string objectName, Vector3 positionOffset, float zScaleOffset)
{
GameObject target = GameObject.Find(objectName);
if (target == null) return;
Vector3 scale = target.transform.localScale;
scale.z += zScaleOffset;
AdjustArenaTransform(target.transform, target.transform.position + positionOffset, scale);
}
private void AdjustArenaTransform(Transform target, Vector3 position, Vector3 scale)
{
int index = adjustedArenaTransforms.IndexOf(target);
if (index < 0)
{
adjustedArenaTransforms.Add(target);
originalArenaPositions.Add(target.position);
originalArenaScales.Add(target.localScale);
}
target.position = position;
target.localScale = scale;
}
private void OnDestroy()
{
for (int i = 0; i < adjustedArenaTransforms.Count; i++)
{
Transform target = adjustedArenaTransforms[i];
if (target == null) continue;
target.position = originalArenaPositions[i];
target.localScale = originalArenaScales[i];
}
if (adjustedSouthBoundary != null
&& adjustedSouthBoundary.sharedMesh == adjustedSouthBoundaryMesh)
adjustedSouthBoundary.sharedMesh = originalSouthBoundaryMesh;
if (adjustedSouthBoundaryMesh != null) Destroy(adjustedSouthBoundaryMesh);
}
private Defense25DActor CreateActor(ComparisonActorKind kind, string label, Vector2 position)
{
GameObject go = new(label);
go.transform.SetParent(transform, false);
go.transform.position = Defense25DActor.OnGround(position, settings.GroundY);
Defense25DActor actor = go.AddComponent<Defense25DActor>();
actor.Configure(this, kind);
return actor;
}
public void RegisterSummon(Defense25DSummon summon)
{
if (summon != null && !summons.Contains(summon)) summons.Add(summon);
}
public void UnregisterSummon(Defense25DSummon summon) => summons.Remove(summon);
internal static void AttachEnemyStatusVisual(Defense25DEnemy enemy)
{
if (enemy == null) return;
Defense25DStatusVisual visual = enemy.GetComponent<Defense25DStatusVisual>();
if (visual == null) visual = enemy.gameObject.AddComponent<Defense25DStatusVisual>();
visual.Bind(enemy);
}
private void CreatePortalRewards()
{
for (int lane = 0; lane < PortalLaneCount; lane++)
{
GameObject reward = GameObject.CreatePrimitive(PrimitiveType.Sphere);
reward.name = $"Portal {lane + 1} Construction Reward";
reward.transform.SetParent(transform, false);
reward.transform.localScale = Vector3.one * .38f;
reward.transform.position = Defense25DActor.OnGround(
Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f, settings.GroundY + .3f);
Destroy(reward.GetComponent<Collider>());
reward.GetComponent<Renderer>().material.color = new Color32(255, 224, 84, 255);
reward.SetActive(false);
portalRewards[lane] = reward;
}
}
private void CreateRuntimeRouteVisuals()
{
for (int segment = 1; segment <= 4; segment++)
GameObject.Find($"Route segment {segment}")?.SetActive(false);
GameObject.Find("Portal")?.SetActive(false);
GameObject.Find("Base - arrival marker")?.SetActive(false);
GameObject.Find("South obstacle")?.SetActive(false);
Transform root = new GameObject("Defense routes and build pads").transform;
root.SetParent(transform, false);
void Box(string label, Vector2 center, Vector3 size, Material material, float height)
{
GameObject item = GameObject.CreatePrimitive(PrimitiveType.Cube);
item.name = label;
item.transform.SetParent(root, false);
item.transform.position = Defense25DActor.OnGround(center, settings.GroundY + height);
item.transform.localScale = size;
item.GetComponent<Renderer>().sharedMaterial = material;
Destroy(item.GetComponent<Collider>());
}
for (int lane = 0; lane < PortalLaneCount; lane++)
{
for (int i = 0; i < routes[lane].Length - 1; i++)
{
Vector2 start = routes[lane][i], end = routes[lane][i + 1];
Vector2 delta = end - start;
GameObject segment = GameObject.CreatePrimitive(PrimitiveType.Cube);
segment.name = $"Portal {lane + 1} route segment {i + 1}";
segment.transform.SetParent(root, false);
segment.transform.position = Defense25DActor.OnGround((start + end) * .5f, settings.GroundY - .005f);
segment.transform.localScale = new Vector3(.65f, .015f, delta.magnitude);
segment.transform.rotation = Quaternion.LookRotation(Defense25DActor.OnGround(delta.normalized));
segment.GetComponent<Renderer>().sharedMaterial = settings.PathMaterial;
Destroy(segment.GetComponent<Collider>());
}
Box($"Portal {lane + 1} marker", routes[lane][0], new Vector3(1f, .12f, 1f), settings.PathMaterial, .06f);
}
Box("Shared base marker", Tuning.GetBasePosition(0), new Vector3(1.25f, .4f, 1.25f), settings.BaseMaterial, .2f);
for (int pad = 0; pad < 6; pad++)
Box($"Tower build pad {pad + 1}", Tuning.GetBuildPadPosition(pad),
new Vector3(.8f, .08f, .8f), settings.BaseMaterial, .035f);
}
public void ResetRun()
{
if (!initialized) return;
ResetPresentationRewards();
combatFeedback?.ResetFeedback();
playerArt?.ResetVisuals();
previousChargeProgress = -1f;
ArtifactExecutor.Cancel();
Loadout.ResetRun();
RelicRuntime?.BeginRun();
if (view != null) BeginStartupArtifactSelection();
CancelCyclone();
GameTime = nextSpawn = reviveAt = buffUntil = 0f;
BaseHealth = Tuning.BaseMaximumHealth;
fallbackGauge = GameplayConstants.Current.Artifacts.StartingGauge;
BumpEvents = CycloneTicks = CycloneDamageEvents = KillCurrency = 0;
input = Vector2.zero;
aim = Vector2.right;
Paused = startupSelectionPending;
IsRunning = true;
RunCleared = RunFailed = false;
CommandTarget = null;
nextBump.Clear();
nextSupplyLane = 0;
Ledger = new DefenseWaveLedger(enemies.Count);
for (int lane = 0; lane < PortalLaneCount; lane++)
{
portalLedgers[lane] = new DefenseWaveLedger(Tuning.PortalEnemyCount);
suppliedPerLane[lane] = 0;
portalSupplyEnded[lane] = false;
portalRewards[lane]?.SetActive(false);
}
TowerManager.ResetForRun();
Player.ResetAt(settings.PlayerSpawn);
foreach (Defense25DEnemy enemy in enemies) enemy.ResetForRun();
foreach (Defense25DSummon summon in summons) summon.ResetForRun();
Physics.SyncTransforms();
}
private void Update()
{
if (!initialized || !ReadInput || Keyboard.current == null) return;
Keyboard k = Keyboard.current;
if (k.rKey.wasPressedThisFrame) ResetRun();
if (k.pKey.wasPressedThisFrame && !startupSelectionPending
&& !HasPendingFieldRelicReward && !showingArtifactSelection)
SetPaused(!Paused);
if (k.escapeKey.wasPressedThisFrame) CancelCyclone();
if (k.bKey.wasPressedThisFrame) ShowBoundaries = !ShowBoundaries;
if (k.cKey.wasPressedThisFrame) ScreenRelativeMovement = !ScreenRelativeMovement;
if (k.tabKey.wasPressedThisFrame) cycloneVisual.Ribbon = !cycloneVisual.Ribbon;
if (k.mKey.wasPressedThisFrame) cycloneVisual.Transparent = !cycloneVisual.Transparent;
if (k.hKey.wasPressedThisFrame) cycloneVisual.Height = cycloneVisual.Height < .4f ? .65f : .15f;
if (k.vKey.wasPressedThisFrame) cycloneVisual.DecorationSpeed = cycloneVisual.DecorationSpeed < 2f ? 4f : 1f;
if (k.nKey.wasPressedThisFrame) cycloneVisual.RadiusScale = cycloneVisual.RadiusScale < 1.1f ? 1.35f : 1f;
if (k.gKey.wasPressedThisFrame && IsRunning && !Paused) DebugFillGauge();
if (startupSelectionPending)
{
if (k.leftArrowKey.wasPressedThisFrame || k.upArrowKey.wasPressedThisFrame)
startupSelectionIndex = (startupSelectionIndex + startupArtifactOptions.Count - 1)
% startupArtifactOptions.Count;
else if (k.rightArrowKey.wasPressedThisFrame || k.downArrowKey.wasPressedThisFrame)
startupSelectionIndex = (startupSelectionIndex + 1) % startupArtifactOptions.Count;
if (k.spaceKey.wasPressedThisFrame) ChooseStartupArtifact(startupSelectionIndex);
return;
}
if (IsTowerShowcaseSimulationActive)
{
input = Vector2.zero;
return;
}
if (!IsRunning || Paused || !Player.IsAlive) { input = Vector2.zero; return; }
Mouse mouse = Mouse.current;
bool pointerOverUI = mouse != null && IsPointerOverDefenseUI(mouse.position.ReadValue());
if (HasPendingFieldRelicReward || showingArtifactSelection
|| (pointerOverUI && mouse != null
&& (mouse.leftButton.wasPressedThisFrame || mouse.leftButton.wasReleasedThisFrame)))
{
input = Vector2.zero;
return;
}
if (k.dKey.wasPressedThisFrame) Player.TryActivateGuard();
if (k.sKey.wasPressedThisFrame && !ArtifactExecutor.IsExecutingArtifact
&& !ArtifactExecutor.IsDashing && Loadout.OwnedArtifactCount > 0)
{
int current = 0;
for (int i = 0; i < Loadout.OwnedArtifacts.Count; i++)
if (ReferenceEquals(Loadout.OwnedArtifacts[i], Loadout.CurrentArtifact)) { current = i; break; }
if (Loadout.Select((current + 1) % Loadout.OwnedArtifactCount))
{
combatFeedback?.NotifyArtifactSwitched(Loadout.CurrentArtifact);
playerArt?.Bind(this);
}
}
if (k.aKey.wasPressedThisFrame) ArtifactExecutor.OnArtifactButtonPressed();
if (k.aKey.wasReleasedThisFrame) ArtifactExecutor.OnArtifactButtonReleased();
if (TowersOperational)
{
if (k.digit1Key.wasPressedThisFrame || k.numpad1Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Direct);
else if (k.digit2Key.wasPressedThisFrame || k.numpad2Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Summon);
else if (k.digit3Key.wasPressedThisFrame || k.numpad3Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Lightning);
else if (k.digit4Key.wasPressedThisFrame || k.numpad4Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Cold);
else if (k.digit5Key.wasPressedThisFrame || k.numpad5Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Seismic);
else if (k.digit6Key.wasPressedThisFrame || k.numpad6Key.wasPressedThisFrame)
TryBuildNearestTower(DefenseTowerKind.Empowering);
else if (k.uKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.None);
else if (k.jKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.First);
else if (k.kKey.wasPressedThisFrame)
TryUpgradeNearestTower(DefenseTowerBranch.Second);
}
// D is the original guard binding; movement remains on the original arrow-key layout.
Vector2 keys = new((k.rightArrowKey.isPressed ? 1 : 0)
- (k.leftArrowKey.isPressed ? 1 : 0),
(k.wKey.isPressed || k.upArrowKey.isPressed ? 1 : 0)
- (k.downArrowKey.isPressed ? 1 : 0));
input = view != null && ScreenRelativeMovement ? view.KeyboardToGround(keys) : Vector2.ClampMagnitude(keys, 1f);
if (view != null && mouse != null && view.TryScreenToGround(mouse.position.ReadValue(), settings.GroundY, out Vector2 point))
{
Vector2 direction = point - Player.Position;
if (direction.sqrMagnitude > .0001f) aim = direction.normalized;
}
}
private bool TryBuildNearestTower(DefenseTowerKind kind)
{
int nearestPad = -1;
float nearestDistance = Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius;
for (int pad = 0; pad < 6; pad++)
{
if (TowerManager.GetTowerAtPad(pad) != null) continue;
float distance = (Player.Position - Tuning.GetBuildPadPosition(pad)).sqrMagnitude;
if (distance > nearestDistance) continue;
nearestDistance = distance;
nearestPad = pad;
}
return nearestPad >= 0 && TowerManager.TryBuildTowerAtPad(nearestPad, kind);
}
private void BeginStartupArtifactSelection()
{
startupSelectionsRemaining = Mathf.Min(3, GameplayConstants.Current.Artifacts.MaximumOwnedArtifacts);
startupSelectionTotal = startupSelectionsRemaining;
startupSelectionPending = startupSelectionsRemaining > 0;
startupSelectionIndex = 0;
if (startupSelectionPending) PopulateStartupArtifactOptions();
}
private void PopulateStartupArtifactOptions()
{
startupArtifactOptions.Clear();
int selectionIndex = Mathf.Min(3 - startupSelectionsRemaining, 2);
startupArtifactColor = selectionIndex switch
{
0 => ArtifactColor.Green,
1 => ArtifactColor.Red,
_ => ArtifactColor.Blue,
};
for (int i = 0; i < Loadout.Catalog.Count; i++)
{
ActiveArtifactDefinition definition = Loadout.Catalog[i];
if (definition != null && definition.ArtifactColor == startupArtifactColor
&& !Loadout.OwnsArtifact(definition.Effect))
startupArtifactOptions.Add(definition);
}
if (startupArtifactOptions.Count == 0) startupSelectionPending = false;
}
private bool ChooseStartupArtifact(int index)
{
if (!startupSelectionPending || index < 0 || index >= startupArtifactOptions.Count
|| !Loadout.TryAddArtifact(startupArtifactOptions[index])) return false;
startupSelectionsRemaining--;
if (startupSelectionsRemaining <= 0)
{
startupSelectionPending = false;
Paused = false;
startupArtifactOptions.Clear();
return true;
}
PopulateStartupArtifactOptions();
startupSelectionIndex = 0;
if (!startupSelectionPending) Paused = false;
return true;
}
private bool TryUpgradeNearestTower(DefenseTowerBranch branch)
{
Defense25DTower nearest = null;
float nearestDistance = Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius;
foreach (Defense25DTower tower in TowerManager.TowersInConstructionOrder)
{
if (tower == null) continue;
float distance = (Player.Position - tower.Position).sqrMagnitude;
if (distance > nearestDistance) continue;
nearestDistance = distance;
nearest = tower;
}
return nearest != null && TowerManager.TryUpgradeTowerAtPad(nearest.PadIndex, branch);
}
public void SetMovementInput(Vector2 direction) => input = Vector2.ClampMagnitude(direction, 1f);
public void DebugFillGauge()
{
if (Loadout != null) Loadout.AddGauge(Loadout.MaximumGauge);
else fallbackGauge = GameplayConstants.Current.Artifacts.MaximumGauge;
}
public void SetPaused(bool value) { Paused = value; input = Vector2.zero; }
private void FixedUpdate()
{
if (!initialized || !IsRunning || Paused) return;
float dt = Time.fixedDeltaTime;
if (IsTowerShowcaseSimulationActive)
{
AdvanceTowerShowcase(dt);
TowerManager.Step(dt);
return;
}
GameTime += dt;
RelicRuntime?.Advance(GameTime, Player.CurrentHealth);
if (buffUntil > 0f && GameTime >= buffUntil)
{
Player.RemoveModifiersFromSource(CycloneBuff);
buffUntil = 0f;
}
if (!Player.IsAlive && GameTime >= reviveAt)
{
Player.ResetAt(settings.PlayerSpawn);
Player.GrantInvulnerability(Tuning.PlayerReviveInvulnerabilitySeconds);
}
if (AutoSpawn && GameTime >= nextSpawn && Ledger.SuppliedCount < enemies.Count)
{
SpawnNextEnemy();
nextSpawn = GameTime + Tuning.PortalSpawnInterval;
}
if (Player.IsAlive)
{
if (CycloneActive) StepCyclone(dt);
else if (Player.HasKnockback || Player.IsHurt) Player.Move(Player.ConsumeKnockback(dt));
else
{
Vector2 velocity = input * Player.MoveSpeed;
ResolveBumps(velocity);
if (Player.IsAlive && !Player.IsHurt)
{
Vector2 moved = Player.Move(velocity * dt);
if (moved.sqrMagnitude > .0000001f)
{
AddGauge(GameplayConstants.Current.Artifacts.MovementGaugeGainPerSecond
* Mathf.Min(dt, moved.magnitude / Mathf.Max(.001f, Player.MoveSpeed)));
RelicRuntime?.AddVoluntaryMovement(moved.magnitude,
Player.Radius * 2f, Player.CurrentHealth);
}
}
}
if (input.sqrMagnitude > .001f) Player.Facing = input.normalized;
}
CommandTarget = null;
ArtifactExecutor.Step(dt);
float chargeProgress = Loadout != null && Loadout.IsCharging ? Loadout.ChargeProgress : 0f;
if (Loadout?.CurrentArtifact != null)
combatFeedback?.NotifyChargeChanged(Loadout.CurrentArtifact, chargeProgress,
chargeProgress >= 1f && previousChargeProgress < 1f);
previousChargeProgress = chargeProgress;
StepRelicHealingOrbs();
TowerManager.Step(dt);
foreach (Defense25DEnemy enemy in enemies) enemy.Step(dt);
foreach (Defense25DSummon summon in summons) summon.Step(dt);
OfferPortalRewards();
TryCollectPortalRewards();
TryFinishRunClear();
}
public Defense25DEnemy SpawnNextEnemy()
{
if (Ledger.SuppliedCount >= enemies.Count) return null;
for (int attempt = 0; attempt < PortalLaneCount; attempt++)
{
int lane = (nextSupplyLane + attempt) % PortalLaneCount;
if (suppliedPerLane[lane] >= Tuning.PortalEnemyCount) continue;
Defense25DEnemy enemy = null;
foreach (Defense25DEnemy candidate in enemies)
{
if (candidate.Lane == lane && candidate.CanSpawn) { enemy = candidate; break; }
}
if (enemy == null || !Ledger.RegisterSupply(enemy.Id)
|| !portalLedgers[lane].RegisterSupply(enemy.Id)) return null;
suppliedPerLane[lane]++;
enemy.Spawn(routes[lane][0]);
if (suppliedPerLane[lane] >= Tuning.PortalEnemyCount)
{
portalSupplyEnded[lane] = true;
portalLedgers[lane].MarkSupplyEnded();
}
nextSupplyLane = (lane + 1) % PortalLaneCount;
if (Ledger.SuppliedCount >= enemies.Count) Ledger.MarkSupplyEnded();
return enemy;
}
return null;
}
public bool TryCollectRewardAtLane(int lane)
{
if (lane < 0 || lane >= PortalLaneCount || !IsRunning || Paused
|| Player == null || !Player.IsAlive || portalRewards[lane] == null
|| !portalLedgers[lane].IsRewardOffered
|| Vector2.Distance(Player.Position, Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f)
> Tuning.RewardPickupRadius
|| !portalLedgers[lane].TryCollectReward()) return false;
portalRewards[lane].SetActive(false);
TowerManager.AddCurrency(Tuning.PortalRewardCurrency);
QueueFieldRelicRewardForCollectedPortal(lane);
TryFinishRunClear();
return true;
}
private void OfferPortalRewards()
{
for (int lane = 0; lane < PortalLaneCount; lane++)
{
if (!portalSupplyEnded[lane] || !portalLedgers[lane].TryOfferReward()) continue;
if (portalRewards[lane] == null)
{
GameObject reward = GameObject.CreatePrimitive(PrimitiveType.Sphere);
reward.name = $"Portal {lane + 1} Construction Reward";
reward.transform.SetParent(transform, false);
reward.transform.localScale = Vector3.one * .38f;
reward.transform.position = Defense25DActor.OnGround(
Tuning.GetPortalPosition(lane) + Vector2.up * 1.2f, settings.GroundY + .3f);
Destroy(reward.GetComponent<Collider>());
reward.GetComponent<Renderer>().material.color = new Color32(255, 224, 84, 255);
portalRewards[lane] = reward;
}
portalRewards[lane].SetActive(true);
}
if (portalSupplyEnded[0] && portalSupplyEnded[1]) Ledger.TryOfferReward();
}
private void TryCollectPortalRewards()
{
for (int lane = 0; lane < PortalLaneCount; lane++)
if (portalRewards[lane] != null && portalRewards[lane].activeSelf)
TryCollectRewardAtLane(lane);
}
private void TryFinishRunClear()
{
if (!IsRunning || RunFailed || Ledger.ResolvedCount < enemies.Count
|| !portalSupplyEnded[0] || !portalSupplyEnded[1]
|| !portalLedgers[0].IsRewardCollected || !portalLedgers[1].IsRewardCollected
|| !CanFinishRunClearPresentation) return;
Ledger.TryOfferReward();
Ledger.TryCollectReward();
RunCleared = true;
EndRun();
}
private void AddGauge(float gain)
{
if (CycloneActive || !Player.IsAlive || !IsRunning || Paused) return;
float adjusted = Player.Evaluate(CharacterStat.ArtifactGaugeGain, gain);
if (Loadout != null) Loadout.AddGauge(adjusted);
else fallbackGauge = Mathf.Clamp(fallbackGauge + adjusted,
0f, GameplayConstants.Current.Artifacts.MaximumGauge);
}
// A Physics3D broadphase only. The final footprint/ellipse checks below use XZ.
public IReadOnlyList<Defense25DActor> QueryEnemies(Vector2 center, Vector2 radii)
{
candidates.Clear();
unique.Clear();
Vector3 low = Defense25DActor.OnGround(center, settings.GroundY + .05f);
Vector3 high = low + Vector3.up * settings.ActorHeight;
int count = Physics.OverlapBoxNonAlloc((low + high) * .5f,
new Vector3(radii.x + 1f, settings.ActorHeight, radii.y + 1f), overlap,
Quaternion.identity, Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
Defense25DActor actor = overlap[i].GetComponentInParent<Defense25DActor>();
if (actor != null && actor.Kind == ComparisonActorKind.Enemy && actor.IsAlive && unique.Add(actor))
candidates.Add(actor);
}
candidates.Sort((a, b) => (a.Position - center).sqrMagnitude.CompareTo((b.Position - center).sqrMagnitude));
return candidates;
}
public void ResolveBumps(Vector2 attackVelocity)
{
CombatConstants combat = GameplayConstants.Current.Combat;
if (!IsRunning || Paused || !Player.IsAlive || Player.IsHurt || Player.HasKnockback || CycloneActive
|| attackVelocity.magnitude < combat.MinimumAttackSpeed) return;
QueryEnemies(Player.Center, Vector2.one * (Player.Radius + .5f));
var bumpTargets = new List<Defense25DActor>(candidates);
foreach (Defense25DActor enemy in bumpTargets)
{
if (Player.GapTo(enemy) > settings.ContactSensorMargin
|| (nextBump.TryGetValue(enemy, out float next) && GameTime < next)) continue;
Vector2 toward = (enemy.Position - Player.Position).normalized;
if (Vector2.Dot(attackVelocity.normalized, toward) < combat.MinimumApproachDot) continue;
HitSide side = BumpCombatMath.ClassifySide(enemy.Facing, Player.Position - enemy.Position);
bool offset = BumpCombatMath.IsOffsetHit(side, attackVelocity, Player.Position, enemy.Position, combat.MinimumOffsetDistance);
float baseDamage = Player.CalculateDamage(combat.BaseDamage, DamageTag.Collision);
float damage = BumpCombatMath.ApplySideMultiplier(baseDamage, side, offset);
damage = RelicRuntime?.ApplyTrophyBumpDamage(
RelicRuntime.ApplyOrdinaryBumpDamage(damage, true, false), true) ?? damage;
float force = combat.KnockbackForce * (side == HitSide.Front ? combat.FrontKnockbackMultiplier : 1f);
if (!enemy.TakeDamage(damage, toward, force,
BumpCombat.Enemies.EnemyDamageSource.Player, ordinaryBump: true))
{
combatFeedback?.NotifyBlockedBump(enemy.ClosestGroundPoint(Player.Center),
attackVelocity.normalized);
continue;
}
nextBump[enemy] = GameTime + combat.NormalHitInterval;
BumpEvents++;
AddGauge(GameplayConstants.Current.Artifacts.NormalBumpGaugeGain);
Player.Visual.ShowAttack(side == HitSide.Back);
if (side == HitSide.Back) enemy.GetComponent<Defense25DEnemy>()?.InterruptAttackFromRearHit();
if (side == HitSide.Front && !offset && combat.EnableFrontalBumpSelfDamage)
Player.TakeDamage(settings.Enemy.AttackDamage, -toward, GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance);
CombatEvents.RaiseValidHit(new CombatHitResult(
Player.gameObject,
enemy.gameObject,
false,
side,
damage,
toward,
force,
enemy.ClosestGroundPoint(Player.Center),
offset,
damageTag: DamageTag.Collision,
sourceId: null,
baseBumpDamage: damage));
RelicRuntime?.NotifyHit(CreateRelicHit(enemy.GetComponent<Defense25DEnemy>(), damage,
toward, baseDamage, true, false), Player.CurrentHealth, Player.MaxHealth);
}
}
public bool TryUseCyclone(bool charged, Vector2 direction)
{
if (!IsRunning || Paused || !Player.IsAlive || Player.IsHurt || CycloneActive) return false;
var definition = settings.Cyclone;
float cost = charged ? definition.ChargedGaugeCost : definition.NormalGaugeCost;
if (Gauge < cost) return false;
if (Loadout != null) Loadout.AddGauge(-cost);
else fallbackGauge = Mathf.Max(0f, fallbackGauge - cost);
CycloneActive = true;
cycloneCharged = charged;
cycloneBlocked = false;
CycloneElapsed = 0f;
CycloneTicks = CycloneDamageEvents = nextCycloneTick = 0;
cycloneDuration = Mathf.Max(Time.fixedDeltaTime, charged ? definition.ChargedDuration : definition.NormalDuration);
cycloneInterval = Mathf.Max(Time.fixedDeltaTime, definition.HitInterval);
cycloneDistance = charged ? definition.ChargedMoveDistance : definition.NormalMoveDistance;
cycloneRange = charged ? definition.ChargedRange : definition.NormalRange;
cycloneDirection = direction.sqrMagnitude > .0001f ? direction.normalized : Player.Facing;
scheduledCycloneTicks = CycloneGeometry.ScheduledHitCount(cycloneDuration, cycloneInterval);
float invulnerability = charged ? definition.ChargedInvulnerabilityDuration : definition.NormalInvulnerabilityDuration;
if (invulnerability > 0f) Player.GrantInvulnerability(invulnerability);
cycloneVisual.SetVisible(true);
return true;
}
public Vector2 CycloneCenter => Player.Position + CycloneGeometry.CenterOffset(cycloneRange, cycloneCharged, settings.PlayerVisualScale);
public Vector2 CycloneRadii => CycloneGeometry.Radii(cycloneRange, cycloneCharged);
public bool IsCycloneCharged => cycloneCharged;
private void StepCyclone(float dt)
{
while (nextCycloneTick < scheduledCycloneTicks && CycloneElapsed + .0001f >= nextCycloneTick * cycloneInterval)
{
DealCycloneTick(nextCycloneTick == scheduledCycloneTicks - 1);
nextCycloneTick++;
CycloneTicks++;
}
if (CycloneElapsed >= cycloneDuration)
{
// Includes a tick exactly on the duration boundary, without losing it to a frame step.
while (nextCycloneTick < scheduledCycloneTicks)
{
DealCycloneTick(nextCycloneTick == scheduledCycloneTicks - 1);
nextCycloneTick++;
CycloneTicks++;
}
CycloneActive = false;
cycloneVisual.SetVisible(false);
Player.RemoveModifiersFromSource(CycloneBuff);
float increase = cycloneCharged ? settings.Cyclone.ChargedMoveSpeedIncrease : settings.Cyclone.NormalMoveSpeedIncrease;
Player.AddModifier(new StatModifier(CycloneBuff, CharacterStat.MoveSpeed, ModifierOperation.Increased, increase));
buffUntil = GameTime + (cycloneCharged ? settings.Cyclone.ChargedBuffDuration : settings.Cyclone.NormalBuffDuration);
return;
}
float nextTime = Mathf.Min(cycloneDuration, CycloneElapsed + dt);
if (!cycloneBlocked)
{
Player.Move(cycloneDirection * (cycloneDistance * (nextTime - CycloneElapsed) / cycloneDuration), false);
cycloneBlocked = Player.MovementBlocked;
}
CycloneElapsed = nextTime;
}
private void DealCycloneTick(bool final)
{
Vector2 center = CycloneCenter;
Vector2 radii = CycloneRadii;
QueryEnemies(center, radii);
int cap = cycloneCharged ? settings.Cyclone.ChargedMaxTargets : settings.Cyclone.NormalMaxTargets;
int hit = 0;
foreach (Defense25DActor enemy in candidates)
{
if (hit >= cap) break;
if (!CycloneGeometry.Overlaps(center, radii, enemy.ClosestPointFunction)) continue;
float damage = Player.CalculateDamage(cycloneCharged ? settings.Cyclone.ChargedDamage : settings.Cyclone.NormalDamage,
settings.Cyclone.DamageTag);
float knockback = final ? (cycloneCharged ? settings.Cyclone.ChargedKnockback : settings.Cyclone.NormalKnockback) : 0f;
if (!enemy.TakeDamage(damage, enemy.Position - center, knockback)) continue;
hit++;
CycloneDamageEvents++;
}
}
public void CancelCyclone()
{
CycloneActive = false;
cycloneBlocked = false;
if (cycloneVisual != null) cycloneVisual.SetVisible(false);
}
public void NotifyDeath(Defense25DActor actor)
{
if (actor == Player)
{
CancelCyclone();
Player.RemoveModifiersFromSource(CycloneBuff);
buffUntil = 0f;
reviveAt = GameTime + Tuning.PlayerReviveDelaySeconds;
RelicRuntime?.Advance(GameTime, 0f);
foreach (Defense25DSummon summon in summons) summon.SuspendAttack();
CommandTarget = null;
}
else if (actor.Kind == ComparisonActorKind.Enemy)
{
Defense25DEnemy enemy = actor.GetComponent<Defense25DEnemy>();
if (enemy != null && enemy.IsShowcaseDummy) NotifyTowerShowcaseDummyDeath(enemy);
else ResolveEnemy(enemy, DefenseEnemyResolution.Death);
}
else actor.GetComponent<Defense25DSummon>()?.BeginRespawn();
}
public void ResolveEnemy(Defense25DEnemy enemy, DefenseEnemyResolution resolution)
{
if (enemy == null || !Ledger.TryResolve(enemy.Id, resolution)) return;
if (resolution == DefenseEnemyResolution.Death)
RelicRuntime?.NotifyEnemyDeath(CreateRelicTarget(enemy), Player.CurrentHealth);
portalLedgers[Mathf.Clamp(enemy.Lane, 0, PortalLaneCount - 1)]?.TryResolve(enemy.Id, resolution);
if (resolution == DefenseEnemyResolution.Death) KillCurrency += Tuning.EnemyKillCurrency;
if (resolution == DefenseEnemyResolution.Death) TowerManager.AddCurrency(Tuning.EnemyKillCurrency);
else BaseHealth = Mathf.Max(0f, BaseHealth - Tuning.ArrivalBaseDamage);
if (resolution == DefenseEnemyResolution.Death) enemy.RetireForDeathVisual();
else enemy.Retire();
if (DefensePrototypeRules.IsBaseDestroyed(BaseHealth))
{
RunFailed = true;
EndRun();
}
}
public void EndRun()
{
IsRunning = false;
ArtifactExecutor?.Cancel();
RelicRuntime?.EndRun();
CancelCyclone();
input = Vector2.zero;
CommandTarget = null;
Player.RemoveModifiersFromSource(CycloneBuff);
TowerManager?.CancelAll();
Player.StopBody();
foreach (Defense25DEnemy enemy in enemies) enemy.Actor.StopBody();
foreach (Defense25DSummon summon in summons) { summon.SuspendAttack(); summon.Actor.StopBody(); }
}
private void OnDisable()
{
CleanupPresentationDamagePopups();
combatFeedback?.ResetFeedback();
if (combatFeedback != null) combatFeedback.enabled = false;
if (initialized) EndRun();
}
private void RestoreRuntimeFeedbackAfterEnable()
{
if (!initialized) return;
if (combatFeedback != null)
{
if (!combatFeedback.enabled) combatFeedback.enabled = true;
else if (combatFeedback.TransparentEffectMaterial == null) combatFeedback.Bind(this);
}
if (playerArt != null)
{
playerArt.ResetVisuals();
playerArt.Bind(this);
}
}
private void NotifyPlayerArtifactUseVisual(Defense25DArtifactUse use)
{
if (Player == null || use.Definition == null) return;
if (use.Effect == ActiveArtifactEffect.Dash) Player.Visual.ShowDash();
else Player.Visual.ShowArtifactUse();
combatFeedback?.NotifyArtifactReleased(use.Definition,
Player.Position, use.Charged);
}
private void OnGUI()
{
DrawHud(Defense25DView.GetViewport(Screen.width, Screen.height));
}
private void DrawHud(Rect viewport) => DrawDefensePresentationGUI(viewport);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3da342f87d5c43a5905256f7b5515c7e
@@ -0,0 +1,731 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Combat;
using System.Text.RegularExpressions;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.UI;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private static readonly Rect RunStatusRect = new(7, 5, 626, 20);
private static readonly Rect ArtifactBarRect = new(7, 28, 626, 25);
private static readonly Rect BottomControlsRect = new(7, 320, 626, 37);
private static readonly Rect FieldRelicPanelRect = new(112, 96, 416, 168);
private static readonly Rect StartupArtifactPanelRect = new(107, 88, 426, 184);
private static readonly Rect ArtifactPickerRect = new(378, 61, 250, 151);
private static readonly Rect[] PersistentDefenseUiRects =
{ RunStatusRect, ArtifactBarRect, BottomControlsRect };
private static readonly DefenseTowerKind[] PresentationTowerKinds =
{
DefenseTowerKind.Direct, DefenseTowerKind.Summon, DefenseTowerKind.Lightning,
DefenseTowerKind.Cold, DefenseTowerKind.Seismic, DefenseTowerKind.Empowering,
};
private DefenseTowerKind selectedBuildKind = DefenseTowerKind.Direct;
private int selectedBuildPad = -1;
private int startupArtifactPage;
private readonly Queue<PassiveRelicId[]> earnedRelicChoices = new();
private readonly bool[] relicChoiceQueuedForLane = new bool[2];
private bool resumeAfterRelicChoice;
private Func<bool> enterValidationShowcase;
private Action exitValidationShowcase;
private bool validationShowcaseActive;
private readonly CombatDamagePopup[] combatDamagePopups =
new CombatDamagePopup[Defense25DCombatPopupConstants.MaximumConcurrentPopups];
private int activeCombatDamagePopupCount;
private Defense25DCombatPopupLifecycle combatPopupLifecycle;
private bool combatPopupEventsSubscribed;
private bool hasPresentationMessage;
private string presentationMessage;
private float presentationMessageUntil;
private GUIStyle compactStatusStyle;
private GUIStyle combatDamagePopupStyle;
private struct CombatDamagePopup
{
public string Text;
public Color Color;
public Vector3 Position;
public float StartedAt;
public bool IsTowerPopup;
public void Set(string text, Color color, Vector3 position, bool isTowerPopup)
{
Text = text;
Color = color;
Position = position;
StartedAt = Time.unscaledTime;
IsTowerPopup = isTowerPopup;
}
}
public IReadOnlyList<DefenseTowerKind> TowerShowcaseKinds => PresentationTowerKinds;
public int SelectedBuildPad => selectedBuildPad;
public DefenseTowerKind SelectedBuildKind => selectedBuildKind;
public IReadOnlyList<PassiveRelicId> PendingFieldRelicChoices =>
earnedRelicChoices.Count == 0 ? Array.Empty<PassiveRelicId>() : earnedRelicChoices.Peek();
public bool HasPendingFieldRelicReward => earnedRelicChoices.Count > 0;
public bool CanFinishRunClearPresentation => !HasPendingFieldRelicReward;
public bool IsValidationShowcaseActive => validationShowcaseActive;
public int ActiveTowerDamagePopupCount
{
get
{
PruneCombatDamagePopups();
int count = 0;
for (int i = 0; i < activeCombatDamagePopupCount; i++)
if (combatDamagePopups[i].IsTowerPopup) count++;
return count;
}
}
public int ActiveCombatDamagePopupCount
{
get
{
PruneCombatDamagePopups();
return activeCombatDamagePopupCount;
}
}
public Rect[] GetPersistentDefenseUiHitRects() => (Rect[])PersistentDefenseUiRects.Clone();
public Rect GetSelectedPadDetailsRect()
{
if (selectedBuildPad < 0 || TowerManager == null) return Rect.zero;
float x = 8f;
if (view != null && view.Camera != null)
{
Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(selectedBuildPad);
Vector3 projected = view.Camera.WorldToViewportPoint(
new Vector3(pad.x, settings.GroundY, pad.y));
if (projected.x < .5f) x = 422f;
}
return new Rect(x, 119, 210, 122);
}
public void RecordTowerDamagePopup(float actualDamage, Color color, Vector3 worldPosition)
{
if (actualDamage > 0f)
AddCombatDamagePopup($"-{DamageCalculator.FormatDamage(actualDamage)}",
color, worldPosition, isTowerPopup: true);
}
public void NotifyPlayerDamagePopup(float actualDamage, Vector3 worldPosition)
{
if (actualDamage > 0f)
AddCombatDamagePopup($"-{DamageCalculator.FormatDamage(actualDamage)}",
Defense25DCombatPopupConstants.PlayerDamageColor, worldPosition,
isTowerPopup: false);
}
private void HandleComparisonValidHit(CombatHitResult result)
{
if (Player == null || result.Attacker != Player.gameObject || result.Target == null) return;
Defense25DEnemy target = result.Target.GetComponentInParent<Defense25DEnemy>();
if (target == null || !enemies.Contains(target)) return;
string damageText = DamageCalculator.FormatDamage(result.Damage);
string popupText = !string.IsNullOrEmpty(result.SourceId)
? $"{GetArtifactHitDisplayName(result.SourceId)} {damageText}"
: result.HasBackBonus
? $"BACK ×1.5 {damageText}"
: result.IsOffsetHit
? $"OFFSET ×1.2 {damageText}"
: result.HasPositionBonus
? $"SIDE ×1.2 {damageText}"
: damageText;
if (result.LaunchSucceeded) popupText = $"LAUNCH {popupText}";
Color color = TryGetArtifactPopupColor(result, out Color artifactColor)
? artifactColor
: result.LaunchSucceeded
? Defense25DCombatPopupConstants.LaunchColor
: result.HasBackBonus
? Defense25DCombatPopupConstants.BackHitColor
: result.HasPositionBonus
? Defense25DCombatPopupConstants.SideHitColor
: Color.white;
AddCombatDamagePopup(popupText, color, result.Target.transform.position,
isTowerPopup: false);
}
private static bool TryGetArtifactPopupColor(CombatHitResult result, out Color color)
{
if (result.IsArtifactHit)
{
color = GetArtifactPopupColor(result.ArtifactEffect);
return true;
}
if (TryGetLegacyArtifactEffect(result.SourceId, out ActiveArtifactEffect effect))
{
color = GetArtifactPopupColor(effect);
return true;
}
color = Color.white;
return false;
}
private static Color GetArtifactPopupColor(ActiveArtifactEffect effect)
{
if (effect < ActiveArtifactEffect.Dash || effect > ActiveArtifactEffect.ChainLightning)
return Color.white;
return ActiveArtifactDefinition.GetArtifactPaletteColor(
ActiveArtifactDefinition.GetArtifactColor(effect));
}
private static bool TryGetLegacyArtifactEffect(string sourceId, out ActiveArtifactEffect effect)
{
if (string.Equals(sourceId, "dash", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.Dash;
else if (string.Equals(sourceId, "pulse", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.Pulse;
else if (string.Equals(sourceId, "phoenix", StringComparison.OrdinalIgnoreCase)
|| string.Equals(sourceId, "scorching_ray", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.Phoenix;
else if (string.Equals(sourceId, "cyclone", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.Cyclone;
else if (string.Equals(sourceId, "thunder_crash", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.ThunderCrash;
else if (string.Equals(sourceId, "chain_lightning", StringComparison.OrdinalIgnoreCase)
|| string.Equals(sourceId, "arc", StringComparison.OrdinalIgnoreCase))
effect = ActiveArtifactEffect.ChainLightning;
else
{
effect = default;
return false;
}
return true;
}
private string GetArtifactHitDisplayName(string sourceId)
{
if (Loadout != null)
foreach (ActiveArtifactDefinition definition in Loadout.Catalog)
if (string.Equals(definition.ArtifactId, sourceId,
StringComparison.OrdinalIgnoreCase)) return definition.DisplayName;
return sourceId.ToUpperInvariant();
}
private void AddCombatDamagePopup(string text, Color color, Vector3 worldPosition,
bool isTowerPopup)
{
if (!isActiveAndEnabled) return;
PruneCombatDamagePopups();
if (activeCombatDamagePopupCount >= combatDamagePopups.Length) return;
combatDamagePopups[activeCombatDamagePopupCount++].Set(
text, color, worldPosition, isTowerPopup);
}
private void PruneCombatDamagePopups()
{
float now = Time.unscaledTime;
for (int i = activeCombatDamagePopupCount - 1; i >= 0; i--)
{
if (now - combatDamagePopups[i].StartedAt
< Defense25DCombatPopupConstants.LifetimeSeconds) continue;
activeCombatDamagePopupCount--;
combatDamagePopups[i] = combatDamagePopups[activeCombatDamagePopupCount];
combatDamagePopups[activeCombatDamagePopupCount] = default;
}
}
private void InitializePresentationDamagePopups()
{
if (combatPopupLifecycle == null)
{
combatPopupLifecycle = GetComponent<Defense25DCombatPopupLifecycle>();
if (combatPopupLifecycle == null)
combatPopupLifecycle = gameObject.AddComponent<Defense25DCombatPopupLifecycle>();
}
combatPopupLifecycle.Bind(this);
SetCombatDamagePopupEventsSubscribed(true);
}
private void CleanupPresentationDamagePopups()
{
SetCombatDamagePopupEventsSubscribed(false);
}
private void OnEnable()
{
if (!initialized) return;
InitializePresentationDamagePopups();
RestoreRuntimeFeedbackAfterEnable();
}
internal void SetCombatDamagePopupEventsSubscribed(bool subscribed)
{
if (combatPopupEventsSubscribed == subscribed) return;
if (subscribed)
CombatEvents.OnValidHit += HandleComparisonValidHit;
else
{
CombatEvents.OnValidHit -= HandleComparisonValidHit;
activeCombatDamagePopupCount = 0;
Array.Clear(combatDamagePopups, 0, combatDamagePopups.Length);
}
combatPopupEventsSubscribed = subscribed;
}
public bool IsPointerOverDefenseUI(Vector2 screenPosition)
{
Rect viewport = Defense25DView.GetViewport(Screen.width, Screen.height);
if (viewport.width <= 0f) return false;
float scale = viewport.width / Defense25DView.Width;
Vector2 gui = new((screenPosition.x - viewport.x) / scale,
(Screen.height - screenPosition.y - viewport.y) / scale);
if (startupSelectionPending) return StartupArtifactPanelRect.Contains(gui);
if (HasPendingFieldRelicReward) return FieldRelicPanelRect.Contains(gui);
if (IsValidationShowcaseActive) return new Rect(7, 5, 626, 28).Contains(gui);
if (RunStatusRect.Contains(gui) || ArtifactBarRect.Contains(gui)
|| BottomControlsRect.Contains(gui)) return true;
Rect details = GetSelectedPadDetailsRect();
return details.width > 0f && details.Contains(gui)
|| showingArtifactSelection && ArtifactPickerRect.Contains(gui);
}
public void BindValidationShowcase(Func<bool> enter, Action exit)
{
enterValidationShowcase = enter;
exitValidationShowcase = exit;
}
public bool EnterValidationShowcase()
{
if (validationShowcaseActive || enterValidationShowcase == null
|| HasPendingFieldRelicReward || !enterValidationShowcase()) return false;
validationShowcaseActive = true;
showingArtifactSelection = false;
return true;
}
public bool ExitValidationShowcase()
{
if (!validationShowcaseActive || exitValidationShowcase == null) return false;
exitValidationShowcase();
validationShowcaseActive = false;
return true;
}
public Sprite GetTowerShowcaseSprite(DefenseTowerKind kind)
{
if (!PresentationTowerKinds.Contains(kind)) return null;
Sprite[] frames = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(kind, 1, "Idle"));
return frames.Length == 0 ? null : frames[0];
}
public bool SelectBuildKind(DefenseTowerKind kind)
{
if (!PresentationTowerKinds.Contains(kind)) return false;
selectedBuildKind = kind;
return true;
}
public bool SelectBuildPad(int padIndex)
{
if (TowerManager == null || padIndex < 0 || padIndex >= TowerManager.Towers.Count) return false;
selectedBuildPad = padIndex;
return true;
}
public bool BuildSelectedTower()
{
bool built = selectedBuildPad >= 0
&& TowerManager.TryBuildTowerAtPad(selectedBuildPad, selectedBuildKind);
SetPresentationMessage(built ? $"{TowerName(selectedBuildKind)} built" : "Move closer to the selected pad");
return built;
}
public bool UpgradeSelectedTower(DefenseTowerBranch branch)
{
bool upgraded = selectedBuildPad >= 0
&& TowerManager.TryUpgradeTowerAtPad(selectedBuildPad, branch);
SetPresentationMessage(upgraded ? "Tower upgraded" : "Upgrade unavailable at this pad");
return upgraded;
}
public bool SelectOwnedArtifact(int index)
{
if (Loadout == null || !Loadout.Select(index)) return false;
ArtifactExecutor?.Cancel();
return true;
}
public bool QueueFieldRelicRewardForCollectedPortal(int lane)
{
if (lane < 0 || lane >= relicChoiceQueuedForLane.Length || relicChoiceQueuedForLane[lane]
|| Loadout == null || portalLedgers[lane] == null || !portalLedgers[lane].IsRewardCollected)
return false;
PassiveRelicId[] available = Loadout.GetAvailableRelics();
if (available.Length == 0) return false;
for (int i = available.Length - 1; i > 0; i--)
{
int swap = UnityEngine.Random.Range(0, i + 1);
(available[i], available[swap]) = (available[swap], available[i]);
}
earnedRelicChoices.Enqueue(available.Take(Mathf.Min(3, available.Length)).ToArray());
relicChoiceQueuedForLane[lane] = true;
if (earnedRelicChoices.Count == 1)
{
resumeAfterRelicChoice = IsRunning && !Paused && !startupSelectionPending;
SetPaused(true);
}
return true;
}
public bool ResolveFieldRelicChoice(PassiveRelicId id)
{
if (!HasPendingFieldRelicReward || !earnedRelicChoices.Peek().Contains(id)
|| !Loadout.TryAcquireRelic(id)) return false;
earnedRelicChoices.Dequeue();
if (earnedRelicChoices.Count == 0)
{
if (resumeAfterRelicChoice && IsRunning && Player != null && Player.IsAlive)
SetPaused(false);
resumeAfterRelicChoice = false;
TryFinishRunClear();
}
return true;
}
public void ResetPresentationRewards()
{
InitializePresentationDamagePopups();
activeCombatDamagePopupCount = 0;
Array.Clear(combatDamagePopups, 0, combatDamagePopups.Length);
earnedRelicChoices.Clear();
Array.Clear(relicChoiceQueuedForLane, 0, relicChoiceQueuedForLane.Length);
resumeAfterRelicChoice = false;
if (validationShowcaseActive) ExitValidationShowcase();
}
public void DrawDefensePresentationGUI(Rect viewport)
{
if (!initialized || viewport.width <= 0f || viewport.height <= 0f) return;
float scale = viewport.width / Defense25DView.Width;
Matrix4x4 previous = GUI.matrix;
try
{
GUI.matrix = Matrix4x4.TRS(new Vector3(viewport.x, viewport.y, 0f),
Quaternion.identity, new Vector3(scale, scale, 1f));
DrawRunStatus();
DrawCombatDamagePopups(scale);
if (IsValidationShowcaseActive)
{
GUI.Box(new Rect(7, 5, 626, 28), "Six-family tower combat showcase");
if (GUI.Button(new Rect(548, 7, 76, 24), "General run")) ExitValidationShowcase();
return;
}
DrawArtifactBar();
DrawTowerCatalog();
DrawPadAndUpgradePanel();
DrawFieldRelicReward();
DrawStartupArtifactPicker();
if (hasPresentationMessage && GameTime < presentationMessageUntil)
GUI.Label(new Rect(245, 317, 150, 20), presentationMessage, CenteredStyle());
if (Paused && !startupSelectionPending && !HasPendingFieldRelicReward)
GUI.Box(new Rect(264, 159, 112, 32), "PAUSED");
if (RunFailed || RunCleared)
GUI.Box(new Rect(224, 151, 192, 42), RunCleared ? "DEFENSE CLEARED" : "BASE DESTROYED");
}
finally { GUI.matrix = previous; }
}
private void DrawCombatDamagePopups(float scale)
{
PruneCombatDamagePopups();
if (view == null || view.Camera == null) return;
Color previousColor = GUI.color;
try
{
combatDamagePopupStyle ??= new GUIStyle(GUI.skin.label)
{
font = PresentationUiStyle.GetGalmuriFont(),
fontSize = Mathf.RoundToInt(Defense25DCombatPopupConstants.FontPixelsAt1080p
* Defense25DCombatPopupConstants.ReferenceInternalHeight
/ Defense25DCombatPopupConstants.ReferenceScreenHeight),
alignment = TextAnchor.MiddleCenter,
wordWrap = false,
clipping = TextClipping.Overflow,
};
for (int i = 0; i < activeCombatDamagePopupCount; i++)
{
CombatDamagePopup popup = combatDamagePopups[i];
Vector3 projected = view.Camera.WorldToViewportPoint(popup.Position);
if (projected.z <= 0f || projected.x < 0f || projected.x > 1f
|| projected.y < 0f || projected.y > 1f) continue;
float age = Mathf.Max(0f, Time.unscaledTime - popup.StartedAt);
float x = projected.x * Defense25DView.Width;
float y = (1f - projected.y) * Defense25DView.Height
- age * Defense25DCombatPopupConstants.RisePixelsAt1080p
* Defense25DCombatPopupConstants.ReferenceInternalHeight
/ Defense25DCombatPopupConstants.ReferenceScreenHeight;
Rect rect = new(
x - Defense25DCombatPopupConstants.PopupWidthInternal * 0.5f,
y - Defense25DCombatPopupConstants.PopupHeightInternal * 0.5f,
Defense25DCombatPopupConstants.PopupWidthInternal,
Defense25DCombatPopupConstants.PopupHeightInternal);
Color faded = popup.Color;
faded.a *= 1f - age / Defense25DCombatPopupConstants.LifetimeSeconds;
Color outline = Defense25DCombatPopupConstants.OutlineColor;
outline.a *= faded.a;
GUI.color = outline;
float outlineOffset = Defense25DCombatPopupConstants.OutlinePixels
/ Mathf.Max(1f, scale);
GUI.Label(new Rect(rect.x - outlineOffset, rect.y, rect.width, rect.height),
popup.Text, combatDamagePopupStyle);
GUI.Label(new Rect(rect.x + outlineOffset, rect.y, rect.width, rect.height),
popup.Text, combatDamagePopupStyle);
GUI.Label(new Rect(rect.x, rect.y - outlineOffset, rect.width, rect.height),
popup.Text, combatDamagePopupStyle);
GUI.Label(new Rect(rect.x, rect.y + outlineOffset, rect.width, rect.height),
popup.Text, combatDamagePopupStyle);
GUI.color = faded;
GUI.Label(rect, popup.Text, combatDamagePopupStyle);
}
}
finally { GUI.color = previousColor; }
}
private void DrawRunStatus()
{
GUI.Box(RunStatusRect, "");
if (Player == null || TowerManager == null || Ledger == null) return;
compactStatusStyle ??= new GUIStyle(GUI.skin.label) { fontSize = 9 };
GUI.Label(new Rect(14, 6, 612, 18),
$"HP {Player.CurrentHealth:0}/{Player.MaxHealth:0} Base {BaseHealth:0} " +
$"Wave {Ledger.ResolvedCount}/{enemies.Count} Build {TowerManager.ConstructionCurrency} " +
$"{GameTime:0.0}s Relics {Loadout.OwnedRelicCount}/20 " +
$"Efficiency {TowerEfficiency:P0} {(IsRunning ? "DEFENDING" : "RUN ENDED")}", compactStatusStyle);
}
private void DrawArtifactBar()
{
GUI.Box(ArtifactBarRect, "");
GUI.Label(new Rect(13, 29, 105, 23), $"Gauge {Gauge:0}");
if (Loadout.CurrentArtifact == null)
{
GUI.Label(new Rect(120, 29, 220, 23), "No artifact equipped");
}
else
{
GUI.Label(new Rect(120, 29, 225, 23), Loadout.CurrentArtifact.DisplayName);
GUI.Label(new Rect(347, 29, 113, 23), Loadout.IsCharging
? $"Charge {Loadout.ChargeProgress:P0}" : "Ready");
}
if (GUI.Button(new Rect(466, 29, 76, 23), "Artifacts")) showingArtifactSelection = !showingArtifactSelection;
if (HasPendingFieldRelicReward) GUI.Label(new Rect(548, 29, 77, 23), "RELIC REWARD");
else if (GUI.Button(new Rect(548, 29, 77, 23), IsValidationShowcaseActive ? "General run" : "Showcase"))
{
if (IsValidationShowcaseActive) ExitValidationShowcase();
else EnterValidationShowcase();
}
if (showingArtifactSelection) DrawOwnedArtifactPicker();
}
private bool showingArtifactSelection;
private void DrawOwnedArtifactPicker()
{
GUI.Box(ArtifactPickerRect, "Choose artifact");
for (int i = 0; i < Loadout.OwnedArtifacts.Count; i++)
{
ActiveArtifactDefinition artifact = Loadout.OwnedArtifacts[i];
if (GUI.Button(new Rect(390, 83 + i * 28, 226, 24), artifact.DisplayName)
&& SelectOwnedArtifact(i)) showingArtifactSelection = false;
}
}
private void DrawTowerCatalog()
{
GUI.Box(BottomControlsRect, "");
for (int i = 0; i < PresentationTowerKinds.Length; i++)
{
DefenseTowerKind kind = PresentationTowerKinds[i];
Rect button = new(12 + i * 51, 326, 49, 25);
string label = selectedBuildKind == kind ? $"*{TowerShortName(kind)}" : TowerShortName(kind);
if (GUI.Button(button, label))
SelectBuildKind(kind);
}
}
private void DrawPadAndUpgradePanel()
{
for (int pad = 0; pad < 6; pad++)
{
Defense25DTower tower = TowerManager.GetTowerAtPad(pad);
string label = tower == null ? $"P{pad + 1}" : $"P{pad + 1} T{tower.Tier}";
if (GUI.Button(new Rect(322 + pad * 51, 326, 49, 25), label)) SelectBuildPad(pad);
}
Rect panel = GetSelectedPadDetailsRect();
if (panel.width <= 0f) return;
Defense25DTower selected = selectedBuildPad >= 0 ? TowerManager.GetTowerAtPad(selectedBuildPad) : null;
GUI.Box(panel, "");
if (GUI.Button(new Rect(panel.xMax - 24, panel.y + 3, 20, 19), "×"))
{
selectedBuildPad = -1;
return;
}
if (selected == null)
{
int cost = BuildCost(selectedBuildKind);
GUI.Label(new Rect(panel.x + 10, panel.y + 20, panel.width - 20, 23),
$"Pad {selectedBuildPad + 1}: {TowerName(selectedBuildKind)}");
if (GUI.Button(new Rect(panel.x + 10, panel.y + 54, 112, 27), $"Build {cost}")
&& selectedBuildPad >= 0)
BuildSelectedTower();
return;
}
GUI.Label(new Rect(panel.x + 10, panel.y + 20, panel.width - 20, 24),
$"Pad {selectedBuildPad + 1}: {TowerName(selected.Kind)} T{selected.Tier}");
if (selected.Tier < 3)
{
if (GUI.Button(new Rect(panel.x + 10, panel.y + 54, 180, 27),
$"Upgrade {UpgradeCost(selected.Tier)}"))
UpgradeSelectedTower(DefenseTowerBranch.None);
}
else if (selected.Tier == 3)
{
string first = DefenseTower.GetBranchName(selected.Kind, DefenseTowerBranch.First);
string second = DefenseTower.GetBranchName(selected.Kind, DefenseTowerBranch.Second);
GUI.Label(new Rect(panel.x + 10, panel.y + 45, panel.width - 20, 27),
$"I {first} II {second}", compactStatusStyle);
if (GUI.Button(new Rect(panel.x + 10, panel.y + 76, 86, 27), $"I {UpgradeCost(3)}"))
UpgradeSelectedTower(DefenseTowerBranch.First);
if (GUI.Button(new Rect(panel.x + 104, panel.y + 76, 86, 27), $"II {UpgradeCost(3)}"))
UpgradeSelectedTower(DefenseTowerBranch.Second);
}
else GUI.Label(new Rect(panel.x + 10, panel.y + 54, 190, 42),
$"{TowerName(selected.Kind)} IV\n{DefenseTower.GetBranchName(selected.Kind, selected.Branch)}");
}
private void DrawFieldRelicReward()
{
if (!HasPendingFieldRelicReward) return;
PassiveRelicId[] choices = earnedRelicChoices.Peek();
GUI.Box(FieldRelicPanelRect, $"Portal field reward {Loadout.OwnedRelicCount}/20");
for (int i = 0; i < choices.Length; i++)
{
Rect button = new(132, 126 + i * 36, 376, 30);
if (GUI.Button(button, RelicName(choices[i]))) ResolveFieldRelicChoice(choices[i]);
}
}
private void DrawStartupArtifactPicker()
{
if (!startupSelectionPending) return;
GUI.Box(StartupArtifactPanelRect,
$"Choose {startupArtifactColor} artifact {Loadout.OwnedArtifactCount + 1}/{startupSelectionTotal}");
int pageCount = Mathf.Max(1, Mathf.CeilToInt(startupArtifactOptions.Count / 4f));
startupArtifactPage = Mathf.Clamp(startupArtifactPage, 0, pageCount - 1);
int firstChoice = startupArtifactPage * 4;
int lastChoice = Mathf.Min(startupArtifactOptions.Count, firstChoice + 4);
for (int i = firstChoice; i < lastChoice; i++)
{
Rect button = new(132, 119 + (i - firstChoice) * 32, 376, 28);
if (GUI.Button(button, startupArtifactOptions[i].DisplayName)) ChooseStartupArtifact(i);
}
if (startupArtifactPage > 0 && GUI.Button(new Rect(132, 250, 70, 21), "Previous")) startupArtifactPage--;
if (startupArtifactPage + 1 < pageCount && GUI.Button(new Rect(438, 250, 70, 21), "Next")) startupArtifactPage++;
}
private void SetPresentationMessage(string message)
{
presentationMessage = message;
presentationMessageUntil = GameTime + 2f;
hasPresentationMessage = true;
}
private int BuildCost(DefenseTowerKind kind)
{
DefenseConstants tuning = GameplayConstants.Current.Defense;
return kind switch
{
DefenseTowerKind.Direct => tuning.DirectTowerCost,
DefenseTowerKind.Summon => tuning.SummonTowerCost,
DefenseTowerKind.Lightning => tuning.LightningTowerCost,
DefenseTowerKind.Cold => tuning.ColdTowerCost,
DefenseTowerKind.Seismic => tuning.SeismicTowerCost,
DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost,
_ => 0,
};
}
private int UpgradeCost(int tier) => tier switch
{
1 => GameplayConstants.Current.Defense.Tier2UpgradeCost,
2 => GameplayConstants.Current.Defense.Tier3UpgradeCost,
_ => GameplayConstants.Current.Defense.Tier4UpgradeCost,
};
private static string TowerName(DefenseTowerKind kind) => kind switch
{
DefenseTowerKind.Direct => "Fire",
DefenseTowerKind.Summon => "Summon",
DefenseTowerKind.Lightning => "Lightning",
DefenseTowerKind.Cold => "Cold",
DefenseTowerKind.Seismic => "Seismic",
DefenseTowerKind.Empowering => "Support",
_ => kind.ToString(),
};
private static string TowerShortName(DefenseTowerKind kind) => kind switch
{
DefenseTowerKind.Direct => "Fire",
DefenseTowerKind.Summon => "Summon",
DefenseTowerKind.Lightning => "Storm",
DefenseTowerKind.Cold => "Cold",
DefenseTowerKind.Seismic => "Earth",
DefenseTowerKind.Empowering => "Support",
_ => kind.ToString(),
};
private static string RelicName(PassiveRelicId id) =>
Regex.Replace(id.ToString(), "(?<=[a-z])(?=[A-Z])", " ");
private static GUIStyle CenteredStyle()
{
GUIStyle style = new(GUI.skin.label) { alignment = TextAnchor.MiddleCenter };
return style;
}
}
internal sealed class Defense25DCombatPopupLifecycle : MonoBehaviour
{
private Defense25DComparison owner;
public void Bind(Defense25DComparison comparison)
{
if (owner == comparison) return;
if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false);
owner = comparison;
if (isActiveAndEnabled && owner != null)
owner.SetCombatDamagePopupEventsSubscribed(true);
}
private void OnEnable()
{
if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(true);
}
private void OnDisable()
{
if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false);
}
private void OnDestroy()
{
if (owner != null) owner.SetCombatDamagePopupEventsSubscribed(false);
owner = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f8db4b736f6478fb580f7fe926c70c1
@@ -0,0 +1,13 @@
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private void ConfigureRelicReplayCallbacks()
{
relicEffects.EmitEcho = (use, origin, direction, multiplier) =>
ArtifactExecutor.TryReplay(use, origin, direction, multiplier);
relicEffects.EmitRepeat = (use, origin, direction, multiplier) =>
ArtifactExecutor.TryReplay(use, origin, direction, multiplier);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7c43d5582674f77a9befa672a24bd6c
@@ -0,0 +1,212 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Enemies;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private sealed class RelicHealingOrb
{
public GameObject Object;
public float ExpiresAt;
}
private readonly List<RelicHealingOrb> relicHealingOrbs = new();
private Defense25DRelicEffects relicEffects;
private float relicOrbLastStepTime;
private void ConfigureRelicRuntime()
{
relicEffects = new Defense25DRelicEffects();
RelicRuntime = new Defense25DRelicRuntime(
Loadout, GameplayConstants.Current.PassiveRelics, relicEffects);
Loadout.ArtifactUseSucceeded += NotifyRelicArtifactUse;
Loadout.ArtifactUseSucceeded += NotifyPlayerArtifactUseVisual;
ArtifactExecutor.NotifyRelicHit = NotifyRelicHit;
RelicRuntime.BeginRun(GameTime);
}
private void ConfigureRelicEffectCallbacks()
{
Defense25DRelicEffects effects = relicEffects;
effects.QueryEnemies = QueryRelicTargets;
effects.DamageEnemy = ApplyRelicDamage;
effects.IgniteEnemy = ApplyRelicIgnite;
effects.CalculatePlayerDamage = (damage, tag) => Player.CalculateDamage(damage, tag);
effects.ApplyVulnerability = (target, duration, increase) =>
FindRelicEnemy(target.Identity)?.ApplyBumpVulnerability(duration, increase) == true;
effects.ApplySlow = (target, duration, fraction) =>
FindRelicEnemy(target.Identity)?.ApplySlow(duration, fraction) == true;
effects.HealPlayer = amount => Player.Heal(amount);
effects.GrantShield = (amount, duration) => Player.GrantShield(amount, duration);
effects.RestoreGauge = fraction =>
{
if (!IsRunning || !Player.IsAlive || fraction <= 0f) return false;
float before = Gauge;
Loadout.AddGauge(Loadout.MaximumGauge * fraction);
return Gauge > before;
};
effects.BoostVisibleTowers = BoostVisibleRelicTowers;
effects.AwardCurrency = AwardRelicCurrency;
effects.SpawnHealingOrb = SpawnRelicHealingOrb;
effects.SetTimedPlayerModifier = (modifier, duration) =>
{
if (Player.IsAlive && duration > 0f) Player.AddModifier(modifier);
};
effects.RemovePlayerModifier = source => Player.RemoveModifiersFromSource(source);
}
private IReadOnlyList<Defense25DRelicTarget> QueryRelicTargets(Vector3 center, float radius)
{
var results = new List<Defense25DRelicTarget>();
float radiusSquared = radius * radius;
foreach (Defense25DEnemy enemy in enemies)
{
if (enemy == null || !enemy.IsAvailable) continue;
Vector2 closest = enemy.Actor.ClosestGroundPoint(new Vector2(center.x, center.z));
Vector2 delta = closest - new Vector2(center.x, center.z);
if (delta.sqrMagnitude <= radiusSquared) results.Add(CreateRelicTarget(enemy));
}
return results;
}
private bool ApplyRelicDamage(Defense25DRelicTarget target, float damage,
Vector3 direction, EnemyDamageSource source)
{
Defense25DEnemy enemy = FindRelicEnemy(target.Identity);
if (enemy == null || !enemy.IsAvailable || damage <= 0f) return false;
return enemy.Actor.TakeDamage(damage, new Vector2(direction.x, direction.z), 0f, source);
}
private bool ApplyRelicIgnite(Defense25DRelicTarget target, float duration,
float interval, float damage, EnemyDamageSource source)
{
return FindRelicEnemy(target.Identity)?.ApplyIgnite(duration, interval, damage, source) == true;
}
private Defense25DEnemy FindRelicEnemy(int identity)
{
foreach (Defense25DEnemy enemy in enemies)
if (enemy != null && enemy.Id == identity) return enemy;
return null;
}
private bool BoostVisibleRelicTowers(float duration)
{
if (!TowersOperational || duration <= 0f) return false;
bool applied = false;
foreach (Defense25DTower tower in TowerManager.Towers)
{
if (tower == null || tower.Kind is DefenseTowerKind.Empowering or DefenseTowerKind.Summon
|| Vector2.Distance(Player.Position, tower.Position) > Tuning.PlayerSightRadius) continue;
tower.ApplyBattleBaton(duration,
GameplayConstants.Current.PassiveRelics.BattleBatonTowerDamageIncrease);
applied = true;
}
return applied;
}
private bool AwardRelicCurrency(int amount)
{
if (!IsRunning || Player == null || !Player.IsAlive || amount <= 0) return false;
TowerManager.AddCurrency(amount);
return true;
}
private void SpawnRelicHealingOrb(Vector3 position, float healFraction, float lifetime)
{
if (!IsRunning || lifetime <= 0f) return;
GameObject orb = GameObject.CreatePrimitive(PrimitiveType.Sphere);
orb.name = "Pilgrim Healing Orb";
orb.transform.SetParent(transform, true);
orb.transform.position = position + Vector3.up * .2f;
orb.transform.localScale = Vector3.one * .3f;
Collider collider = orb.GetComponent<Collider>();
if (collider != null) Destroy(collider);
Renderer renderer = orb.GetComponent<Renderer>();
if (renderer != null) renderer.material.color = new Color(.35f, 1f, .55f);
relicHealingOrbs.Add(new RelicHealingOrb
{
Object = orb,
ExpiresAt = GameTime + lifetime,
});
}
private void StepRelicHealingOrbs()
{
if (GameTime < relicOrbLastStepTime)
{
foreach (RelicHealingOrb oldOrb in relicHealingOrbs)
if (oldOrb.Object != null) Destroy(oldOrb.Object);
relicHealingOrbs.Clear();
RelicRuntime?.NotifyHealingOrbExpired();
}
relicOrbLastStepTime = GameTime;
float pickupRadius = GameplayConstants.Current.Items.ExperienceOrbPickupRadius;
for (int i = relicHealingOrbs.Count - 1; i >= 0; i--)
{
RelicHealingOrb orb = relicHealingOrbs[i];
if (orb.Object == null || GameTime >= orb.ExpiresAt)
{
if (orb.Object != null) Destroy(orb.Object);
RelicRuntime?.NotifyHealingOrbExpired();
relicHealingOrbs.RemoveAt(i);
continue;
}
if (Player == null || !Player.IsAlive) continue;
Vector2 orbPosition = Defense25DActor.Ground(orb.Object.transform.position);
if ((Player.Position - orbPosition).sqrMagnitude > pickupRadius * pickupRadius) continue;
bool pickedUp = RelicRuntime?.NotifyHealingOrbPickedUp(Player.MaxHealth) == true;
Destroy(orb.Object);
relicHealingOrbs.RemoveAt(i);
}
}
private void NotifyRelicArtifactUse(Defense25DArtifactUse use)
{
if (Player == null) return;
RelicRuntime?.NotifyArtifactUse(use,
Defense25DActor.OnGround(Player.Position, settings.GroundY),
Defense25DActor.OnGround(Player.Facing), Player.CurrentHealth);
}
private void NotifyRelicHit(Defense25DRelicHit hit) =>
RelicRuntime?.NotifyHit(hit, Player != null ? Player.CurrentHealth : 0f,
Player != null ? Player.MaxHealth : 0f);
internal Defense25DRelicTarget CreateRelicTarget(Defense25DEnemy enemy)
{
Defense25DActor actor = enemy.Actor;
return new Defense25DRelicTarget(enemy.Id,
Defense25DActor.OnGround(actor.Position, settings.GroundY), actor.CurrentHealth,
actor.MaxHealth, enemy.LastDamageSource, !actor.IsAlive,
igniteRemaining: enemy.IgniteRemaining, igniteInterval: enemy.IgniteInterval,
igniteTickDamage: enemy.IgniteTickDamage);
}
public void NotifyPlayerDamageTaken(float actualLoss) =>
RelicRuntime?.NotifyPlayerDamageTaken(actualLoss, Player.CurrentHealth,
Player.MaxHealth, Defense25DActor.OnGround(Player.Position, settings.GroundY));
private Defense25DRelicHit CreateRelicHit(Defense25DEnemy enemy, float damage,
Vector2 direction, float baseBumpDamage, bool ordinaryBump, bool artifactHit)
{
Defense25DActor actor = enemy.Actor;
Defense25DRelicTarget target = new(enemy.Id,
Defense25DActor.OnGround(actor.Position, settings.GroundY), actor.CurrentHealth,
actor.MaxHealth, enemy.LastDamageSource, !actor.IsAlive,
igniteRemaining: enemy.IgniteRemaining, igniteInterval: enemy.IgniteInterval,
igniteTickDamage: enemy.IgniteTickDamage);
return new Defense25DRelicHit(target,
Defense25DActor.OnGround(actor.ClosestGroundPoint(Player.Center), settings.GroundY),
Defense25DActor.OnGround(direction), damage, baseBumpDamage, ordinaryBump,
artifactHit);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71a94f1e2f6e49ca9a4c44b77f51054e
@@ -0,0 +1,262 @@
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DComparison
{
private static DefenseTowerKind[] ShowcaseKinds => DefenseTowerHitTestLayout.KindsByPad;
private readonly Dictionary<Defense25DEnemy, DefenseTowerKind> showcaseDummyKinds = new();
private readonly Dictionary<Defense25DEnemy, Vector2> showcaseDummyPositions = new();
private readonly Dictionary<Defense25DEnemy, int> showcaseObservedDamageEvents = new();
private readonly Dictionary<DefenseTowerKind, int> showcaseHitCounts = new();
private readonly HashSet<Defense25DEnemy> showcaseSuspendedEnemies = new();
private Defense25DTowerManager runTowerManager;
private Defense25DTowerManager showcaseTowerManager;
private GameObject showcaseRoot;
private Vector2 runPlayerPosition;
private float runGameTime;
private bool towerShowcaseActive;
public bool IsTowerShowcaseSimulationActive => towerShowcaseActive;
public IReadOnlyCollection<Defense25DEnemy> TowerShowcaseDummies => showcaseDummyKinds.Keys;
public int TowerShowcaseCurrency => showcaseTowerManager != null
? showcaseTowerManager.ConstructionCurrency : 0;
public IEnumerable<Defense25DEnemy> GetTowerTargets(DefenseTowerKind sourceKind)
{
foreach (Defense25DEnemy enemy in enemies)
if (enemy != null && !enemy.IsShowcaseDummy)
yield return enemy;
if (!towerShowcaseActive) yield break;
foreach (KeyValuePair<Defense25DEnemy, DefenseTowerKind> pair in showcaseDummyKinds)
if (pair.Value == sourceKind && pair.Key != null && pair.Key.IsAvailable)
yield return pair.Key;
}
public bool IsTowerShowcaseTarget(DefenseTowerKind sourceKind, Defense25DEnemy target)
{
if (target == null) return false;
if (!target.IsShowcaseDummy) return enemies.Contains(target);
return towerShowcaseActive
&& showcaseDummyKinds.TryGetValue(target, out DefenseTowerKind assignedKind)
&& assignedKind == sourceKind;
}
public Defense25DEnemy GetTowerShowcaseDummy(DefenseTowerKind kind)
{
foreach (KeyValuePair<Defense25DEnemy, DefenseTowerKind> pair in showcaseDummyKinds)
if (pair.Value == kind) return pair.Key;
return null;
}
public int GetTowerShowcaseHitCount(DefenseTowerKind kind) =>
showcaseHitCounts.TryGetValue(kind, out int count) ? count : 0;
public float GetTowerShowcaseSupportBonus(DefenseTowerKind kind)
{
if (showcaseTowerManager == null) return 0f;
foreach (Defense25DTower tower in showcaseTowerManager.Towers)
if (tower != null && tower.Kind == kind) return tower.ShowcaseSupportBonus;
return 0f;
}
public bool EnterTowerShowcase()
{
if (towerShowcaseActive || !IsRunning || Paused || RunCleared || RunFailed
|| Player == null || !Player.IsAlive || TowerManager == null
|| HasPendingFieldRelicReward || startupSelectionPending || showingArtifactSelection
|| CycloneActive || ArtifactExecutor.IsExecutingArtifact || ArtifactExecutor.IsDashing)
return false;
runTowerManager = TowerManager;
runPlayerPosition = Player.Position;
runGameTime = GameTime;
towerShowcaseActive = true;
showcaseSuspendedEnemies.Clear();
runTowerManager.SetShowcaseSuspended(true);
foreach (Defense25DEnemy enemy in enemies)
{
if (enemy == null || !enemy.gameObject.activeSelf) continue;
showcaseSuspendedEnemies.Add(enemy);
enemy.gameObject.SetActive(false);
}
showcaseRoot = new GameObject("Isolated tower showcase");
showcaseRoot.transform.SetParent(transform, false);
GameObject managerObject = new("Showcase construction economy");
managerObject.transform.SetParent(showcaseRoot.transform, false);
showcaseTowerManager = managerObject.AddComponent<Defense25DTowerManager>();
showcaseTowerManager.Configure(this);
TowerManager = showcaseTowerManager;
showcaseHitCounts.Clear();
int totalBuildCost = 0;
foreach (DefenseTowerKind kind in ShowcaseKinds) totalBuildCost += ShowcaseBuildCost(kind);
showcaseTowerManager.AddCurrency(totalBuildCost);
for (int i = 0; i < ShowcaseKinds.Length; i++)
{
Vector2 pad = Tuning.GetBuildPadPosition(i);
MovePlayerForShowcaseBuild(pad);
if (!showcaseTowerManager.TryBuildTowerAtPad(i, ShowcaseKinds[i]))
{
ExitTowerShowcase();
return false;
}
}
RestoreShowcasePlayerPosition();
for (int i = 0; i < ShowcaseKinds.Length; i++)
{
DefenseTowerKind kind = ShowcaseKinds[i];
Vector2 pad = Tuning.GetBuildPadPosition(i);
Vector2 position = DefenseTowerHitTestLayout.GetDummyPosition(kind);
GameObject actorObject = new($"Showcase Skeleton - {kind}");
actorObject.transform.SetParent(transform, false);
actorObject.transform.position = Defense25DActor.OnGround(position, settings.GroundY);
Defense25DActor actor = actorObject.AddComponent<Defense25DActor>();
actor.Configure(this, ComparisonActorKind.Enemy);
actor.SetEnemyMaximumHealth(Mathf.Max(1f, settings.Enemy.MaxHealth));
Defense25DEnemy dummy = actorObject.AddComponent<Defense25DEnemy>();
dummy.Configure(this, actor, int.MinValue + i, FindNonImmuneShowcaseLane(kind));
AttachEnemyStatusVisual(dummy);
dummy.ConfigureShowcaseDummy();
dummy.Spawn(position);
showcaseDummyKinds.Add(dummy, kind);
showcaseDummyPositions.Add(dummy, position);
showcaseObservedDamageEvents.Add(dummy, 0);
showcaseHitCounts[kind] = 0;
}
Physics.SyncTransforms();
return true;
}
public void ExitTowerShowcase()
{
if (!towerShowcaseActive) return;
towerShowcaseActive = false;
RestoreShowcasePlayerPosition();
if (showcaseTowerManager != null)
{
showcaseTowerManager.CancelAll();
showcaseTowerManager.ResetForRun();
}
TowerManager = runTowerManager;
GameTime = runGameTime;
runTowerManager?.SetShowcaseSuspended(false);
foreach (Defense25DEnemy enemy in showcaseSuspendedEnemies)
if (enemy != null)
{
enemy.gameObject.SetActive(true);
enemy.Actor.RestoreLiveCollisionAndVisuals();
}
foreach (Defense25DEnemy dummy in showcaseDummyKinds.Keys)
if (dummy != null)
{
dummy.gameObject.SetActive(false);
Destroy(dummy.gameObject);
}
if (showcaseRoot != null) Destroy(showcaseRoot);
showcaseDummyKinds.Clear();
showcaseDummyPositions.Clear();
showcaseObservedDamageEvents.Clear();
showcaseHitCounts.Clear();
showcaseSuspendedEnemies.Clear();
showcaseTowerManager = null;
showcaseRoot = null;
runTowerManager = null;
Physics.SyncTransforms();
}
public void AdvanceTowerShowcase(float dt)
{
if (!towerShowcaseActive || dt <= 0f) return;
GameTime += dt;
foreach (Defense25DEnemy dummy in showcaseDummyKinds.Keys.ToArray())
{
AccumulateShowcaseDamage(dummy);
}
if (showcaseTowerManager == null) return;
foreach (Defense25DTower tower in showcaseTowerManager.Towers)
if (tower != null && tower.Kind == DefenseTowerKind.Summon)
tower.StepShowcaseSummons(dt);
}
public void NotifyTowerShowcaseDummyDeath(Defense25DEnemy enemy)
{
if (!towerShowcaseActive || enemy == null
|| !showcaseDummyKinds.ContainsKey(enemy)) return;
AccumulateShowcaseDamage(enemy);
enemy.ConfigureShowcaseDummy();
enemy.Spawn(showcaseDummyPositions[enemy]);
showcaseObservedDamageEvents[enemy] = 0;
Physics.SyncTransforms();
}
private void AccumulateShowcaseDamage(Defense25DEnemy enemy)
{
if (!showcaseDummyKinds.TryGetValue(enemy, out DefenseTowerKind kind)) return;
int current = enemy.Actor.DamageEvents;
int previous = showcaseObservedDamageEvents.TryGetValue(enemy, out int observed) ? observed : 0;
if (current >= previous) showcaseHitCounts[kind] += current - previous;
showcaseObservedDamageEvents[enemy] = current;
}
private void MovePlayerForShowcaseBuild(Vector2 position)
{
Player.Body.position = Defense25DActor.OnGround(position, settings.GroundY);
Player.transform.position = Player.Body.position;
Physics.SyncTransforms();
}
private void RestoreShowcasePlayerPosition()
{
if (Player == null || Player.Body == null) return;
Player.Body.position = Defense25DActor.OnGround(runPlayerPosition, settings.GroundY);
Player.transform.position = Player.Body.position;
}
private int ShowcaseBuildCost(DefenseTowerKind kind) => kind switch
{
DefenseTowerKind.Direct => Tuning.DirectTowerCost,
DefenseTowerKind.Summon => Tuning.SummonTowerCost,
DefenseTowerKind.Lightning => Tuning.LightningTowerCost,
DefenseTowerKind.Cold => Tuning.ColdTowerCost,
DefenseTowerKind.Seismic => Tuning.SeismicTowerCost,
DefenseTowerKind.Empowering => Tuning.EmpoweringTowerCost,
_ => 0,
};
private int FindNonImmuneShowcaseLane(DefenseTowerKind kind)
{
for (int lane = 0; lane < 2; lane++)
if (!DefensePrototypeRules.IsTowerImmune(kind, Tuning.GetPortalImmuneTowerKind(lane)))
return lane;
return 0;
}
}
public sealed partial class Defense25DEnemy
{
public void ConfigureShowcaseDummy() => showcaseDummy = true;
}
public sealed partial class Defense25DTower
{
public float ShowcaseSupportBonus => Tuning.SupportDamageBonusPerTier * supportAuraStrength;
public void StepShowcaseSummons(float dt)
{
foreach (Defense25DSummon summon in ownedSummons)
if (summon != null) summon.Step(dt);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3408d4c9c47b4639b0c6d601915cc820
@@ -0,0 +1,146 @@
using System;
using BumpCombat.Combat;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DCycloneVisual : MonoBehaviour
{
private Defense25DComparison world;
private readonly Sprite[][] back = new Sprite[2][];
private readonly Sprite[][] front = new Sprite[2][];
private SpriteRenderer backRenderer, frontRenderer;
private MeshRenderer ribbonRenderer;
private Mesh ribbonMesh;
private Material ribbonMaterial;
private LineRenderer boundary;
private readonly Vector3[] vertices = new Vector3[130];
private readonly Vector2[] uv = new Vector2[130];
private bool visible;
public bool Ribbon { get; set; }
public bool Transparent { get; set; }
public float Height { get; set; } = .15f;
public float DecorationSpeed { get; set; } = 1f;
public float RadiusScale { get; set; } = 1f;
public bool IsVisible => visible;
public void Configure(Defense25DComparison owner)
{
world = owner;
for (int i = 0; i < 2; i++)
{
string kind = i == 0 ? "Normal" : "Charged";
string key = $"Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-{kind}-";
back[i] = Load(key + "Back-v3");
front[i] = Load(key + "Front-v3");
}
backRenderer = CreateSprite("Existing Back frames");
frontRenderer = CreateSprite("Existing Front frames");
GameObject ribbon = new("Existing texture on XZ ribbon");
ribbon.transform.SetParent(transform, false);
ribbonMesh = new Mesh { name = "Cyclone visual ribbon (no hitbox)" };
ribbonMesh.MarkDynamic();
ribbon.AddComponent<MeshFilter>().sharedMesh = ribbonMesh;
ribbonRenderer = ribbon.AddComponent<MeshRenderer>();
ribbonMaterial = new Material(world.Settings.CutoutMaterial);
ribbonRenderer.sharedMaterial = ribbonMaterial;
boundary = Defense25DSprite.CreateLine("Actual damage ellipse", world.Settings.HitBoundaryMaterial, transform, .025f);
int[] triangles = new int[64 * 6];
for (int i = 0; i < 64; i++)
{
int v = i * 2, t = i * 6;
triangles[t] = v; triangles[t + 1] = v + 1; triangles[t + 2] = v + 2;
triangles[t + 3] = v + 1; triangles[t + 4] = v + 3; triangles[t + 5] = v + 2;
}
ribbonMesh.vertices = vertices;
ribbonMesh.triangles = triangles;
SetVisible(false);
}
private static Sprite[] Load(string key)
{
Texture2D texture = Resources.Load<Texture2D>(key);
if (!ActiveArtifactController.IsCycloneOrbitTextureValid(texture))
throw new InvalidOperationException($"Existing Cyclone frame sheet missing or invalid: {key}");
return ActiveArtifactController.CreateCycloneOrbitFrames(texture, key);
}
private SpriteRenderer CreateSprite(string label)
{
GameObject go = new(label);
go.transform.SetParent(transform, false);
return go.AddComponent<SpriteRenderer>();
}
public void SetVisible(bool value)
{
visible = value;
if (backRenderer == null) return;
backRenderer.enabled = frontRenderer.enabled = value && !Ribbon;
ribbonRenderer.enabled = value && Ribbon;
boundary.enabled = value && world.ShowBoundaries;
}
private void LateUpdate()
{
if (!visible || !world.CycloneActive) return;
SetVisible(true);
int kind = world.IsCycloneCharged ? 1 : 0;
int frame = Mathf.FloorToInt(world.CycloneElapsed / CycloneGeometry.FrameSeconds);
if (front[kind].Length == 0 || back[kind].Length == 0) return;
Sprite frontFrame = front[kind][frame % front[kind].Length];
Sprite backFrame = back[kind][frame % back[kind].Length];
Material material = Transparent ? world.Settings.TransparentMaterial : world.Settings.CutoutMaterial;
backRenderer.sharedMaterial = frontRenderer.sharedMaterial = material;
backRenderer.sprite = backFrame;
frontRenderer.sprite = frontFrame;
Vector2 center = world.CycloneCenter;
Vector2 radii = world.CycloneRadii;
Quaternion rotation = world.View != null ? world.View.transform.rotation : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
Vector3 position = Defense25DActor.OnGround(center, world.Settings.GroundY);
float scale = CycloneGeometry.VisualScale(world.IsCycloneCharged ? world.Settings.Cyclone.ChargedRange : world.Settings.Cyclone.NormalRange,
world.IsCycloneCharged) * RadiusScale;
foreach (SpriteRenderer renderer in new[] { backRenderer, frontRenderer })
{
renderer.transform.rotation = rotation;
renderer.transform.localScale = Vector3.one * scale;
renderer.transform.position = position + Vector3.up * Height;
}
// Real depth, not ZTest Always or a high global sorting layer.
backRenderer.transform.position += Vector3.forward * .12f;
frontRenderer.transform.position -= Vector3.forward * .12f;
Defense25DSprite.SetEllipse(boundary, center, radii, world.Settings.GroundY + .035f);
if (!Ribbon) return;
ribbonMaterial.CopyPropertiesFromMaterial(material);
ribbonMaterial.mainTexture = frontFrame.texture;
Rect rect = frontFrame.textureRect;
Vector2 texel = frontFrame.texture.texelSize;
for (int i = 0; i <= 64; i++)
{
float t = i / 64f;
float angle = t * Mathf.PI * 2f + world.CycloneElapsed * DecorationSpeed * Mathf.PI * 2f;
Vector2 ground = center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y) * RadiusScale;
float height = world.Settings.GroundY + Height + .075f * Mathf.Sin(angle * 3f);
vertices[2 * i] = Defense25DActor.OnGround(ground, height - .08f);
vertices[2 * i + 1] = Defense25DActor.OnGround(ground, height + .08f);
uv[2 * i] = new Vector2((rect.x + rect.width * t) * texel.x, rect.y * texel.y);
uv[2 * i + 1] = new Vector2((rect.x + rect.width * t) * texel.x, rect.yMax * texel.y);
}
ribbonMesh.vertices = vertices;
ribbonMesh.uv = uv;
ribbonMesh.RecalculateBounds();
}
private void OnDisable() => SetVisible(false);
private void OnDestroy()
{
foreach (Sprite[] frames in back)
if (frames != null) foreach (Sprite frame in frames) Destroy(frame);
foreach (Sprite[] frames in front)
if (frames != null) foreach (Sprite frame in frames) Destroy(frame);
if (ribbonMesh != null) Destroy(ribbonMesh);
if (ribbonMaterial != null) Destroy(ribbonMaterial);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01d30ec45bfe4e77a9ff3fca2a4d592a
@@ -0,0 +1,310 @@
using BumpCombat.Constants;
using BumpCombat.Combat;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DEnemy : MonoBehaviour
{
private Defense25DComparison world;
private EnemyAttackPattern pattern;
private Defense25DActor target;
private Vector2 attackDirection;
private float attackStart = -1f, progressUntil;
private float deathVisualRemovalAt = -1f;
private float slowUntil, slowFraction, stunUntil;
private int stunVisualRevision;
private float igniteRemaining, igniteInterval, igniteNextTick, igniteDamage;
private float shockRemaining, shockIncrease, vulnerabilityRemaining, vulnerabilityIncrease;
private bool dealtDamage, supplied, retired;
private bool showcaseDummy;
public Defense25DActor Actor { get; private set; }
public int Id { get; private set; }
public int Lane { get; private set; }
public bool CanSpawn => !supplied;
public int WaypointIndex { get; private set; }
public bool IsAvailable => isActiveAndEnabled && supplied && !retired && Actor.IsAlive;
public bool IsShowcaseDummy => showcaseDummy;
public bool IsAttacking => attackStart >= 0f;
public bool IsStunned => world != null && world.GameTime < stunUntil;
public int StunVisualRevision => stunVisualRevision;
public bool IsIgnited => igniteRemaining > 0f;
public bool IsShocked => shockRemaining > 0f;
public EnemyDamageSource LastDamageSource { get; private set; }
public float IgniteRemaining => igniteRemaining;
public float IgniteInterval => igniteInterval;
public float IgniteTickDamage => igniteDamage;
public void Configure(Defense25DComparison owner, Defense25DActor actor, int id, int lane = 0)
{
showcaseDummy = false;
world = owner; Actor = actor; Id = id; Lane = lane;
pattern = world.Settings.Enemy.GetAttackPattern(0);
ConfigureSkeletonCombat(world.Settings.Enemy, world.Settings.EnemyVisualScale,
world.Settings.EnemyAttackClip);
}
public void ResetForRun()
{
showcaseDummy = false;
supplied = retired = false;
attackStart = -1f;
deathVisualRemovalAt = -1f;
progressUntil = 0f;
slowUntil = slowFraction = stunUntil = 0f;
ResetArtifactEffects();
LastDamageSource = EnemyDamageSource.None;
WaypointIndex = 1;
target = null;
skeletonAttackAnimationStarted = false;
SetSkeletonSlashVisible(false);
gameObject.SetActive(false);
}
public void Spawn(Vector2 position)
{
supplied = true;
retired = false;
attackStart = -1f;
deathVisualRemovalAt = -1f;
slowUntil = slowFraction = stunUntil = 0f;
ResetArtifactEffects();
LastDamageSource = EnemyDamageSource.None;
WaypointIndex = 1;
Actor.ResetAt(position);
}
public void Retire()
{
retired = true;
deathVisualRemovalAt = -1f;
attackStart = -1f;
target = null;
Actor.StopBody();
gameObject.SetActive(false);
}
public void RetireForDeathVisual()
{
retired = true;
attackStart = -1f;
target = null;
SetSkeletonSlashVisible(false);
Actor.StopBody();
float deathDuration = GameplayConstants.Current.Enemies.DefaultDeathDuration;
float padding = GameplayConstants.Current.Enemies.DeathRemovalPadding;
deathVisualRemovalAt = world.GameTime + deathDuration + padding;
}
private void LateUpdate()
{
if (deathVisualRemovalAt >= 0f && world.GameTime >= deathVisualRemovalAt)
{
deathVisualRemovalAt = -1f;
gameObject.SetActive(false);
}
}
public bool InterruptAttackFromRearHit()
{
if (!IsRearInterruptible) return false;
EndAttack();
CombatEvents.RaiseAttackCancelled(gameObject, false);
CombatEvents.RaiseRearAttackCancelled(gameObject);
return true;
}
private bool IsRearInterruptible
{
get
{
if (!IsAttacking) return false;
float elapsed = world.GameTime - attackStart;
return elapsed < pattern.WarningDuration
|| elapsed < pattern.WarningDuration + pattern.ActiveDuration;
}
}
private void EndAttack()
{
attackStart = -1f;
target = null;
Actor.Visual.SetAttack(false);
progressUntil = world.GameTime + GameplayConstants.Current.Defense.PostAttackProgressSeconds;
}
public void Step(float dt)
{
if (!IsAvailable) return;
StepArtifactEffects(dt);
if (showcaseDummy) { Actor.StopBody(); return; }
if (skeletonDefinition != null && StepSkeletonCombat(dt)) return;
if (IsStunned) { Actor.StopBody(); return; }
if (Actor.HasKnockback) { Actor.Move(Actor.ConsumeKnockback(dt)); return; }
if (IsAttacking)
{
if (target == null || !target.IsAlive || (target.IsMinion && !world.TowersOperational))
{ EndAttack(); return; }
float elapsed = world.GameTime - attackStart;
float activeElapsed = elapsed - pattern.WarningDuration;
Actor.Visual.SetWarning(elapsed < pattern.WarningDuration);
if (pattern.IsContactWindowActive(activeElapsed, pattern.ActiveDuration))
{
Actor.Move(attackDirection * (GameplayConstants.Current.Enemies.BodyContactSpeed * dt), false);
if (!dealtDamage && Actor.GapTo(target) <= world.Settings.ContactSensorMargin)
{
dealtDamage = target.TakeDamage(Actor.CalculateAttackDamage(world.Settings.Enemy.AttackDamage * pattern.DamageMultiplier),
attackDirection, target.IsMinion ? .3f : GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance);
}
}
if (elapsed >= pattern.WarningDuration + pattern.ActiveDuration + pattern.RecoveryDuration) EndAttack();
return;
}
if (world.GameTime >= progressUntil)
{
target = FindTarget();
if (target != null)
{
attackStart = world.GameTime;
dealtDamage = false;
attackDirection = (target.Position - Actor.Position).normalized;
Actor.Facing = attackDirection;
Actor.Visual.SetAttack(true);
return;
}
}
Vector2[] route = world.GetRouteForLane(Lane);
Vector2 delta = route[WaypointIndex] - Actor.Position;
if (delta.magnitude <= .2f)
{
if (WaypointIndex >= route.Length - 1) { world.ResolveEnemy(this, DefenseEnemyResolution.Arrival); return; }
WaypointIndex++;
delta = route[WaypointIndex] - Actor.Position;
}
Actor.Facing = delta.normalized;
Vector2 separation = Vector2.zero;
foreach (Defense25DEnemy other in world.Enemies)
{
if (other == this || !other.IsAvailable) continue;
Vector2 away = Actor.Position - other.Actor.Position;
float length = away.magnitude;
if (length > .001f && length < world.Settings.Enemy.SeparationRadius)
separation += away / length * (1f - length / world.Settings.Enemy.SeparationRadius);
}
Vector2 direction = (delta.normalized + separation * world.Settings.Enemy.SeparationStrength).normalized;
float slow = world.GameTime < slowUntil ? slowFraction : 0f;
Actor.Move(direction * Mathf.Min(delta.magnitude,
Actor.MoveSpeed * (1f - slow) * GameplayConstants.Current.Defense.RouteSpeedMultiplier * dt));
}
public bool ApplyIgnite(float duration, float interval, float tickDamage,
EnemyDamageSource source = EnemyDamageSource.Player)
{
if (!IsAvailable || duration <= 0f || interval <= 0f || tickDamage <= 0f) return false;
bool wasInactive = igniteRemaining <= 0f;
igniteRemaining = Mathf.Max(igniteRemaining, duration);
igniteDamage = Mathf.Max(igniteDamage, tickDamage);
if (wasInactive) igniteInterval = interval;
if (wasInactive) igniteNextTick = interval;
igniteSource = source;
return true;
}
public bool ApplyShock(float duration, float damageIncrease)
{
if (!IsAvailable || duration <= 0f || damageIncrease <= 0f) return false;
shockRemaining = Mathf.Max(shockRemaining, duration);
shockIncrease = Mathf.Max(shockIncrease, damageIncrease);
return true;
}
public bool ApplyBumpVulnerability(float duration, float damageIncrease)
{
if (!IsAvailable || duration <= 0f || damageIncrease <= 0f) return false;
vulnerabilityRemaining = Mathf.Max(vulnerabilityRemaining, duration);
vulnerabilityIncrease = Mathf.Max(vulnerabilityIncrease, damageIncrease);
return true;
}
public float ApplyDamageTakenEffects(float damage, bool ordinaryBump) =>
DamageCalculator.ApplyDamageTakenEffects(damage, shockIncrease,
shockRemaining > 0f, vulnerabilityIncrease,
ordinaryBump && vulnerabilityRemaining > 0f);
public void SetLastDamageSource(EnemyDamageSource source) => LastDamageSource = source;
private EnemyDamageSource igniteSource;
private void StepArtifactEffects(float dt)
{
if (dt <= 0f) return;
if (igniteRemaining > 0f)
{
float before = igniteRemaining;
igniteRemaining = Mathf.Max(0f, before - dt);
while (igniteNextTick <= dt + .0001f && igniteNextTick <= before + .0001f)
{
Actor.TakeDamage(igniteDamage, Vector2.zero, 0f, igniteSource);
if (!Actor.IsAlive) break;
igniteNextTick += igniteInterval;
}
igniteNextTick -= dt;
if (igniteRemaining <= 0f)
igniteNextTick = igniteInterval = igniteDamage = 0f;
}
shockRemaining = Mathf.Max(0f, shockRemaining - dt);
if (shockRemaining <= 0f) shockIncrease = 0f;
vulnerabilityRemaining = Mathf.Max(0f, vulnerabilityRemaining - dt);
if (vulnerabilityRemaining <= 0f) vulnerabilityIncrease = 0f;
}
private void ResetArtifactEffects()
{
igniteRemaining = igniteInterval = igniteNextTick = igniteDamage = 0f;
shockRemaining = shockIncrease = vulnerabilityRemaining = vulnerabilityIncrease = 0f;
igniteSource = EnemyDamageSource.None;
}
public bool ApplySlow(float duration, float fraction)
{
if (!IsAvailable || IsStunned || duration <= 0f || fraction <= 0f) return false;
slowUntil = Mathf.Max(slowUntil, world.GameTime + duration);
slowFraction = Mathf.Max(slowFraction, Mathf.Clamp01(fraction));
return true;
}
public bool ApplyStun(float duration)
{
if (!IsAvailable || duration <= 0f) return false;
stunVisualRevision++;
stunUntil = Mathf.Max(stunUntil, world.GameTime + duration);
if (IsAttacking)
{
attackStart = -1f;
target = null;
Actor.Visual.SetAttack(false);
progressUntil = world.GameTime + GameplayConstants.Current.Defense.PostAttackProgressSeconds;
}
Actor.StopBody();
return true;
}
private Defense25DActor FindTarget()
{
Defense25DActor best = null;
float distance = pattern.AttackRange;
void Consider(Defense25DActor actor)
{
if (!actor.IsAlive) return;
float candidate = Vector2.Distance(Actor.Position, actor.Position);
if (candidate > distance) return;
distance = candidate; best = actor;
}
Consider(world.Player);
if (world.TowersOperational)
foreach (Defense25DSummon summon in world.Summons) Consider(summon.Actor);
return best;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fb1f1b5a3fcb417788dec00833f6b32a
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Constants;
using BumpCombat.Player;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public readonly struct Defense25DArtifactUse
{
public Defense25DArtifactUse(ActiveArtifactDefinition definition, bool charged)
{
Definition = definition;
Charged = charged;
}
public ActiveArtifactDefinition Definition { get; }
public bool Charged { get; }
public ActiveArtifactEffect Effect => Definition.Effect;
public float Damage => Charged ? Definition.ChargedDamage : Definition.NormalDamage;
public float Range => Charged ? Definition.ChargedRange : Definition.NormalRange;
public float Width => Charged ? Definition.ChargedWidth : Definition.NormalWidth;
public float Duration => Charged ? Definition.ChargedDuration : Definition.NormalDuration;
public float Knockback => Charged ? Definition.ChargedKnockback : Definition.NormalKnockback;
public int MaxTargets => Charged ? Definition.ChargedMaxTargets : Definition.NormalMaxTargets;
public float GaugeCost => Charged ? Definition.ChargedGaugeCost : Definition.NormalGaugeCost;
}
/// <summary>
/// Owns the comparison run's artifact/relic state. The world supplies effect execution
/// callbacks so this state stays independent of the original Physics2D singletons.
/// </summary>
public sealed class Defense25DLoadout
{
private readonly List<ActiveArtifactDefinition> catalog = new();
private readonly List<ActiveArtifactDefinition> ownedArtifacts = new();
private readonly HashSet<PassiveRelicId> ownedRelics = new();
private readonly float startingGauge;
private readonly float maximumGauge;
private readonly int maximumArtifacts;
private readonly int maximumPerColor;
private int selectedIndex;
private float chargeElapsed;
private ActiveArtifactDefinition chargingArtifact;
public Defense25DLoadout(float startingGauge, float maximumGauge, int maximumArtifacts, int maximumPerColor)
{
this.startingGauge = Mathf.Clamp(startingGauge, 0f, Mathf.Max(0f, maximumGauge));
this.maximumGauge = Mathf.Max(0f, maximumGauge);
this.maximumArtifacts = Mathf.Max(0, maximumArtifacts);
this.maximumPerColor = Mathf.Max(0, maximumPerColor);
Gauge = this.startingGauge;
}
public Func<Defense25DArtifactUse, bool> ExecuteArtifact { get; set; }
public Action<PassiveRelicId> DispatchRelicTrigger { get; set; }
public event Action<Defense25DArtifactUse> ArtifactUseSucceeded;
public bool ChargeUnlocked { get; set; } = true;
public float Gauge { get; private set; }
public float MaximumGauge => maximumGauge;
public float ChargeProgress => chargingArtifact == null || chargingArtifact.ChargeDuration <= 0f
? 0f : Mathf.Clamp01(chargeElapsed / chargingArtifact.ChargeDuration);
public bool IsCharging => chargingArtifact != null;
public int OwnedArtifactCount => ownedArtifacts.Count;
public int OwnedRelicCount => ownedRelics.Count;
public IReadOnlyList<ActiveArtifactDefinition> Catalog => catalog;
public IReadOnlyList<ActiveArtifactDefinition> OwnedArtifacts => ownedArtifacts;
public IReadOnlyCollection<PassiveRelicId> OwnedRelics => ownedRelics;
public ActiveArtifactDefinition CurrentArtifact => ownedArtifacts.Count == 0
? null : ownedArtifacts[Mathf.Clamp(selectedIndex, 0, ownedArtifacts.Count - 1)];
public void ConfigureCatalog(IEnumerable<ActiveArtifactDefinition> definitions)
{
catalog.Clear();
if (definitions == null) return;
foreach (ActiveArtifactDefinition definition in definitions)
{
if (definition != null && !catalog.Any(item => item.Effect == definition.Effect))
catalog.Add(definition);
}
}
public ActiveArtifactDefinition FindArtifact(ActiveArtifactEffect effect) =>
catalog.FirstOrDefault(definition => definition.Effect == effect);
public bool TryAddArtifact(ActiveArtifactDefinition definition)
{
if (definition == null || ownedArtifacts.Count >= maximumArtifacts
|| ownedArtifacts.Any(item => item.Effect == definition.Effect)
|| ownedArtifacts.Count(item => item.ArtifactColor == definition.ArtifactColor) >= maximumPerColor)
return false;
ownedArtifacts.Add(definition);
if (ownedArtifacts.Count == 1) selectedIndex = 0;
return true;
}
public bool OwnsArtifact(ActiveArtifactEffect effect) =>
ownedArtifacts.Any(definition => definition.Effect == effect);
public bool Select(int index)
{
if (index < 0 || index >= ownedArtifacts.Count) return false;
if (selectedIndex != index) CancelCharge();
selectedIndex = index;
return true;
}
public void AddGauge(float amount) => Gauge = Mathf.Clamp(Gauge + amount, 0f, maximumGauge);
public bool BeginCharge()
{
ActiveArtifactDefinition definition = CurrentArtifact;
if (IsCharging || definition == null || definition.ChargeDuration <= 0f
|| !ChargeUnlocked || Gauge < definition.NormalGaugeCost)
return false;
chargingArtifact = definition;
chargeElapsed = 0f;
return true;
}
public void AdvanceCharge(float deltaTime)
{
if (IsCharging) chargeElapsed = Mathf.Min(chargeElapsed + Mathf.Max(0f, deltaTime), chargingArtifact.ChargeDuration);
}
public bool ReleaseCharge()
{
ActiveArtifactDefinition definition = chargingArtifact;
if (definition == null) return false;
bool charged = ReferenceEquals(definition, CurrentArtifact) && ChargeUnlocked
&& ChargeProgress >= 1f && Gauge >= definition.ChargedGaugeCost;
CancelCharge();
return TryUse(charged);
}
public void CancelCharge()
{
chargingArtifact = null;
chargeElapsed = 0f;
}
public bool TryUse(bool charged)
{
ActiveArtifactDefinition definition = CurrentArtifact;
if (definition == null || IsCharging || (charged && !ChargeUnlocked)) return false;
Defense25DArtifactUse use = new(definition, charged);
if (Gauge < use.GaugeCost || ExecuteArtifact == null || !ExecuteArtifact(use)) return false;
AddGauge(-use.GaugeCost);
ArtifactUseSucceeded?.Invoke(use);
return true;
}
public PassiveRelicId[] GetAvailableRelics()
{
return PassiveRelicRules.GetAvailableRelics(ownedRelics)
.Where(CanOfferRelic).ToArray();
}
public bool TryAcquireRelic(PassiveRelicId id)
{
if (!GetAvailableRelics().Contains(id)) return false;
return ownedRelics.Add(id);
}
public bool OwnsRelic(PassiveRelicId id) => ownedRelics.Contains(id);
public void NotifyRelicTrigger(PassiveRelicId id)
{
if (ownedRelics.Contains(id)) DispatchRelicTrigger?.Invoke(id);
}
public void ResetRun()
{
CancelCharge();
ownedArtifacts.Clear();
ownedRelics.Clear();
selectedIndex = 0;
Gauge = startingGauge;
}
private bool CanOfferRelic(PassiveRelicId id)
{
if (id == PassiveRelicId.EmberCarrier)
return ownedRelics.Contains(PassiveRelicId.EmberWick) || OwnsArtifact(ActiveArtifactEffect.Phoenix);
if (id == PassiveRelicId.ResonantCrystal)
return ownedArtifacts.Any(definition => definition.NormalDamage > 0f || definition.ChargedDamage > 0f);
return true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1060f84efa70459e92eff3b57938c07a
@@ -0,0 +1,173 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
[DefaultExecutionOrder(1001)]
public sealed class Defense25DPlayerArt : MonoBehaviour
{
private readonly Dictionary<string, Sprite[]> equipmentFrames = new();
private readonly Dictionary<Sprite, Sprite> equipmentOriginals = new();
private readonly Dictionary<Sprite, Sprite> guardFrames = new();
private Defense25DComparison world;
private Defense25DActor player;
private SpriteRenderer playerRenderer;
private SpriteRenderer guardOutline;
public void Bind(Defense25DComparison comparison)
{
if (world != comparison) ResetVisuals();
world = comparison;
player = world != null ? world.Player : null;
playerRenderer = player != null && player.Visual != null
? player.Visual.Renderer : null;
if (world != null && !world.isActiveAndEnabled)
{
ResetVisuals();
return;
}
RefreshEquipmentSprite();
RefreshGuardOutline();
}
public void ResetVisuals()
{
RestoreEquipmentSprite();
if (guardOutline != null) guardOutline.enabled = false;
}
private void LateUpdate()
{
if (world == null || !world.isActiveAndEnabled)
{
ResetVisuals();
return;
}
if (player == null) player = world.Player;
if (playerRenderer == null && player != null && player.Visual != null)
playerRenderer = player.Visual.Renderer;
RefreshEquipmentSprite();
RefreshGuardOutline();
}
private void RefreshEquipmentSprite()
{
if (playerRenderer == null || playerRenderer.sprite == null) return;
Sprite current = playerRenderer.sprite;
Sprite original = ResolveOriginalSprite(current);
ActiveArtifactDefinition selected = world != null ? world.Loadout?.CurrentArtifact : null;
if (selected == null)
{
playerRenderer.sprite = original;
return;
}
string baseTextureName = StripArtifactPaletteSuffix(original.texture.name);
string path = "Artifacts/ThreeColor-v1/Player/" + baseTextureName
+ "-" + selected.ArtifactColor + "-v1";
if (!equipmentFrames.TryGetValue(path, out Sprite[] frames))
{
frames = Resources.LoadAll<Sprite>(path);
equipmentFrames[path] = frames;
}
foreach (Sprite frame in frames)
{
if (frame.rect != original.rect) continue;
equipmentOriginals[frame] = original;
playerRenderer.sprite = frame;
return;
}
playerRenderer.sprite = original;
}
private void RefreshGuardOutline()
{
bool visible = isActiveAndEnabled && player != null && player.IsAlive
&& player.IsGuarding
&& playerRenderer != null && playerRenderer.enabled && playerRenderer.sprite != null;
if (!visible)
{
if (guardOutline != null) guardOutline.enabled = false;
return;
}
if (guardOutline == null)
{
GameObject outlineObject = new("Player Guard Outline");
outlineObject.transform.SetParent(playerRenderer.transform, false);
guardOutline = outlineObject.AddComponent<SpriteRenderer>();
}
Sprite current = playerRenderer.sprite;
Sprite original = ResolveOriginalSprite(current);
if (!guardFrames.TryGetValue(original, out Sprite mask))
{
foreach (Sprite frame in Resources.LoadAll<Sprite>(
"Combat/Guard-v1/" + StripArtifactPaletteSuffix(original.texture.name) + "-outline"))
{
if (frame.rect != original.rect) continue;
mask = frame;
break;
}
guardFrames[original] = mask;
}
guardOutline.sprite = mask;
guardOutline.enabled = mask != null;
guardOutline.flipX = playerRenderer.flipX;
guardOutline.flipY = playerRenderer.flipY;
guardOutline.color = new Color(1f, 1f, 1f, playerRenderer.color.a);
guardOutline.sharedMaterial = playerRenderer.sharedMaterial;
guardOutline.sortingLayerID = playerRenderer.sortingLayerID;
guardOutline.sortingOrder = playerRenderer.sortingOrder + 1;
}
private void RestoreEquipmentSprite()
{
if (playerRenderer != null && playerRenderer.sprite != null
&& equipmentOriginals.TryGetValue(playerRenderer.sprite, out Sprite original))
playerRenderer.sprite = original;
}
private Sprite ResolveOriginalSprite(Sprite current)
{
if (equipmentOriginals.TryGetValue(current, out Sprite original)) return original;
string textureName = current.texture.name;
string baseTextureName = StripArtifactPaletteSuffix(textureName);
if (baseTextureName == textureName || world == null || world.Settings == null
|| world.Settings.PlayerArt == null)
return current;
ComparisonSpriteFrames playerFrames = world.Settings.PlayerArt;
Sprite originalFrame = FindMatchingFrame(playerFrames.Idle, baseTextureName, current.rect)
?? FindMatchingFrame(playerFrames.Walk, baseTextureName, current.rect)
?? FindMatchingFrame(playerFrames.Attack, baseTextureName, current.rect)
?? FindMatchingFrame(playerFrames.Hurt, baseTextureName, current.rect)
?? FindMatchingFrame(playerFrames.Death, baseTextureName, current.rect);
if (originalFrame != null) equipmentOriginals[current] = originalFrame;
return originalFrame != null ? originalFrame : current;
}
private static Sprite FindMatchingFrame(Sprite[] frames, string textureName, Rect rect)
{
if (frames == null) return null;
foreach (Sprite frame in frames)
if (frame != null && frame.texture.name == textureName && frame.rect == rect)
return frame;
return null;
}
private static string StripArtifactPaletteSuffix(string textureName)
{
string[] suffixes = { "-Green-v1", "-Red-v1", "-Blue-v1" };
foreach (string suffix in suffixes)
if (textureName.EndsWith(suffix))
return textureName.Substring(0, textureName.Length - suffix.Length);
return textureName;
}
private void OnDisable() => ResetVisuals();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 98960d72f62d4a23b1e4f4678b15787b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 1001
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,518 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public readonly struct Defense25DRelicTarget
{
public Defense25DRelicTarget(
int identity, Vector3 position, float health, float maximumHealth,
EnemyDamageSource lastDamageSource, bool isDead, bool isElite = false,
bool isMidBoss = false, bool isFinalBoss = false,
float igniteRemaining = 0f, float igniteInterval = 0f, float igniteTickDamage = 0f)
{
Identity = identity;
Position = position;
Health = health;
MaximumHealth = maximumHealth;
LastDamageSource = lastDamageSource;
IsDead = isDead;
IsElite = isElite;
IsMidBoss = isMidBoss;
IsFinalBoss = isFinalBoss;
IgniteRemaining = igniteRemaining;
IgniteInterval = igniteInterval;
IgniteTickDamage = igniteTickDamage;
}
public int Identity { get; }
public Vector3 Position { get; }
public float Health { get; }
public float MaximumHealth { get; }
public EnemyDamageSource LastDamageSource { get; }
public bool IsDead { get; }
public bool IsElite { get; }
public bool IsMidBoss { get; }
public bool IsFinalBoss { get; }
public float IgniteRemaining { get; }
public float IgniteInterval { get; }
public float IgniteTickDamage { get; }
}
public readonly struct Defense25DRelicHit
{
public Defense25DRelicHit(
Defense25DRelicTarget target, Vector3 position, Vector3 direction,
float damage, float baseBumpDamage, bool ordinaryBump,
bool artifactHit = false, string sourceId = null)
{
Target = target;
Position = position;
Direction = direction;
Damage = damage;
BaseBumpDamage = baseBumpDamage;
OrdinaryBump = ordinaryBump;
ArtifactHit = artifactHit;
SourceId = sourceId;
}
public Defense25DRelicTarget Target { get; }
public Vector3 Position { get; }
public Vector3 Direction { get; }
public float Damage { get; }
public float BaseBumpDamage { get; }
public bool OrdinaryBump { get; }
public bool ArtifactHit { get; }
public string SourceId { get; }
}
/// <summary>World operations required by relic procs; QueryEnemies returns 2.5D body/circle overlaps, not center-radius matches.</summary>
public sealed class Defense25DRelicEffects
{
public Func<Vector3, float, IReadOnlyList<Defense25DRelicTarget>> QueryEnemies { get; set; }
public Func<Defense25DRelicTarget, float, Vector3, EnemyDamageSource, bool> DamageEnemy { get; set; }
public Func<Defense25DRelicTarget, float, float, float, EnemyDamageSource, bool> IgniteEnemy { get; set; }
public Func<float, DamageTag, float> CalculatePlayerDamage { get; set; }
public Func<Defense25DRelicTarget, float, float, bool> ApplyVulnerability { get; set; }
public Func<Defense25DRelicTarget, float, float, bool> ApplySlow { get; set; }
public Func<float, bool> HealPlayer { get; set; }
public Func<float, float, bool> GrantShield { get; set; }
public Func<float, bool> RestoreGauge { get; set; }
public Func<float, bool> BoostVisibleTowers { get; set; }
public Func<int, bool> AwardCurrency { get; set; }
public Func<Defense25DArtifactUse, Vector3, Vector3, float, bool> EmitEcho { get; set; }
public Func<Defense25DArtifactUse, Vector3, Vector3, float, bool> EmitRepeat { get; set; }
public Action<Vector3, float, float> SpawnHealingOrb { get; set; }
public Action<StatModifier, float> SetTimedPlayerModifier { get; set; }
public Action<string> RemovePlayerModifier { get; set; }
}
/// <summary>
/// Stateful passive relic rules for the 2.5D run. It owns proc state/timers only; the
/// comparison world supplies target queries and applies every physical/gameplay effect.
/// </summary>
public sealed class Defense25DRelicRuntime
{
public const string EchoSourceId = PassiveRelicController.EchoSourceId;
public const string HunterBuffSourceId = "relic.hunter-feather";
public const string TrophyBuffSourceId = "relic.trophy-mask";
private readonly Defense25DLoadout loadout;
private readonly PassiveRelicConstants tuning;
private readonly Defense25DRelicEffects effects;
private readonly Func<float> randomRoll;
private readonly HashSet<int> processedDeaths = new();
private readonly Dictionary<int, float> recentBumps = new();
private readonly Dictionary<int, (int hits, float at)> riftHits = new();
private float now;
private float nextEcho, nextWave, nextChain, nextCarrier, nextBlood, nextRecovery;
private float nextHeart, heartDamage, nextBell, nextRepeat, nextBaton, nextLedger, nextPouch;
private float hunterUntil, trophyUntil, voluntaryDistance;
private float healingOrbExpiresAt;
private bool wheelCharged, repeatPending, healingOrbPending, active;
private Defense25DArtifactUse pendingRepeat;
private Vector3 repeatOrigin, repeatDirection;
public Defense25DRelicRuntime(
Defense25DLoadout loadout, PassiveRelicConstants tuning,
Defense25DRelicEffects effects, Func<float> randomRoll = null)
{
this.loadout = loadout ?? throw new ArgumentNullException(nameof(loadout));
this.tuning = tuning ?? throw new ArgumentNullException(nameof(tuning));
this.effects = effects ?? throw new ArgumentNullException(nameof(effects));
this.randomRoll = randomRoll ?? (() => UnityEngine.Random.value);
}
public bool IsActive => active;
public bool IsWheelCharged => wheelCharged;
public bool IsRepeatPending => repeatPending;
public bool IsHealingOrbPending => healingOrbPending;
public float HunterExpiresAt => hunterUntil;
public float TrophyExpiresAt => trophyUntil;
public void BeginRun(float time = 0f)
{
ResetState(time);
active = true;
}
public void EndRun()
{
active = false;
repeatPending = false;
healingOrbPending = false;
ClearTemporaryModifiers();
}
public void ResetRun(float time = 0f) => ResetState(time);
private void ResetState(float time)
{
ClearTemporaryModifiers();
now = time;
processedDeaths.Clear();
recentBumps.Clear();
riftHits.Clear();
nextEcho = nextWave = nextChain = nextCarrier = nextBlood = nextRecovery = 0f;
nextHeart = nextBell = nextRepeat = nextBaton = nextLedger = nextPouch = 0f;
heartDamage = voluntaryDistance = 0f;
hunterUntil = trophyUntil = 0f;
wheelCharged = repeatPending = healingOrbPending = false;
healingOrbExpiresAt = 0f;
active = false;
}
public void Advance(float time, float playerHealth)
{
now = time;
if (repeatPending && now >= repeatAt && (!active || playerHealth <= 0f))
repeatPending = false;
if (!active || playerHealth <= 0f)
{
ClearTemporaryModifiers();
return;
}
if (hunterUntil > 0f && now >= hunterUntil)
{
effects.RemovePlayerModifier?.Invoke(HunterBuffSourceId);
hunterUntil = 0f;
}
if (trophyUntil > 0f && now >= trophyUntil)
{
effects.RemovePlayerModifier?.Invoke(TrophyBuffSourceId);
trophyUntil = 0f;
}
if (healingOrbPending && now >= healingOrbExpiresAt)
healingOrbPending = false;
if (repeatPending && now >= repeatAt)
{
repeatPending = false;
if (effects.EmitRepeat?.Invoke(pendingRepeat, repeatOrigin, repeatDirection,
tuning.ResonantCrystalDamageFraction) == true)
nextRepeat = now + tuning.ResonantCrystalCooldownSeconds;
}
}
private float repeatAt;
public float ApplyOrdinaryBumpDamage(float damage, bool ordinaryBump, bool hasArtifactColor)
{
if (!active || !loadout.OwnsRelic(PassiveRelicId.TwinHornTalisman)) return damage;
return PassiveRelicRules.ApplyTwinHornDamage(damage, true, ordinaryBump,
hasArtifactColor, tuning.TwinHornDoubleDamageChance, Roll());
}
public float ApplyTrophyBumpDamage(float damage, bool ordinaryBump) =>
active && ordinaryBump && now < trophyUntil
? damage * (1f + tuning.TrophyMaskBumpDamageIncrease) : damage;
public void AddVoluntaryMovement(float actualDistance, float bodyDiameter, float playerHealth)
{
if (!active || !loadout.OwnsRelic(PassiveRelicId.SprintWheel) || wheelCharged
|| actualDistance <= 0f || playerHealth <= 0f || bodyDiameter <= 0f) return;
voluntaryDistance += actualDistance;
wheelCharged = voluntaryDistance >= bodyDiameter * tuning.SprintWheelRequiredBodyDiameters;
}
public void NotifyHit(Defense25DRelicHit hit, float playerHealth, float maximumPlayerHealth = 0f)
{
if (maximumPlayerHealth > 0f) MaximumPlayerHealth = maximumPlayerHealth;
if (!active || hit.Damage <= 0f || playerHealth <= 0f
|| hit.SourceId == EchoSourceId) return;
if (hit.OrdinaryBump && !hit.ArtifactHit && loadout.OwnsRelic(PassiveRelicId.BondLedger))
recentBumps[hit.Target.Identity] = now;
if (hit.OrdinaryBump && !hit.ArtifactHit) TrySprintWheel(hit);
if (hit.BaseBumpDamage > 0f)
{
TryWaveNail(hit);
TryCapacitorRing(hit);
}
TryEmberWick(hit);
TryRiftWedge(hit);
TryExecutioner(hit);
TryBloodFang(playerHealth);
if (loadout.OwnsRelic(PassiveRelicId.ResonanceCore) && loadout.CurrentArtifact != null && now >= nextEcho
&& Rolls(tuning.ResonanceEchoChance))
{
ActiveArtifactDefinition current = loadout.CurrentArtifact;
var use = current != null ? new Defense25DArtifactUse(current, false) : default;
if (effects.EmitEcho?.Invoke(use, hit.Position, hit.Direction, tuning.ResonanceEchoDamageMultiplier) == true)
nextEcho = now + tuning.ResonanceEchoCooldownSeconds;
}
}
public void NotifyArtifactUse(Defense25DArtifactUse use, Vector3 origin, Vector3 direction, float playerHealth)
{
if (!active) return;
TryBattleBaton();
if (!loadout.OwnsRelic(PassiveRelicId.ResonantCrystal) || repeatPending || now < nextRepeat
|| playerHealth <= 0f || !Rolls(tuning.ResonantCrystalChance)) return;
pendingRepeat = use;
repeatOrigin = origin;
repeatDirection = direction;
repeatAt = now + tuning.ResonantCrystalDelaySeconds;
repeatPending = true;
}
public void NotifyPlayerDamageTaken(float actualLoss, float playerHealth, float maximumHealth, Vector3 playerPosition)
{
if (!active || actualLoss <= 0f || playerHealth <= 0f) return;
if (loadout.OwnsRelic(PassiveRelicId.BackflowHeart) && now >= nextHeart)
{
heartDamage += actualLoss;
if (heartDamage >= maximumHealth * tuning.BackflowHeartDamageThreshold)
{
heartDamage = 0f;
if (effects.GrantShield?.Invoke(maximumHealth * tuning.BackflowHeartShieldFraction,
tuning.BackflowHeartShieldDurationSeconds) == true)
nextHeart = now + tuning.BackflowHeartCooldownSeconds;
}
}
if (!loadout.OwnsRelic(PassiveRelicId.CounterBell) || now < nextBell
|| !Rolls(tuning.CounterBellChance)) return;
bool applied = false;
foreach (Defense25DRelicTarget target in Query(playerPosition, BodyRadius(playerPosition)))
{
if (!target.IsDead)
applied |= effects.ApplySlow?.Invoke(target,
tuning.CounterBellSlowDurationSeconds, tuning.CounterBellSlowFraction) == true;
}
if (applied) nextBell = now + tuning.CounterBellCooldownSeconds;
}
public void NotifyEnemyDeath(Defense25DRelicTarget victim, float playerHealth)
{
if (!active || !processedDeaths.Add(victim.Identity) || playerHealth <= 0f) return;
riftHits.Remove(victim.Identity);
TryBondLedger(victim);
recentBumps.Remove(victim.Identity);
if (!PassiveRelicRules.IsPlayerRewardKill(victim.LastDamageSource)) return;
TryHunterFeather();
if (loadout.OwnsRelic(PassiveRelicId.RecoveryFunnel) && now >= nextRecovery
&& Rolls(tuning.RecoveryFunnelChance))
{
if (effects.RestoreGauge?.Invoke(tuning.RecoveryFunnelGaugeFraction) == true)
nextRecovery = now + tuning.RecoveryFunnelCooldownSeconds;
}
TryTrophy(victim);
TryPilgrimPouch(victim);
if (!PassiveRelicRules.IsPlayerCreditedKill(victim.LastDamageSource)) return;
TryEmberCarrier(victim);
TryRupture(victim);
}
public bool NotifyHealingOrbPickedUp(float playerMaximumHealth)
{
if (!active || !healingOrbPending) return false;
healingOrbPending = false;
return effects.HealPlayer?.Invoke(playerMaximumHealth * tuning.PilgrimPouchHealMaxHealthFraction) == true;
}
public void NotifyHealingOrbExpired() => healingOrbPending = false;
private void TryWaveNail(Defense25DRelicHit hit)
{
if (!loadout.OwnsRelic(PassiveRelicId.WaveNail) || now < nextWave || !Rolls(tuning.WaveNailChance)) return;
float damage = hit.BaseBumpDamage * tuning.WaveNailDamageFraction;
bool applied = damage > 0f && DamageArea(hit.Position, BodyRadius(hit.Position), damage,
null, int.MaxValue, EnemyDamageSource.PassiveRelicAttack) > 0;
if (applied) nextWave = now + tuning.WaveNailCooldownSeconds;
}
private void TryCapacitorRing(Defense25DRelicHit hit)
{
if (!loadout.OwnsRelic(PassiveRelicId.CapacitorRing) || now < nextChain || !Rolls(tuning.CapacitorRingChance)) return;
float damage = hit.BaseBumpDamage * tuning.CapacitorRingDamageFraction;
int applied = damage <= 0f ? 0 : DamageArea(hit.Position, BodyRadius(hit.Position), damage,
hit.Target.Identity, tuning.CapacitorRingMaxTargets, EnemyDamageSource.PassiveRelicAttack);
if (applied > 0) nextChain = now + tuning.CapacitorRingCooldownSeconds;
}
private void TryEmberWick(Defense25DRelicHit hit)
{
if (!loadout.OwnsRelic(PassiveRelicId.EmberWick) || !Rolls(tuning.EmberWickChance)) return;
ActiveArtifactDefinition phoenix = loadout.FindArtifact(ActiveArtifactEffect.Phoenix);
if (phoenix == null || hit.Target.IsDead) return;
float tickDamage = effects.CalculatePlayerDamage?.Invoke(
phoenix.NormalIgniteTickDamage, phoenix.DamageTag) ?? phoenix.NormalIgniteTickDamage;
effects.IgniteEnemy?.Invoke(hit.Target, phoenix.NormalIgniteDuration,
phoenix.NormalIgniteInterval, tickDamage, EnemyDamageSource.Player);
}
private void TrySprintWheel(Defense25DRelicHit hit)
{
if (!wheelCharged || hit.BaseBumpDamage <= 0f) return;
wheelCharged = false;
voluntaryDistance = 0f;
DamageArea(hit.Position, BodyRadius(hit.Position),
hit.BaseBumpDamage * tuning.SprintWheelShockwaveDamageFraction, null,
int.MaxValue, EnemyDamageSource.PassiveRelicAttack);
}
private void TryRiftWedge(Defense25DRelicHit hit)
{
if (!loadout.OwnsRelic(PassiveRelicId.RiftWedge) || !hit.OrdinaryBump || hit.ArtifactHit
|| hit.Target.IsDead) return;
riftHits.TryGetValue(hit.Target.Identity, out var progress);
int count = now - progress.at > tuning.RiftWedgeMaxHitGapSeconds ? 1 : progress.hits + 1;
if (count < tuning.RiftWedgeRequiredHits)
{
riftHits[hit.Target.Identity] = (count, now);
return;
}
riftHits.Remove(hit.Target.Identity);
ActiveArtifactDefinition thunder = loadout.FindArtifact(ActiveArtifactEffect.ThunderCrash);
if (thunder != null)
effects.ApplyVulnerability?.Invoke(hit.Target, thunder.NormalVulnerabilityDuration,
thunder.NormalVulnerabilityIncrease);
}
private void TryExecutioner(Defense25DRelicHit hit)
{
if (!loadout.OwnsRelic(PassiveRelicId.ExecutionerSigil) || !hit.OrdinaryBump || hit.ArtifactHit
|| !Rolls(tuning.ExecutionerSigilChance) || hit.Target.IsDead || hit.Target.IsMidBoss
|| hit.Target.IsFinalBoss || hit.Target.Health > hit.Target.MaximumHealth * tuning.ExecutionerSigilHealthThreshold) return;
effects.DamageEnemy?.Invoke(hit.Target, hit.Target.MaximumHealth * 100f,
hit.Direction, EnemyDamageSource.PassiveRelicAttack);
}
private void TryBloodFang(float playerHealth)
{
if (!loadout.OwnsRelic(PassiveRelicId.BloodFang) || now < nextBlood
|| playerHealth >= MaximumPlayerHealth || !Rolls(tuning.BloodFangChance)) return;
if (effects.HealPlayer?.Invoke(MaximumPlayerHealth * tuning.BloodFangHealMaxHealthFraction) == true)
nextBlood = now + tuning.BloodFangCooldownSeconds;
}
private float MaximumPlayerHealth { get; set; }
public void SetMaximumPlayerHealth(float value) => MaximumPlayerHealth = Mathf.Max(0f, value);
private void TryRupture(Defense25DRelicTarget victim)
{
if (!loadout.OwnsRelic(PassiveRelicId.RuptureSeed) || !Rolls(tuning.RuptureSeedChance)) return;
float damage = PassiveRelicRules.GetExplosionDamage(victim.MaximumHealth, tuning.RuptureExplosionMaxHealthFraction);
if (damage <= 0f) return;
DamageArea(victim.Position, BodyRadius(victim.Position, tuning.RuptureExplosionPlayerBodyDiameters),
damage, victim.Identity, int.MaxValue, EnemyDamageSource.PassiveRelicExplosion);
}
private void TryHunterFeather()
{
if (!loadout.OwnsRelic(PassiveRelicId.HunterFeather) || !Rolls(tuning.HunterFeatherChance)) return;
effects.SetTimedPlayerModifier?.Invoke(new StatModifier(
HunterBuffSourceId, CharacterStat.MoveSpeed, ModifierOperation.Increased,
tuning.HunterFeatherMoveSpeedIncrease), tuning.HunterFeatherDurationSeconds);
hunterUntil = now + tuning.HunterFeatherDurationSeconds;
}
private void TryTrophy(Defense25DRelicTarget victim)
{
if (!loadout.OwnsRelic(PassiveRelicId.TrophyMask) || !victim.IsElite) return;
effects.SetTimedPlayerModifier?.Invoke(new StatModifier(
TrophyBuffSourceId, CharacterStat.MoveSpeed, ModifierOperation.Increased,
tuning.TrophyMaskMoveSpeedIncrease), tuning.TrophyMaskDurationSeconds);
trophyUntil = now + tuning.TrophyMaskDurationSeconds;
}
private void TryBattleBaton()
{
if (loadout.OwnsRelic(PassiveRelicId.BattleBaton) && now >= nextBaton
&& effects.BoostVisibleTowers?.Invoke(tuning.BattleBatonDurationSeconds) == true)
nextBaton = now + tuning.BattleBatonCooldownSeconds;
}
private void TryBondLedger(Defense25DRelicTarget victim)
{
if (!loadout.OwnsRelic(PassiveRelicId.BondLedger) || victim.LastDamageSource != EnemyDamageSource.Tower
|| now < nextLedger || !recentBumps.TryGetValue(victim.Identity, out float bumpedAt)
|| now - bumpedAt > tuning.BondLedgerBumpWindowSeconds || !Rolls(tuning.BondLedgerChance)) return;
if (effects.AwardCurrency?.Invoke(tuning.BondLedgerCurrency) == true)
nextLedger = now + tuning.BondLedgerCooldownSeconds;
}
private void TryPilgrimPouch(Defense25DRelicTarget victim)
{
if (!loadout.OwnsRelic(PassiveRelicId.PilgrimPouch) || healingOrbPending || now < nextPouch
|| !Rolls(tuning.PilgrimPouchChance)) return;
if (effects.SpawnHealingOrb == null) return;
effects.SpawnHealingOrb(victim.Position, tuning.PilgrimPouchHealMaxHealthFraction,
tuning.PilgrimPouchOrbLifetimeSeconds);
healingOrbPending = true;
healingOrbExpiresAt = now + tuning.PilgrimPouchOrbLifetimeSeconds;
nextPouch = now + tuning.PilgrimPouchCooldownSeconds;
}
private void TryEmberCarrier(Defense25DRelicTarget victim)
{
if (!loadout.OwnsRelic(PassiveRelicId.EmberCarrier) || victim.IgniteRemaining <= 0f
|| victim.IgniteInterval <= 0f || victim.IgniteTickDamage <= 0f || now < nextCarrier
|| !Rolls(tuning.EmberCarrierChance)) return;
int count = 0;
foreach (Defense25DRelicTarget target in Query(victim.Position, BodyRadius(victim.Position))
.Where(enemy => enemy.Identity != victim.Identity && !enemy.IsDead)
.OrderBy(enemy => GroundDistanceSquared(enemy.Position, victim.Position)))
{
if (count >= tuning.EmberCarrierMaxTargets) break;
if (effects.IgniteEnemy?.Invoke(target, victim.IgniteRemaining, victim.IgniteInterval,
victim.IgniteTickDamage, EnemyDamageSource.PassiveRelicTransferredIgnite) == true)
count++;
}
if (count > 0) nextCarrier = now + tuning.EmberCarrierCooldownSeconds;
}
private int DamageArea(Vector3 origin, float radius, float damage, int? excludedIdentity,
int maxTargets, EnemyDamageSource source)
{
if (damage <= 0f || radius <= 0f || effects.DamageEnemy == null) return 0;
int count = 0;
foreach (Defense25DRelicTarget target in Query(origin, radius)
.Where(enemy => !enemy.IsDead && (!excludedIdentity.HasValue
|| enemy.Identity != excludedIdentity.Value))
.OrderBy(enemy => GroundDistanceSquared(enemy.Position, origin)))
{
if (count >= maxTargets) break;
Vector3 direction = target.Position - origin;
direction.y = 0f;
if (effects.DamageEnemy(target, damage, direction.sqrMagnitude > 0f ? direction.normalized : Vector3.zero, source))
count++;
}
return count;
}
private IReadOnlyList<Defense25DRelicTarget> Query(Vector3 center, float radius) =>
effects.QueryEnemies?.Invoke(center, radius) ?? Array.Empty<Defense25DRelicTarget>();
private float BodyRadius(Vector3 center, float multiplier = 3f) =>
PassiveRelicRules.GetExplosionRadius(BodyDiameterProvider?.Invoke() ?? 0f, multiplier);
public Func<float> BodyDiameterProvider { get; set; }
private static float GroundDistanceSquared(Vector3 a, Vector3 b)
{
float dx = a.x - b.x, dz = a.z - b.z;
return dx * dx + dz * dz;
}
private bool Rolls(float chance) => PassiveRelicRules.RollsChance(chance, Roll());
private float Roll() => randomRoll();
private void ClearTemporaryModifiers()
{
effects.RemovePlayerModifier?.Invoke(HunterBuffSourceId);
effects.RemovePlayerModifier?.Invoke(TrophyBuffSourceId);
hunterUntil = trophyUntil = 0f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8003ed4e9dc54893b405abfe3dd2378a
@@ -0,0 +1,329 @@
using BumpCombat.Enemies;
using BumpCombat.Core;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DEnemy
{
private EnemyDefinition skeletonDefinition;
private float skeletonRootScale = 1f;
private AnimationClip skeletonAttackClip;
private LineRenderer skeletonSlash;
private bool skeletonAttackAnimationStarted;
private float SkeletonGeometryScale => pattern.GeometryScaleMode
== EnemyAttackGeometryScaleMode.RootScale ? skeletonRootScale : 1f;
public void ConfigureSkeletonCombat(
EnemyDefinition definition,
float sourceRootScale,
AnimationClip sourceAttackClip)
{
if (definition == null || definition.Kind != EnemyKind.Skeleton)
{
skeletonDefinition = null;
skeletonAttackClip = null;
SetSkeletonSlashVisible(false);
return;
}
skeletonDefinition = definition;
pattern = definition.GetAttackPattern(0);
skeletonRootScale = Mathf.Max(.0001f, Mathf.Abs(sourceRootScale));
skeletonAttackClip = sourceAttackClip;
EnsureSkeletonSlash();
skeletonSlash.gameObject.SetActive(true);
SetSkeletonSlashVisible(false);
}
private void OnEnable() => SetSkeletonSlashVisible(false);
public bool StepSkeletonCombat(float dt)
{
if (skeletonDefinition == null || !IsAvailable) return false;
if (!world.IsRunning || world.Paused) return true;
if (IsStunned)
{
Actor.StopBody();
Actor.Visual.SetWarning(false);
SetSkeletonSlashVisible(false);
return true;
}
if (Actor.HasKnockback)
{
Actor.Move(Actor.ConsumeKnockback(dt));
if (IsAttacking) UpdateSkeletonSlash(world.GameTime - attackStart);
return true;
}
if (IsAttacking)
{
StepSkeletonAttack();
return true;
}
if (world.GameTime < progressUntil)
{
Actor.Visual.SetWarning(false);
SetSkeletonSlashVisible(false);
return false;
}
target = FindSkeletonTarget();
if (target == null)
{
Actor.Visual.SetWarning(false);
SetSkeletonSlashVisible(false);
return false;
}
Vector2 targetPosition = target.Center;
Vector2 toTarget = targetPosition - Actor.Position;
float range = pattern.AttackRange * SkeletonGeometryScale;
if (toTarget.magnitude > range)
{
target = null;
return false;
}
attackDirection = EnemyAttack.ResolveAttackDirection(
pattern.DirectionMode,
toTarget,
Actor.Facing);
if (!IsSkeletonSlashAligned(targetPosition))
{
MoveToSkeletonSlashSlot(targetPosition, range, dt);
target = null;
return true;
}
Actor.Facing = attackDirection;
attackStart = world.GameTime;
dealtDamage = false;
skeletonAttackAnimationStarted = false;
Actor.Visual.SetWarning(true);
UpdateSkeletonSlash(0f);
return true;
}
public static bool SkeletonSlashIntersectsCircle(
Vector2 attackOrigin,
Vector2 lockedDirection,
float attackLength,
float attackWidth,
Vector2 targetCenter,
float targetRadius)
{
if (attackLength <= 0f || attackWidth <= 0f || targetRadius < 0f) return false;
Vector2 direction = lockedDirection.sqrMagnitude > .0001f
? lockedDirection.normalized
: Vector2.right;
Vector2 lateral = new(-direction.y, direction.x);
Vector2 delta = targetCenter - attackOrigin;
float forward = Vector2.Dot(delta, direction);
if (forward < 0f) return false;
float side = Vector2.Dot(delta, lateral);
float nearestForward = Mathf.Clamp(forward, 0f, attackLength);
float nearestSide = Mathf.Clamp(side, -attackWidth * .5f, attackWidth * .5f);
float forwardGap = forward - nearestForward;
float sideGap = side - nearestSide;
return forwardGap * forwardGap + sideGap * sideGap
<= targetRadius * targetRadius;
}
public static bool SkeletonAttackAnimationShouldStart(
EnemyAttackPattern attackPattern,
float elapsed)
{
float warningRemaining = attackPattern.WarningDuration - elapsed;
return EnemyController.ShouldStartAttackAnimation(
warningRemaining,
attackPattern.AnimationLeadTime,
false);
}
public static int SkeletonAttackFrameAt(AnimationClip clip, float elapsed, int frameCount)
{
if (clip == null || frameCount <= 0) return 0;
float clipTime = Mathf.Clamp(elapsed, 0f, clip.length);
return Mathf.Clamp(Mathf.FloorToInt(clipTime * clip.frameRate), 0, frameCount - 1);
}
private Defense25DActor FindSkeletonTarget()
{
Defense25DActor best = null;
float bestDistance = pattern.AttackRange * SkeletonGeometryScale;
void Consider(Defense25DActor actor)
{
if (actor == null || !actor.IsAlive) return;
float distance = Vector2.Distance(Actor.Position, actor.Center);
if (distance > bestDistance) return;
best = actor;
bestDistance = distance;
}
Consider(world.Player);
if (world.TowersOperational)
foreach (Defense25DSummon summon in world.Summons)
Consider(summon.Actor);
return best;
}
private bool IsSkeletonSlashAligned(Vector2 targetCenter)
{
Vector2 origin = GetSkeletonSlashOrigin();
Vector2 lateral = new(-attackDirection.y, attackDirection.x);
float targetSide = Vector2.Dot(targetCenter - origin, lateral);
float halfWidth = pattern.AttackWidth * SkeletonGeometryScale * .5f;
return Mathf.Abs(targetSide) <= halfWidth + target.Radius;
}
private void MoveToSkeletonSlashSlot(Vector2 targetCenter, float range, float dt)
{
Vector2 boundedTarget = targetCenter;
float edge = Mathf.Max(0f, world.ArenaHalfExtents.y - Actor.Radius);
boundedTarget.y = Mathf.Clamp(targetCenter.y, -edge, edge);
float verticalDelta = targetCenter.y - boundedTarget.y;
float horizontalDistance = Mathf.Sqrt(Mathf.Max(
0f,
range * range - verticalDelta * verticalDelta));
horizontalDistance = Mathf.Max(0f, horizontalDistance - .02f);
Vector2 slot = new(
targetCenter.x - attackDirection.x * horizontalDistance,
boundedTarget.y);
Vector2 delta = slot - Actor.Position;
if (delta.sqrMagnitude <= .0001f) return;
Actor.Facing = delta.normalized;
float slow = world.GameTime < slowUntil ? slowFraction : 0f;
float speed = Actor.Evaluate(CharacterStat.MoveSpeed, skeletonDefinition.MoveSpeed);
Actor.Move(delta.normalized * Mathf.Min(delta.magnitude, speed * (1f - slow) * dt));
}
private void StepSkeletonAttack()
{
if (target == null || !target.IsAlive
|| (target.IsMinion && !world.TowersOperational))
{
EndSkeletonAttack();
return;
}
float elapsed = world.GameTime - attackStart;
float animationLead = Mathf.Max(0f, pattern.WarningDuration - pattern.AnimationLeadTime);
if (!skeletonAttackAnimationStarted && SkeletonAttackAnimationShouldStart(pattern, elapsed))
{
skeletonAttackAnimationStarted = true;
Actor.Visual.SetAttack(true);
}
bool warning = elapsed < pattern.WarningDuration;
Actor.Visual.SetWarning(warning && !skeletonAttackAnimationStarted);
if (skeletonAttackAnimationStarted)
{
float attackElapsed = elapsed - animationLead;
Actor.Visual.SetAttackFrame(SkeletonAttackFrameAt(
skeletonAttackClip,
attackElapsed,
world.Settings.EnemyArt.Attack.Length));
}
UpdateSkeletonSlash(elapsed);
float activeElapsed = elapsed - pattern.WarningDuration;
if (!dealtDamage
&& pattern.IsContactWindowActive(activeElapsed, pattern.ActiveDuration)
&& SkeletonSlashIntersectsCircle(
GetSkeletonSlashOrigin(),
attackDirection,
pattern.AttackLength * SkeletonGeometryScale,
pattern.AttackWidth * SkeletonGeometryScale,
target.Center,
target.Radius))
{
Vector2 knockbackDirection = target.Center - Actor.Position;
if (knockbackDirection.sqrMagnitude <= .0001f)
knockbackDirection = attackDirection;
float knockback = target.IsMinion
? 0f
: BumpCombat.Constants.GameplayConstants.Current.Enemies.NormalPlayerKnockbackDistance;
dealtDamage = target.TakeDamage(
Actor.CalculateAttackDamage(skeletonDefinition.AttackDamage * pattern.DamageMultiplier),
knockbackDirection.normalized,
knockback);
}
if (elapsed >= pattern.WarningDuration + pattern.ActiveDuration + pattern.RecoveryDuration)
EndSkeletonAttack();
}
private Vector2 GetSkeletonSlashOrigin()
{
return EnemyAttack.GetAttackOrigin(
Actor.Position,
attackDirection,
pattern.AttackOriginOffset * SkeletonGeometryScale,
pattern.DirectionMode);
}
private void UpdateSkeletonSlash(float elapsed)
{
if (skeletonSlash == null) return;
bool visible = elapsed < pattern.WarningDuration + pattern.ActiveDuration;
SetSkeletonSlashVisible(visible);
if (!visible) return;
Vector2 origin = GetSkeletonSlashOrigin();
Vector2 direction = attackDirection.sqrMagnitude > .0001f
? attackDirection.normalized
: Vector2.right;
Vector2 side = pattern.DirectionMode == EnemyAttackDirectionMode.HorizontalRoot
? Vector2.up
: new Vector2(-direction.y, direction.x);
float length = pattern.AttackLength * SkeletonGeometryScale;
float halfWidth = pattern.AttackWidth * SkeletonGeometryScale * .5f;
Vector2 end = origin + direction * length;
float y = world.Settings.GroundY + .025f;
skeletonSlash.SetPosition(0, Defense25DActor.OnGround(origin - side * halfWidth, y));
skeletonSlash.SetPosition(1, Defense25DActor.OnGround(end - side * halfWidth, y));
skeletonSlash.SetPosition(2, Defense25DActor.OnGround(end + side * halfWidth, y));
skeletonSlash.SetPosition(3, Defense25DActor.OnGround(origin + side * halfWidth, y));
skeletonSlash.SetPosition(4, Defense25DActor.OnGround(origin - side * halfWidth, y));
Color color = elapsed < pattern.WarningDuration
? new Color(1f, .7f, .15f, .95f)
: new Color(1f, .2f, .12f, 1f);
skeletonSlash.startColor = skeletonSlash.endColor = color;
}
private void EnsureSkeletonSlash()
{
if (skeletonSlash != null) return;
GameObject go = new("Skeleton slash warning");
go.transform.SetParent(transform, false);
skeletonSlash = Defense25DSprite.CreateLine(
go.name,
world.Settings.HitBoundaryMaterial != null
? world.Settings.HitBoundaryMaterial
: world.Settings.BoundaryMaterial,
go.transform,
.025f);
skeletonSlash.loop = false;
skeletonSlash.positionCount = 5;
skeletonSlash.enabled = false;
}
private void SetSkeletonSlashVisible(bool visible)
{
if (skeletonSlash != null) skeletonSlash.enabled = visible;
}
private void EndSkeletonAttack()
{
attackStart = -1f;
target = null;
skeletonAttackAnimationStarted = false;
Actor.Visual.SetWarning(false);
Actor.Visual.SetAttack(false);
SetSkeletonSlashVisible(false);
progressUntil = world.GameTime;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41e5d84f32414319800222052a38f643
@@ -0,0 +1,473 @@
using BumpCombat.Constants;
using BumpCombat.Combat;
using BumpCombat.Defense;
using BumpCombat.Player;
using System;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
// Rendering only: this child never owns a body, a hurtbox or an authoritative position.
[DefaultExecutionOrder(-100)]
public sealed class Defense25DSprite : MonoBehaviour
{
private Defense25DComparison world;
private Defense25DActor actor;
private SpriteRenderer sprite;
private LineRenderer footprint;
private ComparisonSpriteFrames frames;
private Sprite[] transientFrames;
private float[] transientDurations;
private float transientStart, transientDuration;
private string summonIdleKey = "Summons/Slime/Idle";
private string summonWalkKey = "Summons/Slime/Walk";
private float scale = 1f, footOffset, hurtUntil, attackUntil, attackStart;
private bool attacking, warning, tower;
private bool hideAfterDeath;
private string activePlayerMotion;
private Color feedbackTint = Color.white;
private int forcedFrame;
private Vector3 previousPosition;
public SpriteRenderer Renderer => sprite;
public float VisualScale => scale;
public float FootVisualOffset => footOffset;
public float VisualHeight { get; set; }
public Vector3 FootPosition => Defense25DActor.OnGround(
Defense25DActor.Ground(transform.position) + (actor != null ? actor.Offset : Vector2.zero),
world.Settings.GroundY);
public void Configure(Defense25DComparison owner, Defense25DActor body)
{
world = owner; actor = body;
scale = body.Kind == ComparisonActorKind.Player ? world.Settings.PlayerVisualScale
: body.Kind == ComparisonActorKind.Enemy ? world.Settings.EnemyVisualScale : world.Settings.SummonVisualScale;
footOffset = body.FootOffset;
frames = body.Kind == ComparisonActorKind.Player ? world.Settings.PlayerArt
: body.Kind == ComparisonActorKind.Enemy ? world.Settings.EnemyArt
: new ComparisonSpriteFrames
{
Idle = DefenseTowerArt.Load("Summons/Slime/Idle"),
Walk = DefenseTowerArt.Load("Summons/Slime/Walk"),
Attack = DefenseTowerArt.Load("Summons/Slime/Attack01"),
Hurt = DefenseTowerArt.Load("Summons/Slime/Hurt"),
Death = DefenseTowerArt.Load("Summons/Slime/Death"),
};
CreateVisual();
}
public void ConfigureTower(Defense25DComparison owner)
{
world = owner; tower = true; footOffset = -9f / 32f;
frames = new ComparisonSpriteFrames { Idle = DefenseTowerArt.Load(DefenseTowerArt.BodyKey(DefenseTowerKind.Summon, 1, "Idle")) };
CreateVisual();
}
public void ConfigureSummonArt(int tier, DefenseTowerBranch branch)
{
string family = tier == 4 && branch == DefenseTowerBranch.First
? "GiantSlime" : tier == 4 && branch == DefenseTowerBranch.Second
? "LavaSlime" : "Slime";
summonIdleKey = $"Summons/{family}/Idle";
summonWalkKey = $"Summons/{family}/Walk";
frames = new ComparisonSpriteFrames
{
Idle = DefenseTowerArt.Load(summonIdleKey),
Walk = DefenseTowerArt.Load(summonWalkKey),
Attack = DefenseTowerArt.Load($"Summons/{family}/Attack01"),
Hurt = DefenseTowerArt.Load($"Summons/{family}/Hurt"),
Death = DefenseTowerArt.Load($"Summons/{family}/Death"),
};
float baseScale = world.Settings.SummonVisualScale;
scale = family == "GiantSlime" ? 1.55f
: family == "LavaSlime" ? 1.15f : baseScale;
footOffset = family == "GiantSlime" ? 0f
: world.Settings.SummonFootOffset * (scale / baseScale);
float coreDiameterPixels = family == "GiantSlime"
? Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels
: family == "LavaSlime" ? Defense25DComparisonSettings.LavaSlimeBodyCoreDiameterPixels
: Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels;
Vector2 bodyCenterFromFootPixels = family == "GiantSlime"
? Defense25DComparisonSettings.GiantSlimeBodyCenterFromFootPixels
: family == "LavaSlime" ? Defense25DComparisonSettings.LavaSlimeBodyCenterFromFootPixels
: Defense25DComparisonSettings.SlimeBodyCenterFromFootPixels;
actor.SetSummonVariantScale(scale, coreDiameterPixels, bodyCenterFromFootPixels);
if (sprite != null && frames.Idle.Length > 0) sprite.sprite = frames.Idle[0];
}
private void CreateVisual()
{
GameObject image = new("Billboard - visual only");
image.transform.SetParent(transform, false);
sprite = image.AddComponent<SpriteRenderer>();
sprite.sharedMaterial = world.Settings.CutoutMaterial;
sprite.sprite = frames.Idle.Length > 0 ? frames.Idle[0] : null;
footprint = CreateLine("Body capsule bounds", world.Settings.BoundaryMaterial, transform, .03f);
ResetVisual();
}
public static LineRenderer CreateLine(string label, Material material, Transform parent, float width)
{
GameObject go = new(label);
go.transform.SetParent(parent, false);
LineRenderer line = go.AddComponent<LineRenderer>();
line.sharedMaterial = material;
line.useWorldSpace = true;
line.loop = true;
line.widthMultiplier = width;
line.alignment = LineAlignment.View;
line.textureMode = LineTextureMode.Stretch;
line.positionCount = 65;
return line;
}
public static void SetEllipse(LineRenderer line, Vector2 center, Vector2 radii, float y)
{
for (int i = 0; i < line.positionCount; i++)
{
float angle = i * Mathf.PI * 2f / (line.positionCount - 1);
line.SetPosition(i, Defense25DActor.OnGround(center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y), y));
}
}
public static float Duration(string key)
{
int count = DefenseTowerArt.Load(key).Length;
float result = 0f;
for (int i = 0; i < count; i++) result += DefenseTowerArt.FrameSeconds(key, i);
return Mathf.Max(.01f, result);
}
public static int FrameAt(string key, float elapsed, bool loop)
{
int count = DefenseTowerArt.Load(key).Length;
if (count <= 0) return 0;
if (loop) elapsed %= Duration(key);
for (int i = 0; i < count; i++)
{
float duration = DefenseTowerArt.FrameSeconds(key, i);
if (elapsed < duration) return i;
elapsed -= duration;
}
return count - 1;
}
public static float PixelAlignedScale(float authoredScale, int targetPixelHeight,
float orthographicSize, float pixelsPerUnit)
{
if (authoredScale <= 0f || targetPixelHeight <= 0
|| orthographicSize <= 0f || pixelsPerUnit <= 0f)
return authoredScale;
float authoredPixelPitch = targetPixelHeight / (2f * orthographicSize)
* authoredScale / pixelsPerUnit;
int alignedPitch = Mathf.Max(1, Mathf.RoundToInt(authoredPixelPitch));
return alignedPitch * (2f * orthographicSize) * pixelsPerUnit / targetPixelHeight;
}
public static Vector3 PixelGridOffset(Camera camera, Sprite sprite,
bool flippedHorizontally,
Vector3 spritePivotWorldPosition, Vector3 visualScale)
{
if (camera == null || sprite == null || !camera.orthographic
|| camera.orthographicSize <= 0f || sprite.pixelsPerUnit <= 0f)
return Vector3.zero;
int pixelHeight = camera.targetTexture != null
? camera.targetTexture.height : camera.pixelHeight;
if (pixelHeight <= 0) return Vector3.zero;
float unitsPerPixel = 2f * camera.orthographicSize / pixelHeight;
float texelPitch = visualScale.y / sprite.pixelsPerUnit / unitsPerPixel;
Vector3 screen = camera.WorldToScreenPoint(spritePivotWorldPosition);
float pivotX = flippedHorizontally ? sprite.rect.width - sprite.pivot.x : sprite.pivot.x;
float left = screen.x - pivotX * texelPitch;
float bottom = screen.y - sprite.pivot.y * texelPitch;
float deltaX = Mathf.Round(left) - left;
float deltaY = Mathf.Round(bottom) - bottom;
return camera.transform.right * (deltaX * unitsPerPixel)
+ camera.transform.up * (deltaY * unitsPerPixel);
}
public void ResetVisual()
{
hurtUntil = attackUntil = 0f;
transientFrames = null;
transientDurations = null;
transientDuration = 0f;
hideAfterDeath = false;
activePlayerMotion = null;
ClearFeedbackTint();
attacking = warning = false;
VisualHeight = 0f;
previousPosition = transform.position;
if (sprite != null) sprite.enabled = true;
}
public void Hide()
{
if (sprite != null) sprite.enabled = false;
if (footprint != null) footprint.enabled = false;
}
public void RestoreVisibility()
{
if (sprite != null) sprite.enabled = true;
if (footprint != null) footprint.enabled = true;
}
public void ShowHurt()
{
if (actor != null && actor.Kind != ComparisonActorKind.Player)
{
StartTimedSequence(frames.Hurt, Defense25DPlayerAnimationConstants.HurtFrameSeconds, "Hurt");
return;
}
PlayPlayerSequence("Hurt", Defense25DPlayerAnimationConstants.HurtFrameSeconds);
}
public void ShowAttack(bool backHit = false)
{
if (activePlayerMotion is "Hurt" or "Dash" or "Death" or "ArtifactUse-v1") return;
if (actor == null || actor.Kind != ComparisonActorKind.Player)
{
attackUntil = world.GameTime + .24f;
return;
}
PlayPlayerSequence(backHit ? "BackBumpAttack-v2" : "BumpAttack-v1",
Defense25DPlayerAnimationConstants.BumpFrameSeconds);
}
public void ShowArtifactUse()
{
if (activePlayerMotion is "Hurt" or "Dash" or "Death") return;
PlayPlayerSequence("ArtifactUse-v1", Defense25DPlayerAnimationConstants.ArtifactUseFrameSeconds);
}
public void ShowDash()
{
if (activePlayerMotion is "Hurt" or "Death") return;
PlayPlayerSequence("Dash", Defense25DPlayerAnimationConstants.DashFrameSeconds);
}
public void ShowDeath()
{
if (actor != null && actor.Kind != ComparisonActorKind.Player)
{
float duration = actor.Kind == ComparisonActorKind.Enemy
? GameplayConstants.Current.Enemies.DefaultDeathDuration
: Defense25DPlayerAnimationConstants.NonPlayerDeathFallbackDuration;
StartTimedSequence(frames.Death,
duration / Mathf.Max(1, frames.Death.Length), "Death");
}
else BeginPlayerSequence("Death", Defense25DPlayerAnimationConstants.DeathFrameSeconds, frames.Death);
hideAfterDeath = true;
activePlayerMotion = "Death";
}
private void PlayPlayerSequence(string motion, float frameSeconds)
{
ArtifactColor color = world.Loadout?.CurrentArtifact?.ArtifactColor ?? ArtifactColor.Green;
string path = $"Artifacts/ThreeColor-v1/Player/Swordsman_{motion}-{color}-v1";
Sprite[] fallback = motion switch
{
"BumpAttack-v1" => frames.Attack,
"Hurt" => frames.Hurt,
"Death" => frames.Death,
_ => Array.Empty<Sprite>(),
};
BeginPlayerSequence(motion, frameSeconds, fallback);
}
private void BeginPlayerSequence(string motion, float frameSeconds, Sprite[] fallback)
{
ArtifactColor color = world.Loadout?.CurrentArtifact?.ArtifactColor ?? ArtifactColor.Green;
string path = $"Artifacts/ThreeColor-v1/Player/Swordsman_{motion}-{color}-v1";
Sprite[] sequence = Resources.LoadAll<Sprite>(path);
Array.Sort(sequence, (a, b) => FrameNumber(a.name).CompareTo(FrameNumber(b.name)));
if (sequence.Length == 0) sequence = fallback;
if (sequence.Length == 0) return;
transientFrames = sequence;
transientDurations = UniformDurations(sequence.Length, frameSeconds);
transientStart = world.GameTime;
transientDuration = Sum(transientDurations);
attackUntil = transientStart + transientDuration;
activePlayerMotion = motion;
if (motion == "Hurt") hurtUntil = attackUntil;
}
private void StartTimedSequence(Sprite[] framesToPlay, float frameSeconds, string motion)
{
if (framesToPlay == null || framesToPlay.Length == 0) return;
transientFrames = framesToPlay;
transientDurations = UniformDurations(transientFrames.Length, frameSeconds);
transientStart = world.GameTime;
transientDuration = Sum(transientDurations);
attackUntil = transientStart + transientDuration;
activePlayerMotion = motion;
if (motion == "Hurt") hurtUntil = attackUntil;
}
private static float[] UniformDurations(int count, float seconds)
{
float[] result = new float[count];
Array.Fill(result, seconds);
return result;
}
private static float Sum(float[] values)
{
float result = 0f;
foreach (float value in values) result += value;
return result;
}
private static int FrameNumber(string name)
{
int separator = name.LastIndexOf('_');
return separator >= 0 && int.TryParse(name.Substring(separator + 1), out int index) ? index : 0;
}
public void SetFeedbackTint(Color tint) => feedbackTint = tint;
public void ClearFeedbackTint() => feedbackTint = Color.white;
public void SetAttack(bool value) { if (value && !attacking) attackStart = world.GameTime; attacking = value; forcedFrame = -1; warning = false; }
public void SetAttackFrame(int value) { forcedFrame = value; }
public void SetWarning(bool value) => warning = value;
private void LateUpdate()
{
if (world != null && world.isActiveAndEnabled && world.CombatFeedback != null)
{
if (!world.CombatFeedback.enabled) world.CombatFeedback.enabled = true;
else if (world.CombatFeedback.TransparentEffectMaterial == null)
world.CombatFeedback.Bind(world);
}
if (sprite == null || !sprite.enabled) return;
Quaternion facingCamera = world.View != null ? world.View.transform.rotation : Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
sprite.transform.rotation = facingCamera;
float visualScale = scale;
if (actor != null && actor.Kind == ComparisonActorKind.Player && sprite.sprite != null
&& world.View != null)
{
int pixelHeight = world.View.Camera.targetTexture != null
? world.View.Camera.targetTexture.height : world.View.Camera.pixelHeight;
visualScale = PixelAlignedScale(scale, pixelHeight,
world.View.Camera.orthographicSize, sprite.sprite.pixelsPerUnit);
}
sprite.transform.localScale = Vector3.one * visualScale;
float authoredScale = scale > 0f ? visualScale / scale : 1f;
sprite.transform.position = FootPosition + Vector3.up * VisualHeight
- facingCamera * Vector3.up * (footOffset * authoredScale);
bool moving = (transform.position - previousPosition).sqrMagnitude > .0000001f;
previousPosition = transform.position;
if (transientFrames != null && world.GameTime - transientStart >= transientDuration)
{
transientFrames = null;
transientDurations = null;
activePlayerMotion = null;
if (hideAfterDeath) { Hide(); return; }
}
Sprite[] sequence = transientFrames != null ? transientFrames
: world.GameTime < hurtUntil && frames.Hurt.Length > 0 ? frames.Hurt
: (attacking || world.GameTime < attackUntil) && frames.Attack.Length > 0 ? frames.Attack
: moving && frames.Walk.Length > 0 ? frames.Walk : frames.Idle;
int frame = transientFrames != null ? TimedFrame(transientDurations, world.GameTime - transientStart)
: forcedFrame >= 0 && attacking ? forcedFrame
: Mathf.FloorToInt((attacking ? world.GameTime - attackStart : world.GameTime)
/ (sequence == frames.Idle
? Defense25DPlayerAnimationConstants.IdleFrameSeconds
: Defense25DPlayerAnimationConstants.WalkFrameSeconds));
if (actor != null && actor.IsMinion && !attacking)
frame = FrameAt(moving ? summonWalkKey : summonIdleKey, world.GameTime, true);
if (sequence.Length > 0) sprite.sprite = sequence[Mathf.Max(0, frame) % sequence.Length];
if (actor != null) sprite.flipX = actor.Facing.x < 0f;
if (actor != null && actor.Kind == ComparisonActorKind.Player && world.View != null)
sprite.transform.position += PixelGridOffset(world.View.Camera, sprite.sprite, sprite.flipX,
sprite.transform.position, sprite.transform.lossyScale);
Color baseTint = warning ? new Color(1f, .55f, .4f)
: tower && !world.TowersOperational ? new Color(.45f, .45f, .45f) : Color.white;
sprite.color = new Color(baseTint.r * feedbackTint.r, baseTint.g * feedbackTint.g,
baseTint.b * feedbackTint.b, baseTint.a * feedbackTint.a);
Material transparent = world.CombatFeedback != null
? world.CombatFeedback.TransparentEffectMaterial : world.Settings.TransparentMaterial;
sprite.sharedMaterial = actor != null && actor.Kind == ComparisonActorKind.Player
&& sprite.color.a < .999f && transparent != null
? transparent : world.Settings.CutoutMaterial;
footprint.enabled = world.ShowBoundaries;
if (actor != null)
{
if (world.View != null)
SetCapsuleProjection(footprint, actor.BodyCollider, world.View.Camera, .015f);
else footprint.enabled = false;
}
else
{
SetEllipse(footprint, world.Settings.TowerPosition,
Vector2.one * GameplayConstants.Current.Defense.SummonAreaRadius,
world.Settings.GroundY + .02f);
}
}
public static void SetCapsuleProjection(LineRenderer line, CapsuleCollider capsule,
Camera camera, float cameraOffset)
{
Vector3 axisDirection = capsule.direction switch
{
0 => capsule.transform.right,
2 => capsule.transform.forward,
_ => capsule.transform.up,
};
Vector3 scale = capsule.transform.lossyScale;
float axialScale = capsule.direction switch
{
0 => Mathf.Abs(scale.x),
2 => Mathf.Abs(scale.z),
_ => Mathf.Abs(scale.y),
};
float radialScale = capsule.direction switch
{
0 => Mathf.Max(Mathf.Abs(scale.y), Mathf.Abs(scale.z)),
2 => Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.y)),
_ => Mathf.Max(Mathf.Abs(scale.x), Mathf.Abs(scale.z)),
};
float radius = capsule.radius * radialScale;
float height = capsule.height * axialScale;
height = Mathf.Max(height, 2f * radius);
float halfSegment = Mathf.Max(0f, height * .5f - radius);
Vector3 segment = axisDirection.normalized * halfSegment;
Vector2 projectedAxis = new(Vector3.Dot(segment, camera.transform.right),
Vector3.Dot(segment, camera.transform.up));
Vector2 direction = projectedAxis.sqrMagnitude > .000001f
? projectedAxis.normalized : Vector2.up;
Vector2 perpendicular = new(-direction.y, direction.x);
Vector3 center = capsule.transform.TransformPoint(capsule.center)
- camera.transform.forward * cameraOffset;
line.positionCount = 66;
line.loop = true;
line.widthMultiplier = .03f;
for (int i = 0; i <= 32; i++)
{
float angle = -Mathf.PI * .5f + Mathf.PI * i / 32f;
Vector2 point = projectedAxis + radius
* (direction * Mathf.Cos(angle) + perpendicular * Mathf.Sin(angle));
line.SetPosition(i, center + camera.transform.right * point.x + camera.transform.up * point.y);
}
for (int i = 0; i <= 32; i++)
{
float angle = Mathf.PI * .5f + Mathf.PI * i / 32f;
Vector2 point = -projectedAxis + radius
* (direction * Mathf.Cos(angle) + perpendicular * Mathf.Sin(angle));
line.SetPosition(33 + i, center + camera.transform.right * point.x + camera.transform.up * point.y);
}
}
private static int TimedFrame(float[] durations, float elapsed)
{
if (durations == null || durations.Length == 0) return 0;
for (int i = 0; i < durations.Length; i++)
{
if (elapsed < durations[i]) return i;
elapsed -= durations[i];
}
return durations.Length - 1;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0f62837c9d2482e9e3b23f43b10ee33
@@ -0,0 +1,344 @@
using System;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
[DefaultExecutionOrder(1000)]
public sealed class Defense25DStatusVisual : MonoBehaviour
{
public const int StatusSortingOffset = 1;
public const float StatusVisualAnchorOffsetY = -13f / 32f;
private const int FrameCount = 6;
private const float PixelsPerUnit = 32f;
private const float StunMarkerGap = 4f / 32f;
private const string IgniteResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Ignite-Warlock-v1";
private const string ShockResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Shock-Warlock-v1";
private static readonly float[] IgniteFrameDurations = { .1f, .1f, .1f, .1f, .1f, .1f };
private static readonly float[] ShockFrameDurations = { .06f, .04f, .07f, .05f, .07f, .11f };
private sealed class CachedFrames
{
public CachedFrames(Sprite[] frames, bool ownsSprites)
{
Frames = frames;
OwnsSprites = ownsSprites;
}
public Sprite[] Frames { get; }
public bool OwnsSprites { get; }
}
private static readonly Dictionary<string, CachedFrames> FrameCache = new();
private sealed class StatusSlot
{
public StatusSlot(string resourcePath, string objectName, float[] frameDurations)
{
ResourcePath = resourcePath;
ObjectName = objectName;
FrameDurations = frameDurations;
}
public string ResourcePath { get; }
public string ObjectName { get; }
public float[] FrameDurations { get; }
public Sprite[] Frames;
public GameObject Root;
public SpriteRenderer Renderer;
public int FrameIndex;
public float FrameElapsed;
}
private readonly StatusSlot ignite = new(
IgniteResourcePath, "Ignite Status Visual", IgniteFrameDurations);
private readonly StatusSlot shock = new(
ShockResourcePath, "Shock Status Visual", ShockFrameDurations);
private Defense25DEnemy enemy;
private Defense25DComparison owner;
private GameObject stunRoot;
private SpriteRenderer stunRenderer;
private float stunElapsed;
private bool stunActive;
private int lastStunVisualRevision;
private Defense25DSprite tintedVisual;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetFrameCache()
{
foreach (CachedFrames cached in FrameCache.Values)
{
if (!cached.OwnsSprites) continue;
foreach (Sprite sprite in cached.Frames)
{
if (sprite == null) continue;
if (Application.isPlaying) Destroy(sprite);
else DestroyImmediate(sprite);
}
}
FrameCache.Clear();
}
public void Bind(Defense25DEnemy value)
{
if (enemy == value) return;
ClearSlot(ignite);
ClearSlot(shock);
ClearStunMarker();
ClearStunTint();
enemy = value;
owner = enemy != null ? enemy.GetComponentInParent<Defense25DComparison>() : null;
lastStunVisualRevision = enemy != null ? enemy.StunVisualRevision : 0;
}
private void OnDisable()
{
ClearSlot(ignite);
ClearSlot(shock);
ClearStunMarker();
ClearStunTint();
}
private void LateUpdate()
{
SpriteRenderer sourceRenderer = enemy != null && enemy.Actor != null
&& enemy.Actor.Visual != null ? enemy.Actor.Visual.Renderer : null;
if (enemy == null || !enemy.isActiveAndEnabled || owner == null
|| !owner.isActiveAndEnabled || !owner.IsRunning || enemy.Actor == null
|| !enemy.Actor.IsAlive || sourceRenderer == null || !sourceRenderer.enabled)
{
ClearSlot(ignite);
ClearSlot(shock);
ClearStunMarker();
ClearStunTint();
return;
}
float deltaTime = owner.Paused ? 0f : Time.deltaTime;
UpdateSlot(ignite, enemy.IsIgnited, sourceRenderer, deltaTime);
UpdateSlot(shock, enemy.IsShocked, sourceRenderer, deltaTime);
UpdateStunMarker(sourceRenderer, deltaTime);
}
private void UpdateStunMarker(SpriteRenderer sourceRenderer, float deltaTime)
{
bool active = enemy.IsStunned;
bool refreshed = enemy.StunVisualRevision != lastStunVisualRevision;
lastStunVisualRevision = enemy.StunVisualRevision;
if (!active)
{
ClearStunMarker();
ClearStunTint();
return;
}
if (stunRoot == null || !stunActive || refreshed)
{
ClearStunMarker();
CreateStunMarker(sourceRenderer);
ApplyStunTint();
}
stunActive = true;
stunElapsed += deltaTime;
Transform actorTransform = enemy.Actor.transform;
stunRoot.transform.localPosition = CalculateLocalStunMarkerPosition(
actorTransform, sourceRenderer);
stunRoot.transform.localRotation = Quaternion.Inverse(actorTransform.rotation)
* sourceRenderer.transform.rotation;
stunRoot.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(
actorTransform.lossyScale);
stunRenderer.sprite = CrowdControlMarkerArt.Frame(stunElapsed, false);
stunRenderer.sortingLayerID = sourceRenderer.sortingLayerID;
stunRenderer.sortingOrder = CombatFeedback.CalculateStunMarkerSortingOrder(
sourceRenderer.sortingOrder);
stunRenderer.forceRenderingOff = sourceRenderer.forceRenderingOff;
stunRenderer.enabled = sourceRenderer.enabled;
stunRenderer.sharedMaterial = sourceRenderer.sharedMaterial;
stunRenderer.color = new Color(1f, 1f, 1f, sourceRenderer.color.a);
}
private void CreateStunMarker(SpriteRenderer sourceRenderer)
{
Transform actorTransform = enemy.Actor.transform;
stunRoot = new GameObject("Stun Runes");
stunRoot.transform.SetParent(actorTransform, false);
stunRoot.transform.localPosition = CalculateLocalStunMarkerPosition(
actorTransform, sourceRenderer);
stunRoot.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(
actorTransform.lossyScale);
stunRenderer = stunRoot.AddComponent<SpriteRenderer>();
stunRenderer.sprite = CrowdControlMarkerArt.Frame(0f, false);
stunRenderer.color = Color.white;
stunRenderer.sharedMaterial = sourceRenderer.sharedMaterial;
stunElapsed = 0f;
stunActive = true;
}
private static Vector3 CalculateLocalStunMarkerPosition(
Transform actorTransform, SpriteRenderer sourceRenderer)
{
Transform bodyTransform = sourceRenderer.transform;
Vector3 head = bodyTransform.TransformPoint(new Vector3(
0f, CrowdControlMarkerArt.HeadLocalHeight(sourceRenderer.sprite), 0f));
Vector3 markerPosition = head + bodyTransform.up * (StunMarkerGap
+ CrowdControlMarkerArt.MarkerHalfHeight
* Mathf.Abs(bodyTransform.lossyScale.y));
return actorTransform.InverseTransformPoint(markerPosition);
}
private void ApplyStunTint()
{
tintedVisual = enemy.Actor.Visual;
if (tintedVisual != null)
tintedVisual.SetFeedbackTint(Color.Lerp(Color.white, new Color(.72f, .42f, 1f), .3f));
}
private void ClearStunTint()
{
if (tintedVisual != null) tintedVisual.ClearFeedbackTint();
tintedVisual = null;
}
private void ClearStunMarker()
{
if (stunRoot != null)
{
stunRoot.SetActive(false);
Destroy(stunRoot);
}
stunRoot = null;
stunRenderer = null;
stunElapsed = 0f;
stunActive = false;
}
private void UpdateSlot(
StatusSlot slot, bool active, SpriteRenderer sourceRenderer, float deltaTime)
{
if (!active)
{
ClearSlot(slot);
return;
}
if (slot.Root == null)
{
slot.Frames ??= LoadFrames(slot.ResourcePath);
if (slot.Frames.Length == 0) return;
CreateSlot(slot);
}
SyncSlot(slot, sourceRenderer);
AdvanceSlot(slot, deltaTime);
}
private static void CreateSlot(StatusSlot slot)
{
slot.Root = new GameObject(slot.ObjectName);
slot.Renderer = slot.Root.AddComponent<SpriteRenderer>();
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
slot.Renderer.sprite = slot.Frames[0];
}
private static void SyncSlot(StatusSlot slot, SpriteRenderer sourceRenderer)
{
if (slot.Root == null || slot.Renderer == null) return;
Transform sourceTransform = sourceRenderer.transform;
if (slot.Root.transform.parent != sourceTransform)
slot.Root.transform.SetParent(sourceTransform, false);
slot.Root.transform.localPosition = new Vector3(
0f, sourceRenderer.flipY ? -StatusVisualAnchorOffsetY : StatusVisualAnchorOffsetY, 0f);
slot.Root.transform.localRotation = Quaternion.identity;
slot.Root.transform.localScale = Vector3.one;
slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID;
slot.Renderer.sortingOrder = sourceRenderer.sortingOrder + StatusSortingOffset;
slot.Renderer.forceRenderingOff = sourceRenderer.forceRenderingOff;
slot.Renderer.flipX = sourceRenderer.flipX;
slot.Renderer.flipY = sourceRenderer.flipY;
slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial;
slot.Renderer.color = new Color(1f, 1f, 1f, sourceRenderer.color.a);
slot.Renderer.enabled = sourceRenderer.enabled;
}
private static void AdvanceSlot(StatusSlot slot, float deltaTime)
{
if (slot.Renderer == null || slot.Frames == null
|| slot.Frames.Length == 0 || deltaTime <= 0f) return;
slot.FrameElapsed += deltaTime;
while (slot.FrameElapsed >= slot.FrameDurations[slot.FrameIndex])
{
slot.FrameElapsed -= slot.FrameDurations[slot.FrameIndex];
slot.FrameIndex = (slot.FrameIndex + 1) % slot.Frames.Length;
slot.Renderer.sprite = slot.Frames[slot.FrameIndex];
}
}
private static void ClearSlot(StatusSlot slot)
{
if (slot.Root != null)
{
slot.Root.SetActive(false);
Destroy(slot.Root);
}
slot.Root = null;
slot.Renderer = null;
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
}
private static Sprite[] LoadFrames(string resourcePath)
{
if (FrameCache.TryGetValue(resourcePath, out CachedFrames cached)) return cached.Frames;
Sprite[] importedFrames = Resources.LoadAll<Sprite>(resourcePath);
if (importedFrames != null && importedFrames.Length >= FrameCount)
{
Sprite[] frames = new Sprite[FrameCount];
Array.Copy(importedFrames, frames, FrameCount);
FrameCache[resourcePath] = new CachedFrames(frames, false);
return frames;
}
Texture2D texture = importedFrames != null && importedFrames.Length > 0
? importedFrames[0].texture
: Resources.Load<Texture2D>(resourcePath);
if (texture == null || texture.width < FrameCount
|| texture.width % FrameCount != 0 || texture.height <= 0)
{
Sprite[] missing = Array.Empty<Sprite>();
FrameCache[resourcePath] = new CachedFrames(missing, false);
return missing;
}
int frameWidth = texture.width / FrameCount;
Sprite[] generatedFrames = new Sprite[FrameCount];
for (int i = 0; i < FrameCount; i++)
{
generatedFrames[i] = Sprite.Create(
texture,
new Rect(i * frameWidth, 0f, frameWidth, texture.height),
new Vector2(.5f, .125f),
PixelsPerUnit,
0,
SpriteMeshType.FullRect);
generatedFrames[i].name = $"{resourcePath} Frame {i + 1}";
generatedFrames[i].hideFlags = HideFlags.HideAndDontSave;
}
FrameCache[resourcePath] = new CachedFrames(generatedFrames, true);
return generatedFrames;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e0877869c1f6490a945d49e8fc16fead
@@ -0,0 +1,159 @@
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DSummon : MonoBehaviour
{
private Defense25DComparison world;
private Defense25DTower tower;
private Vector2 anchor;
private Defense25DEnemy target;
private float attackElapsed, nextAttack, respawnRemaining;
private bool attacking, hit, returning;
public Defense25DActor Actor { get; private set; }
public int SlotIndex { get; private set; }
public Defense25DTower Owner => tower;
public int AttackEvents { get; private set; }
public int LastDamageFrame { get; private set; } = -1;
public int Respawns { get; private set; }
public bool IsAttacking => attacking;
public bool IsReturningToAnchor => returning;
public float RespawnRemaining => tower != null ? tower.SummonRespawnRemaining : respawnRemaining;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
public void Configure(Defense25DComparison owner, Defense25DActor actor,
Vector2 slot, Defense25DTower towerOwner = null, int slotIndex = -1)
{
world = owner;
Actor = actor;
anchor = slot;
tower = towerOwner;
SlotIndex = slotIndex;
if (tower != null) Actor.Visual.ConfigureSummonArt(tower.Tier, tower.Branch);
}
public void ResetForRun()
{
SuspendAttack();
Actor.ResetAt(anchor);
AttackEvents = Respawns = 0;
LastDamageFrame = -1;
nextAttack = respawnRemaining = 0f;
}
public void BeginRespawn()
{
SuspendAttack();
if (tower != null) tower.NotifySummonUnitDied(this);
else respawnRemaining = Tuning.SummonUnitRespawnSeconds;
}
public void SuspendAttack()
{
attacking = hit = returning = false;
target = null;
attackElapsed = 0f;
Actor.Visual.SetAttack(false);
}
public void Step(float dt)
{
if (!world.TowersOperational || (tower != null && !tower.isActiveAndEnabled))
{ SuspendAttack(); return; }
if (!Actor.IsAlive)
{
if (tower != null) return;
respawnRemaining = Mathf.Max(0f, respawnRemaining - dt);
if (respawnRemaining <= 0f) { Actor.ResetAt(anchor); Respawns++; }
return;
}
if (Actor.HasKnockback) { Actor.Move(Actor.ConsumeKnockback(dt)); return; }
if (attacking)
{
Defense25DEnemy commandTarget = tower != null ? tower.CommandTarget : world.CommandTarget;
if (target == null || !target.IsAvailable || target != commandTarget)
{ FinishAttack(); return; }
attackElapsed += dt;
string attackKey = GetAttackKey();
int frame = Defense25DSprite.FrameAt(attackKey, attackElapsed, false);
Actor.Visual.SetAttackFrame(frame);
bool scout = tower != null && tower.Tier == 4
&& tower.Branch == DefenseTowerBranch.Second;
bool basicSlime = tower == null || tower.Tier < 4;
if (frame >= Tuning.SummonBodySlamImpactFrame && !hit && basicSlime)
{
Vector2 direction = (target.Actor.Center - Actor.Center).normalized;
Actor.Facing = direction;
Actor.Move(direction * (Actor.MoveSpeed * Tuning.SummonBodySlamSpeedMultiplier * dt), false);
}
if (!hit && frame >= Tuning.SummonBodySlamImpactFrame
&& (scout || !basicSlime
|| Actor.GapTo(target.Actor) <= Tuning.SummonBodySlamContactMargin))
{
hit = true;
bool landed = tower != null
? tower.TryAttackSummon(this, target)
: target.Actor.TakeDamage(Actor.CalculateAttackDamage(Tuning.SummonDamage)
* world.TowerEfficiency, (target.Actor.Center - Actor.Center).normalized);
if (landed)
{
AttackEvents++;
LastDamageFrame = frame;
}
}
if (attackElapsed >= Defense25DSprite.Duration(attackKey)) FinishAttack();
return;
}
Defense25DEnemy activeTarget = tower != null ? tower.CommandTarget : world.CommandTarget;
target = activeTarget;
if (returning || target == null || !target.IsAvailable)
{
Vector2 delta = anchor - Actor.Position;
Actor.Move(Vector2.ClampMagnitude(delta, Actor.MoveSpeed * dt));
if (returning && (delta.magnitude <= .08f || Actor.MovementBlocked))
returning = false;
return;
}
Vector2 previousPosition = Actor.Position;
Vector2 toward = target.Actor.Position - previousPosition;
Actor.Facing = toward.normalized;
bool isScout = tower != null && tower.Tier == 4
&& tower.Branch == DefenseTowerBranch.Second;
float range = Tuning.SummonAttackRange * (isScout ? 2.5f : 1f);
Vector2 nextPosition = previousPosition;
if (toward.magnitude > range * .8f)
nextPosition += Actor.Move(toward.normalized * Mathf.Min(
toward.magnitude - range * .8f, Actor.MoveSpeed * dt));
if (world.GameTime >= nextAttack
&& (target.Actor.Position - nextPosition).sqrMagnitude <= range * range)
{
attacking = true;
hit = false;
attackElapsed = 0f;
int tier = tower != null ? tower.Tier : 1;
nextAttack = world.GameTime + Tuning.SummonAttackInterval
/ (1f + .15f * (tier - 1))
* (tower != null ? tower.AttackIntervalMultiplier : 1f);
Actor.Visual.SetAttack(true);
}
}
private void FinishAttack()
{
attacking = false;
returning = tower == null || tower.Tier < 4;
target = null;
Actor.Visual.SetAttack(false);
}
private string GetAttackKey()
{
string family = tower != null && tower.Tier == 4
? tower.Branch == DefenseTowerBranch.First ? "GiantSlime" : "LavaSlime"
: "Slime";
return $"Summons/{family}/Attack01";
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 397309e5e32f429fb6903a42040ba89e
@@ -0,0 +1,194 @@
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower : MonoBehaviour
{
private Defense25DTowerManager manager;
private Defense25DComparison world;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
private SpriteRenderer body;
private Sprite[] idleFrames;
private Sprite[] attackFrames;
private float cooldown;
private float supportAuraStrength;
public DefenseTowerKind Kind { get; private set; }
public DefenseTowerBranch Branch { get; private set; }
public int Tier { get; private set; }
public int PadIndex { get; private set; }
public Vector2 Position { get; private set; }
public Color HitFeedbackColor => Kind switch
{
DefenseTowerKind.Direct => new Color32(240, 96, 65, 255),
DefenseTowerKind.Summon => new Color32(244, 154, 74, 255),
DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255),
DefenseTowerKind.Cold => new Color32(65, 211, 240, 255),
DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255),
_ => new Color32(148, 228, 122, 255),
};
public float SightEfficiency => DefensePrototypeRules.GetTowerEfficiency(
Vector2.Distance(world.Player.Position, Position), Tuning.PlayerSightRadius,
1f, Tuning.OutsideSightDamageEfficiency);
public void Configure(Defense25DTowerManager owner, Defense25DComparison run,
int padIndex, DefenseTowerKind kind, Vector2 position)
{
manager = owner;
world = run;
PadIndex = padIndex;
Kind = kind;
Position = position;
Tier = 1;
Branch = DefenseTowerBranch.None;
transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY);
CreateVisual();
InitializeSummons();
}
public bool TryUpgrade(DefenseTowerBranch branch)
{
if (Tier >= 4 || (Tier < 3 && branch != DefenseTowerBranch.None)
|| (Tier == 3 && branch is not (DefenseTowerBranch.First or DefenseTowerBranch.Second))) return false;
CancelPending();
Tier++;
if (Tier == 4) Branch = branch;
ReplaceSummonsForUpgrade();
RefreshVisual();
return true;
}
public void Step(float dt)
{
if (!world.TowersOperational) return;
StepBodyAnimation(dt);
if (Kind == DefenseTowerKind.Empowering)
return;
if (Kind == DefenseTowerKind.Summon)
{
StepSummons(dt);
StepParityAttacks(dt);
return;
}
StepParityAttacks(dt);
}
public void CancelPending()
{
CancelParityAttacks();
cooldown = 0f;
ResetBodyAnimation();
foreach (Defense25DSummon summon in ownedSummons)
if (summon != null) summon.SuspendAttack();
}
private void OnDisable()
{
CancelPending();
DestroySummonsForRunReset();
}
private bool ApplyDamage(Defense25DEnemy enemy, float damage)
{
if (enemy == null || !enemy.IsAvailable || !world.IsTowerShowcaseTarget(Kind, enemy)
|| world.IsTowerImmuneToEnemy(Kind, enemy)) return false;
Vector2 direction = (enemy.Actor.Position - Position).normalized;
float multiplier = 1f;
foreach (Defense25DTower support in manager.TowersInConstructionOrder)
{
if (support == null || support == this || support.Kind != DefenseTowerKind.Empowering) continue;
float efficiency = DefensePrototypeRules.GetTowerEfficiency(
Vector2.Distance(world.Player.Position, support.Position), Tuning.PlayerSightRadius,
1f, Tuning.OutsideSightDamageEfficiency);
if (Vector2.Distance(support.Position, Position) <= Tuning.SupportRadius)
multiplier = Mathf.Max(multiplier,
1f + Tuning.SupportDamageBonusPerTier * support.Tier * efficiency);
if (support.Tier == 4 && support.Branch == DefenseTowerBranch.Second
&& Vector2.Distance(support.Position, enemy.Actor.Position) <= Tuning.SupportRadius)
multiplier *= 1f + Tuning.SmotheringDamageBonus * efficiency;
}
multiplier *= GetBattleBatonDamageMultiplier();
bool hit = enemy.Actor.TakeDamageWithResult(damage * multiplier * SightEfficiency, direction,
0f, BumpCombat.Enemies.EnemyDamageSource.Tower, false, out float actualDamage);
if (actualDamage > 0f)
{
world.CombatFeedback?.NotifyTowerImpact(Kind, enemy,
enemy.Actor.ClosestGroundPoint(Position));
world.RecordTowerDamagePopup(actualDamage, HitFeedbackColor,
enemy.Actor.transform.position + Vector3.up * .7f);
}
return hit;
}
public void ResetSupport()
{
supportInterval = 1f;
supportIntervalSet = false;
supportAuraStrength = 0f;
SetBodySupportAuraTier(0, 0f);
}
public void UpdateSupport()
{
if (Kind != DefenseTowerKind.Empowering || !world.TowersOperational) return;
foreach (Defense25DTower tower in manager.TowersInConstructionOrder)
{
if (tower == null || tower == this || Vector2.Distance(Position, tower.Position) > Tuning.SupportRadius) continue;
tower.SetSupport(Tier == 4 && Branch == DefenseTowerBranch.First
? Tuning.ImbuingAttackIntervalMultiplier : 1f,
SightEfficiency, Tier, Tier * SightEfficiency);
}
}
private float supportInterval = 1f;
private bool supportIntervalSet;
private void SetSupport(float interval, float efficiency, int supportTier, float strength)
{
if (Tuning.SupportDamageBonusPerTier * strength > 0f && strength > supportAuraStrength)
{
supportAuraStrength = strength;
SetBodySupportAuraTier(supportTier, strength);
}
if (interval >= 1f || supportIntervalSet) return;
supportInterval = 1f - (1f - interval) * efficiency;
supportIntervalSet = true;
}
private float GetAttackIntervalMultiplier() => supportInterval;
public float AttackIntervalMultiplier => GetAttackIntervalMultiplier();
private void PlayAttack(Defense25DEnemy target)
{
StartBodyAttack(target.Actor.Position.x < Position.x);
}
private void CreateVisual()
{
GameObject visual = new("Tower billboard");
visual.transform.SetParent(transform, false);
body = visual.AddComponent<SpriteRenderer>();
body.sharedMaterial = world.Settings.CutoutMaterial;
RefreshVisual();
}
private void RefreshVisual()
{
ConfigureBodyAnimation();
}
private void LateUpdate()
{
if (body == null) return;
Quaternion facing = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
body.transform.rotation = facing;
body.transform.position = transform.position + Vector3.up * .45f - facing * Vector3.up * (-9f / 32f);
body.transform.localScale = Vector3.one * DefenseTowerArtBounds.TowerBillboardScale;
if (world.TowersOperational) body.color = Color.white;
else body.color = new Color(.45f, .45f, .45f);
UpdateBodySupportAuraVisual();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82f31b61a3df4e2bb0e8a828184f7527
@@ -0,0 +1,820 @@
using System;
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower
{
private sealed class ParityFlight
{
public float Delay;
public float Elapsed;
public float Duration;
public float StartHeightOffset;
public Vector2 Start;
public Vector2 End;
public Defense25DEnemy Target;
public Action<Defense25DEnemy> Impact;
public GameObject Root;
public DefenseSpriteSequence Sequence;
public bool TrackTarget;
public bool HideAtImpact;
public Func<Vector3> VisualStartProvider;
}
private sealed class CenteredImpactWave
{
public SpriteRenderer Renderer;
public Vector2 Center;
public float Scale;
}
private readonly List<ParityFlight> parityFlights = new();
private readonly List<GameObject> parityEffects = new();
private readonly HashSet<Defense25DEnemy> parityUniqueTargets = new();
private readonly List<CenteredImpactWave> centeredImpactWaves = new();
private string flameBeamKey = "Effects/Fire/Eyeball Monster_Beam-Clean";
private SpriteRenderer flameBeamRenderer;
private GameObject flameBeamRoot;
private Defense25DEnemy flameBeamTarget;
private float flameStreakStartedAt;
private float flameLastSuccessfulAt;
private bool flameStreakActive;
private bool showcaseBodyWasEnabled;
private readonly List<GameObject> showcaseSuspendedObjects = new();
private readonly List<bool> showcaseObjectActiveStates = new();
public void SetShowcaseSuspended(bool suspended)
{
if (manager == null || manager.IsSuspended != suspended) return;
if (suspended)
{
showcaseBodyWasEnabled = body != null && body.enabled;
if (body != null) body.enabled = false;
showcaseSuspendedObjects.Clear();
showcaseObjectActiveStates.Clear();
foreach (Defense25DSummon summon in ownedSummons)
RememberShowcaseObject(summon != null ? summon.gameObject : null);
foreach (GameObject effect in parityEffects)
RememberShowcaseObject(effect);
}
else
{
for (int i = 0; i < showcaseSuspendedObjects.Count; i++)
if (showcaseSuspendedObjects[i] != null)
showcaseSuspendedObjects[i].SetActive(showcaseObjectActiveStates[i]);
foreach (Defense25DSummon summon in ownedSummons)
if (summon != null && summon.gameObject.activeSelf)
summon.Actor.RestoreLiveCollisionAndVisuals();
if (body != null) body.enabled = showcaseBodyWasEnabled;
showcaseSuspendedObjects.Clear();
showcaseObjectActiveStates.Clear();
}
}
private void RememberShowcaseObject(GameObject instance)
{
if (instance == null || showcaseSuspendedObjects.Contains(instance)) return;
showcaseSuspendedObjects.Add(instance);
showcaseObjectActiveStates.Add(instance.activeSelf);
instance.SetActive(false);
}
public void StepParityAttacks(float dt)
{
if (!world.TowersOperational) return;
AdvanceParityFlights(dt);
if (Kind is DefenseTowerKind.Empowering or DefenseTowerKind.Summon) return;
if (Kind == DefenseTowerKind.Direct && Tier == 4
&& Branch == DefenseTowerBranch.Second)
{
StepParityFlamethrower(dt);
return;
}
cooldown -= dt;
if (cooldown > 0f) return;
Defense25DEnemy target = FindParityTarget(
Kind is DefenseTowerKind.Cold or DefenseTowerKind.Seismic,
GetParityRange(), preserveTarget: false);
if (target == null) return;
var previousBodyAnimation = CaptureBodyAnimationState();
PlayAttack(target);
bool fired = Kind switch
{
DefenseTowerKind.Direct => FireParityDirect(target),
DefenseTowerKind.Lightning => FireParityLightning(target),
DefenseTowerKind.Cold => FireParityCold(target),
DefenseTowerKind.Seismic => FireParitySeismic(target),
_ => false,
};
if (!fired)
{
RestoreBodyAnimationState(previousBodyAnimation);
return;
}
float interval = Kind switch
{
DefenseTowerKind.Direct => Tuning.DirectTowerAttackInterval,
DefenseTowerKind.Lightning => Tuning.LightningInterval,
DefenseTowerKind.Cold => Tuning.ColdInterval,
_ => Tuning.SeismicInterval,
};
if (Tier == 4 && Kind == DefenseTowerKind.Direct
&& Branch == DefenseTowerBranch.Second) interval *= 0.4f;
cooldown = Mathf.Max(0.05f,
interval / (1f + 0.15f * (Tier - 1)) * GetAttackIntervalMultiplier());
}
public void CancelParityAttacks()
{
parityFlights.Clear();
centeredImpactWaves.Clear();
for (int i = 0; i < parityEffects.Count; i++)
if (parityEffects[i] != null) Destroy(parityEffects[i]);
parityEffects.Clear();
if (flameBeamRoot != null) Destroy(flameBeamRoot);
flameBeamRoot = null;
flameBeamRenderer = null;
flameBeamTarget = null;
flameStreakActive = false;
flameStreakStartedAt = flameLastSuccessfulAt = 0f;
cooldown = 0f;
}
private bool FireParityDirect(Defense25DEnemy target)
{
float scale = GetParityDamageScale();
if (Tier == 4 && Branch == DefenseTowerBranch.First)
{
Vector2 destination = target.Actor.Position;
Vector2 start = destination + new Vector2(-0.81f, 0.81f);
CreateParityFlight("Effects/Fire/Meteor-Flight", start, destination,
0f, 0.32f, target, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyParityArea(impact.Actor.Position, 1.8f,
Tuning.DirectTowerDamage * scale * 1.6f);
CreateParityRadius(impact.Actor.Position, 1.8f, impact, .22f, .9f);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Meteor-Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
});
}
else
{
Vector3 visualStart = ParityStaffTipVisualWorldPosition;
Vector2 start = Defense25DActor.Ground(visualStart);
string key = $"Effects/Fire/T{Tier}-Fireball";
CreateParityFlight(key, start, target.Actor.Position, 0f, 0.18f, target, true,
impact =>
{
if (!CanParityImpact(impact)) return;
ApplyDamage(impact, Tuning.DirectTowerDamage * scale);
SpriteRenderer hit = CreateParitySprite("Effects/Fire/Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
}, visualStart: visualStart);
}
return true;
}
private bool FireParityLightning(Defense25DEnemy target)
{
Vector2 center = target.Actor.Position;
Vector2 visualCenter = GetParityShadowPosition(target);
float radius = Tier == 4
? Branch == DefenseTowerBranch.First ? 3f : 2.4f
: Tuning.LightningRadius;
float damage = Tuning.LightningDamage * GetParityDamageScale()
* (Tier == 4 && Branch == DefenseTowerBranch.Second ? 1.4f : 1f);
int maxTargets = Tier == 4 && Branch == DefenseTowerBranch.First ? 3 : int.MaxValue;
if (!ApplyParityArea(center, radius, damage, maxTargets)) return false;
CreateParityRadius(visualCenter, radius, target, .22f, .9f);
string key = Tier == 4 && Branch == DefenseTowerBranch.Second
? "Effects/Lightning/LightningStorm"
: Tier == 4 ? "Effects/Lightning/Arc" : $"Effects/Lightning/T{Tier}-Bolt";
GameObject root = CreateParityRoot("Lightning Attack Effect");
if (Tier == 4 && Branch == DefenseTowerBranch.Second)
{
SpriteRenderer storm = CreateParitySprite(root.transform, key, visualCenter,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(storm, target);
FitParityStorm(storm, visualCenter, radius);
}
else
{
Vector2 origin = GetParityStaffTip();
SpriteRenderer bolt = CreateParitySprite(root.transform, key,
Vector2.Lerp(origin, visualCenter, .5f), Vector2.one,
GroundAngle(origin, visualCenter), 14, destroy: true);
SetParityHitSorting(bolt, target);
FitParityBeam(bolt, origin, visualCenter, 1f);
}
if (Tier == 4)
{
float waveScale = Mathf.Max(0.8f, radius / 1.4f);
SpriteRenderer wave = CreateParitySprite(root.transform, "Effects/Lightning/ImpactWave", visualCenter,
Vector2.one * waveScale, 0f, 13, destroy: true, startDelay: 0.15f);
SetParityHitSorting(wave, target);
}
return true;
}
private bool FireParityCold(Defense25DEnemy target)
{
float damageScale = GetParityDamageScale();
Vector3 visualStart = ParityStaffTipVisualWorldPosition;
Vector2 start = Defense25DActor.Ground(visualStart);
if (Tier == 4 && Branch == DefenseTowerBranch.Second)
{
Vector2 center = target.Actor.Position;
Vector2 shadowCenter = GetParityShadowPosition(target);
CreateParityRadius(shadowCenter, 1.65f, target,
DefenseTowerArtBounds.BlizzardLastLandingSeconds + .22f,
DefenseTowerArtBounds.BlizzardImpactRadiusAlpha);
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
{
float launchDelay = i * DefenseTowerArtBounds.BlizzardLaunchIntervalSeconds;
Vector2 landing = shadowCenter + DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
float height = DefenseTowerArtBounds.BlizzardDropHeight
+ i % 4 * DefenseTowerArtBounds.BlizzardDropHeightStep;
float rotation = 90f + (i % 5 - 2) * 7f;
Vector2 shardStart = landing + Vector2.up * height;
SpriteRenderer wave = CreateParitySprite("Effects/Ice/Blizzard-Wave-v1", landing,
Vector2.one * DefenseTowerArtBounds.BlizzardWaveScale, 0f, 13,
destroy: true, startDelay: launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds);
SetParityHitSorting(wave, target);
centeredImpactWaves.Add(new CenteredImpactWave
{
Renderer = wave,
Center = landing,
Scale = DefenseTowerArtBounds.BlizzardWaveScale,
});
SpriteRenderer impact = CreateParitySprite("Effects/Ice/Impact", landing,
Vector2.one * DefenseTowerArtBounds.BlizzardImpactScale, 0f, 14,
destroy: true, startDelay: launchDelay + DefenseTowerArtBounds.BlizzardLandingSeconds);
SetParityHitSorting(impact, target);
CreateParityFlight("Effects/Ice/Shard-Blizzard-v1", shardStart, landing,
launchDelay, DefenseTowerArtBounds.BlizzardLandingSeconds, target, false,
_ => ApplyParityBlizzardWave(landing, 1.65f,
Tuning.ColdDamage * damageScale * 0.5f), rotation,
scale: Vector2.one * DefenseTowerArtBounds.BlizzardShardScale);
CreateParityFlight("Effects/Ice/Shard-Blizzard-v1",
shardStart + Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset,
landing + Vector2.up * DefenseTowerArtBounds.BlizzardTrailOffset,
launchDelay, DefenseTowerArtBounds.BlizzardLandingSeconds, target, false,
null, rotation, scale: Vector2.one * DefenseTowerArtBounds.BlizzardTrailScale,
alpha: DefenseTowerArtBounds.BlizzardTrailAlpha);
}
return true;
}
int projectileCount = Tier == 4 ? Tuning.FreezeboltProjectileCount : Tier;
string projectileKey = Tier == 4 ? "Effects/Ice/Freezebolt" : "Effects/Ice/T1-Volley";
for (int i = 0; i < projectileCount; i++)
{
float delay = i * Tuning.ColdProjectileBurstInterval;
CreateParityFlight(projectileKey, start, target.Actor.Position, delay, 0.18f,
target, true, impact =>
{
if (!CanParityImpact(impact)) return;
float sight = SightEfficiency;
impact.ApplySlow(Tuning.ColdSlowSeconds * (1f + .25f * (Tier - 1)),
Tuning.ColdSlowFraction * sight);
ApplyDamage(impact, Tuning.ColdDamage * damageScale
* (Tier == 4 ? 1.8f : 1f));
if (Tier == 4) impact.ApplyStun(.3f * sight);
SpriteRenderer hit = CreateParitySprite("Effects/Ice/Impact", impact.Actor.Position,
Vector2.one, 0f, 13, destroy: true);
SetParityHitSorting(hit, impact);
}, hideAtImpact: true, scale: Vector2.one,
visualStartProvider: () => ParityStaffTipVisualWorldPosition,
visualStart: visualStart);
}
return true;
}
private bool FireParitySeismic(Defense25DEnemy target)
{
Vector2 center = target.Actor.Position;
float radius = Tier == 4
? Branch == DefenseTowerBranch.Second ? 2.2f : 1.8f
: Tuning.SeismicRadius;
bool applied = Tier == 4 && Branch == DefenseTowerBranch.Second
? ApplyParityArea(center, radius, Tuning.SeismicDamage * GetParityDamageScale(),
slowSeconds: 2f, slowFraction: .5f)
: ApplyParityArea(center, radius, Tuning.SeismicDamage * GetParityDamageScale(),
stunSeconds: Tuning.SeismicStunSeconds * (Tier == 4 ? 2f : 1f));
if (!applied) return false;
string key = Tier == 4
? Branch == DefenseTowerBranch.First ? "Effects/Control/StoneGaze" : "Effects/Control/Temporal"
: $"Effects/Control/T{Tier}-Aura";
int visibleWidth = Tier == 4
? Branch == DefenseTowerBranch.First ? 40 : 45
: 32 + (Tier - 1) * 6;
float scale = GetParityGroundMarkerScale(target, key, visibleWidth);
SpriteRenderer marker = CreateParitySprite(key, center, Vector2.one * scale, 0f, 13, destroy: true);
SetParityGroundSorting(marker, target);
return true;
}
private void StepParityFlamethrower(float dt)
{
float now = world.GameTime;
if (flameStreakActive && now - flameLastSuccessfulAt >= Tuning.FireT4BeamResetAfterSeconds)
{
EndParityFlameBeam(true);
ResetParityFlameStreak();
}
bool valid = flameBeamTarget != null && CanParityImpact(flameBeamTarget)
&& Vector2.Distance(Position, flameBeamTarget.Actor.Position) <= GetParityRange();
if (!valid)
{
EndParityFlameBeam(false);
flameBeamTarget = FindParityTarget(false, GetParityRange(), false);
if (flameBeamTarget == null) return;
EnsureParityFlameBeam(flameBeamTarget);
}
cooldown -= dt;
if (cooldown > 0f) return;
Defense25DEnemy target = flameBeamTarget;
EnsureParityFlameBeam(target);
float elapsed = flameStreakActive ? Mathf.Max(0f, now - flameStreakStartedAt) : 0f;
float rampMultiplier = elapsed >= Tuning.FireT4BeamMaximumTierSeconds
? Tuning.FireT4BeamMaximumMultiplier
: elapsed >= Tuning.FireT4BeamSecondTierSeconds
? Tuning.FireT4BeamSecondTierMultiplier : 1f;
float beamMultiplier = Tuning.FireT4BeamBaseDamageMultiplier * rampMultiplier;
float expectedDamage = Tuning.DirectTowerDamage * GetParityDamageScale()
* Tuning.FireT4BeamBaseDamageMultiplier * rampMultiplier;
float damage = Tuning.DirectTowerDamage * GetParityDamageScale() * beamMultiplier;
AssertParityFlameMultiplier(rampMultiplier, beamMultiplier);
Debug.Assert(Mathf.Approximately(damage, expectedDamage),
"Flamethrower damage must retain the source base multiplier before ramp scaling.");
if (!ApplyDamage(target, damage))
{
EndParityFlameBeam(false);
return;
}
if (!flameStreakActive) flameStreakStartedAt = now;
flameStreakActive = true;
flameLastSuccessfulAt = now;
cooldown = Mathf.Max(.05f, Tuning.DirectTowerAttackInterval * 0.4f
/ (1f + .15f * (Tier - 1)) * GetAttackIntervalMultiplier());
PlayAttack(target);
FitParityFlameBeam(target, elapsed);
}
private void EnsureParityFlameBeam(Defense25DEnemy target)
{
if (flameBeamRoot != null && flameBeamTarget == target) return;
EndParityFlameBeam(true);
Sprite[] frames = DefenseTowerArt.Load(flameBeamKey);
if (frames.Length == 0) return;
flameBeamRoot = CreateParityRoot("Fire T4 Sustained Beam");
GameObject beam = new("Beam Sprite");
beam.transform.SetParent(flameBeamRoot.transform, false);
flameBeamRenderer = beam.AddComponent<SpriteRenderer>();
flameBeamRenderer.sharedMaterial = world.Settings.CutoutMaterial;
flameBeamRenderer.sortingOrder = 14;
flameBeamRenderer.sprite = frames[0];
flameBeamTarget = target;
SetParityHitSorting(flameBeamRenderer, target);
if (!flameStreakActive) flameStreakStartedAt = world.GameTime;
FitParityFlameBeam(target, 0f);
}
private void FitParityFlameBeam(Defense25DEnemy target, float elapsed)
{
if (flameBeamRenderer == null || flameBeamRenderer.sprite == null || target == null) return;
Sprite[] frames = DefenseTowerArt.Load(flameBeamKey);
if (frames.Length >= 2 && elapsed >= DefenseTowerArtBounds.FireBeamIgnitionSeconds)
flameBeamRenderer.sprite = frames[1];
Vector3 start = ParityStaffTipVisualWorldPosition;
Vector3 end = Defense25DActor.OnGround(target.Actor.Center, world.Settings.GroundY + .03f);
Vector3 direction = end - start;
Quaternion camera = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
Vector3 right = camera * Vector3.right;
Vector3 up = camera * Vector3.up;
float distance = new Vector2(Vector3.Dot(direction, right), Vector3.Dot(direction, up)).magnitude;
float angle = Mathf.Atan2(Vector3.Dot(direction, up), Vector3.Dot(direction, right)) * Mathf.Rad2Deg;
float ppu = flameBeamRenderer.sprite.pixelsPerUnit;
float scaleX = distance * ppu
/ (DefenseTowerArtBounds.FireBeamEndX - DefenseTowerArtBounds.FireBeamStartX);
float width = elapsed >= Tuning.FireT4BeamMaximumTierSeconds
? Tuning.FireT4BeamMaximumWidthMultiplier
: elapsed >= Tuning.FireT4BeamSecondTierSeconds
? Tuning.FireT4BeamSecondTierWidthMultiplier
: DefenseTowerArtBounds.FireT4BeamBaseWidthMultiplier;
Transform beam = flameBeamRenderer.transform;
beam.rotation = camera * Quaternion.Euler(0f, 0f, angle);
beam.localScale = new Vector3(scaleX, width, 1f);
Vector2 pixelStart = new(
(DefenseTowerArtBounds.FireBeamStartX - flameBeamRenderer.sprite.pivot.x) / ppu,
(flameBeamRenderer.sprite.rect.height - DefenseTowerArtBounds.FireBeamCenterY
- flameBeamRenderer.sprite.pivot.y) / ppu);
beam.position = start - beam.right * (pixelStart.x * scaleX) - beam.up * (pixelStart.y * width);
}
private void EndParityFlameBeam(bool immediate)
{
if (flameBeamRoot == null) return;
Sprite[] frames = DefenseTowerArt.Load(flameBeamKey);
if (frames.Length >= 3 && flameBeamRenderer != null) flameBeamRenderer.sprite = frames[2];
if (immediate) Destroy(flameBeamRoot);
else Destroy(flameBeamRoot, DefenseTowerArtBounds.FireBeamExtinguishSeconds);
flameBeamRoot = null;
flameBeamRenderer = null;
flameBeamTarget = null;
}
private void ResetParityFlameStreak()
{
flameStreakActive = false;
flameStreakStartedAt = flameLastSuccessfulAt = 0f;
}
private void AdvanceParityFlights(float dt)
{
AlignCenteredImpactWaves();
for (int i = parityFlights.Count - 1; i >= 0; i--)
{
ParityFlight flight = parityFlights[i];
float activeDelta = dt;
if (flight.Delay > 0f)
{
float delayStep = Mathf.Min(flight.Delay, dt);
flight.Delay -= delayStep;
activeDelta -= delayStep;
if (flight.Delay <= 0f && flight.VisualStartProvider != null)
{
Vector3 start = flight.VisualStartProvider();
flight.Start = Defense25DActor.Ground(start);
flight.StartHeightOffset = start.y - world.Settings.GroundY - .04f;
flight.VisualStartProvider = null;
}
if (flight.Delay > 0f) continue;
}
flight.Elapsed += activeDelta;
Vector2 end = flight.TrackTarget && CanParityImpact(flight.Target)
? flight.Target.Actor.Position : flight.End;
float progress = Mathf.Clamp01(flight.Elapsed / flight.Duration);
if (flight.Root != null)
{
float height = world.Settings.GroundY + .04f
+ Mathf.Lerp(flight.StartHeightOffset, 0f, progress);
flight.Root.transform.position = Defense25DActor.OnGround(
Vector2.Lerp(flight.Start, end, progress), height);
SpriteRenderer sprite = flight.Root.GetComponentInChildren<SpriteRenderer>();
if (sprite != null && flight.Delay <= 0f) sprite.enabled = true;
}
if (progress < 1f) continue;
parityFlights.RemoveAt(i);
if (flight.HideAtImpact && flight.Root != null)
{
SpriteRenderer sprite = flight.Root.GetComponentInChildren<SpriteRenderer>();
if (sprite != null) sprite.enabled = false;
}
flight.Impact?.Invoke(flight.Target);
if (flight.Root != null) Destroy(flight.Root);
}
for (int i = parityEffects.Count - 1; i >= 0; i--)
{
if (parityEffects[i] == null) { parityEffects.RemoveAt(i); continue; }
if (parityEffects[i].transform.childCount != 0) continue;
Destroy(parityEffects[i]);
parityEffects.RemoveAt(i);
}
}
private void CreateParityFlight(string key, Vector2 start, Vector2 end, float delay,
float duration, Defense25DEnemy target, bool trackTarget,
Action<Defense25DEnemy> impact, float rotation = 0f, bool hideAtImpact = false,
Vector2? scale = null, float alpha = 1f, string nextKey = null,
Func<Vector3> visualStartProvider = null, Vector3? visualStart = null)
{
GameObject root = CreateParityRoot(key.Contains("Shard") ? "Blizzard Shard Flight" : "Tower Projectile");
SpriteRenderer renderer = CreateParitySprite(root.transform, key, Vector2.zero,
scale ?? Vector2.one * (Kind == DefenseTowerKind.Direct ? .85f : 1f),
rotation, 14, destroy: false, nextKey: nextKey);
renderer.color = new Color(1f, 1f, 1f, alpha);
SetParityHitSorting(renderer, target);
renderer.enabled = delay <= 0f;
DefenseSpriteSequence sequence = renderer.GetComponent<DefenseSpriteSequence>();
sequence.SetCanAdvance(() => world != null && world.TowersOperational
&& (manager == null || !manager.IsSuspended));
if (delay > 0f && nextKey == null)
{
sequence.Play(renderer, key, DefenseTowerArt.Load(key), false, startDelay: delay);
sequence.SetCanAdvance(() => world != null && world.TowersOperational
&& (manager == null || !manager.IsSuspended));
}
sequence.SetCompletionCallback(() =>
{
if (renderer != null && root != null) renderer.enabled = true;
});
if (delay > 0f && key is "Effects/Ice/T1-Volley" or "Effects/Ice/Freezebolt")
Debug.Assert(visualStartProvider != null,
"Each delayed cold salvo projectile must resample its staff-tip at launch.");
Vector3 initialVisualStart = visualStart ?? Defense25DActor.OnGround(
start, world.Settings.GroundY + .04f);
parityFlights.Add(new ParityFlight
{
Delay = delay,
Duration = Mathf.Max(.001f, duration),
Start = start,
End = end,
Target = target,
TrackTarget = trackTarget,
Impact = impact,
Root = root,
Sequence = sequence,
HideAtImpact = hideAtImpact,
StartHeightOffset = initialVisualStart.y - world.Settings.GroundY - .04f,
VisualStartProvider = visualStartProvider,
});
root.transform.position = initialVisualStart;
renderer.transform.localPosition = Vector3.zero;
}
private GameObject CreateParityRoot(string label)
{
GameObject root = new(label);
root.transform.SetParent(transform.parent, false);
parityEffects.Add(root);
return root;
}
private SpriteRenderer CreateParitySprite(string key, Vector2 position, Vector2 scale,
float rotation, int sortingOrder, bool destroy, string nextKey = null,
float startDelay = 0f)
{
GameObject root = CreateParityRoot("Tower Attack Effect");
return CreateParitySprite(root.transform, key, position, scale, rotation,
sortingOrder, destroy, nextKey, startDelay);
}
private SpriteRenderer CreateParitySprite(Transform parent, string key, Vector2 position,
Vector2 scale, float rotation, int sortingOrder, bool destroy,
string nextKey = null, float startDelay = 0f)
{
GameObject image = new(key);
image.transform.SetParent(parent, false);
image.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY + .04f);
image.transform.localScale = new Vector3(scale.x, scale.y, 1f);
Quaternion camera = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
image.transform.rotation = camera * Quaternion.Euler(0f, 0f, rotation);
SpriteRenderer renderer = image.AddComponent<SpriteRenderer>();
renderer.sharedMaterial = world.Settings.CutoutMaterial;
renderer.sortingOrder = sortingOrder;
Sprite[] frames = DefenseTowerArt.Load(key);
Sprite[] next = string.IsNullOrEmpty(nextKey) ? null : DefenseTowerArt.Load(nextKey);
renderer.enabled = startDelay <= 0f;
DefenseSpriteSequence sequence = image.AddComponent<DefenseSpriteSequence>();
sequence.Play(renderer, key, frames, destroy, nextKey, next, startDelay);
sequence.SetCanAdvance(() => world != null && world.TowersOperational
&& (manager == null || !manager.IsSuspended));
return renderer;
}
private bool ApplyParityArea(Vector2 center, float radius, float damage,
int maxTargets = int.MaxValue, float slowSeconds = 0f,
float slowFraction = 0f, float stunSeconds = 0f)
{
int count = 0;
bool appliedAny = false;
parityUniqueTargets.Clear();
foreach (Defense25DActor actor in world.QueryEnemies(center, Vector2.one * radius))
{
if (count++ >= maxTargets) break;
Defense25DEnemy enemy = actor.GetComponent<Defense25DEnemy>();
if (enemy == null || !CanParityImpact(enemy) || !parityUniqueTargets.Add(enemy)
|| (actor.Position - center).sqrMagnitude > radius * radius) continue;
bool applied = ApplyDamage(enemy, damage);
float sight = SightEfficiency;
if (slowSeconds > 0f) applied |= enemy.ApplySlow(slowSeconds, slowFraction * sight);
if (stunSeconds > 0f) applied |= enemy.ApplyStun(stunSeconds * sight);
appliedAny |= applied;
}
return appliedAny;
}
private void ApplyParityBlizzardWave(Vector2 center, float castRadius, float damage)
{
float radiusX = DefenseTowerArtBounds.BlizzardWaveRadiusX;
float radiusY = DefenseTowerArtBounds.BlizzardWaveRadiusY;
float broadphase = castRadius + radiusX;
foreach (Defense25DActor actor in world.QueryEnemies(center, Vector2.one * broadphase))
{
Defense25DEnemy enemy = actor.GetComponent<Defense25DEnemy>();
if (enemy == null || !CanParityImpact(enemy)) continue;
Vector2 delta = Defense25DActor.Ground(actor.Visual.FootPosition) - center;
if (delta.x * delta.x / (radiusX * radiusX)
+ delta.y * delta.y / (radiusY * radiusY) > 1f) continue;
ApplyDamage(enemy, damage);
float sight = SightEfficiency;
enemy.ApplySlow(Tuning.ColdSlowSeconds * 1.5f, Tuning.ColdSlowFraction * sight);
enemy.ApplyStun(.35f * sight);
}
}
private bool CanParityImpact(Defense25DEnemy target) => target != null
&& target.IsAvailable && world != null && world.IsRunning
&& world.IsTowerShowcaseTarget(Kind, target);
private bool IsParityTowerImmune(Defense25DEnemy target) =>
world.IsTowerImmuneToEnemy(Kind, target);
private Defense25DEnemy FindParityTarget(bool leading, float range, bool preserveTarget)
{
if (preserveTarget && CanParityImpact(flameBeamTarget)
&& Vector2.Distance(Position, flameBeamTarget.Actor.Position) <= range)
return flameBeamTarget;
Defense25DEnemy nearest = null;
float best = range * range;
foreach (Defense25DEnemy enemy in world.GetTowerTargets(Kind))
{
if (!CanParityImpact(enemy) || (!leading && IsParityTowerImmune(enemy))) continue;
float distance = (enemy.Actor.Position - Position).sqrMagnitude;
if (distance > best) continue;
best = distance;
nearest = enemy;
}
return nearest;
}
private float GetParityRange() => Kind == DefenseTowerKind.Direct && Tier == 4
&& Branch == DefenseTowerBranch.Second ? Tuning.TowerRange * .5f : Tuning.TowerRange;
private float GetParityDamageScale() => 1f + Tuning.TowerDamageBonusPerTier * (Tier - 1);
private Vector2 GetParityStaffTip()
{
return Defense25DActor.Ground(ParityStaffTipVisualWorldPosition);
}
private float GetParityGroundMarkerScale(Defense25DEnemy target, string key, int widthPixels)
{
Sprite[] frames = DefenseTowerArt.Load(key);
Sprite sprite = frames.Length > 0 ? frames[0] : null;
if (sprite == null) return 0f;
SpriteRenderer enemyRenderer = target.Actor.Visual != null ? target.Actor.Visual.Renderer : null;
Sprite targetSprite = enemyRenderer != null ? enemyRenderer.sprite : null;
if (targetSprite == null) return 0f;
float markerWidth = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels
* Mathf.Abs(enemyRenderer.transform.lossyScale.x) / targetSprite.pixelsPerUnit;
return markerWidth / (widthPixels / sprite.pixelsPerUnit
* Mathf.Max(.001f, Mathf.Abs(transform.lossyScale.x)));
}
private static float GroundAngle(Vector2 start, Vector2 end) =>
Mathf.Atan2(end.y - start.y, end.x - start.x) * Mathf.Rad2Deg;
private static void SetParityHitSorting(Renderer renderer, Defense25DEnemy target)
{
SpriteRenderer targetRenderer = target != null && target.Actor != null
&& target.Actor.Visual != null ? target.Actor.Visual.Renderer : null;
if (renderer == null || targetRenderer == null) return;
renderer.sortingLayerID = targetRenderer.sortingLayerID;
renderer.sortingOrder = targetRenderer.sortingOrder
+ DefenseTowerArtBounds.TowerHitSortingOffset;
}
private static void SetParityGroundSorting(Renderer renderer, Defense25DEnemy target)
{
SpriteRenderer targetRenderer = target != null && target.Actor != null
&& target.Actor.Visual != null ? target.Actor.Visual.Renderer : null;
if (renderer == null || targetRenderer == null) return;
renderer.sortingLayerID = targetRenderer.sortingLayerID;
renderer.sortingOrder = targetRenderer.sortingOrder - 1;
}
private void CreateParityRadius(Vector2 center, float radius, Defense25DEnemy target,
float lifetime, float alpha)
{
GameObject root = CreateParityRoot("Tower Impact Radius");
GameObject ring = new("Impact Radius");
ring.transform.SetParent(root.transform, false);
LineRenderer line = ring.AddComponent<LineRenderer>();
line.sharedMaterial = world.Settings.BoundaryMaterial;
line.useWorldSpace = true;
line.loop = true;
line.widthMultiplier = .07f;
line.positionCount = 40;
Color color = Kind switch
{
DefenseTowerKind.Direct => new Color32(240, 96, 65, 255),
DefenseTowerKind.Lightning => new Color32(238, 218, 83, 255),
DefenseTowerKind.Cold => new Color32(65, 211, 240, 255),
DefenseTowerKind.Seismic => new Color32(175, 141, 100, 255),
_ => Color.white,
};
color.a = alpha;
line.startColor = line.endColor = color;
for (int i = 0; i < line.positionCount; i++)
{
float angle = i * Mathf.PI * 2f / line.positionCount;
Vector2 point = center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
line.SetPosition(i, Defense25DActor.OnGround(point, world.Settings.GroundY + .02f));
}
SetParityHitSorting(line, target);
Destroy(root, lifetime);
}
private void FitParityBeam(SpriteRenderer renderer, Vector2 start, Vector2 end, float thickness)
{
if (renderer == null || renderer.sprite == null) return;
Vector3 a = Defense25DActor.OnGround(start, world.Settings.GroundY + .04f);
Vector3 b = Defense25DActor.OnGround(end, world.Settings.GroundY + .04f);
Vector3 direction = b - a;
Quaternion camera = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
Vector3 right = camera * Vector3.right;
Vector3 up = camera * Vector3.up;
float screenX = Vector3.Dot(direction, right);
float screenY = Vector3.Dot(direction, up);
float distance = new Vector2(screenX, screenY).magnitude;
float angle = Mathf.Atan2(screenY, screenX) * Mathf.Rad2Deg;
Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(0);
float ppu = renderer.sprite.pixelsPerUnit;
float scaleX = distance * ppu / Mathf.Max(1f, bounds.y - bounds.x);
renderer.transform.rotation = camera * Quaternion.Euler(0f, 0f, angle);
renderer.transform.localScale = new Vector3(scaleX, thickness, 1f);
float localStart = (bounds.x - renderer.sprite.pivot.x) / ppu * scaleX;
renderer.transform.position = a - renderer.transform.right * localStart;
}
private static Vector2 GetParityShadowPosition(Defense25DEnemy target) =>
target != null && target.Actor != null && target.Actor.Visual != null
? Defense25DActor.Ground(target.Actor.Visual.FootPosition)
: target != null ? target.Actor.Position : Vector2.zero;
private void FitParityStorm(SpriteRenderer renderer, Vector2 center, float radius)
{
if (renderer == null || renderer.sprite == null) return;
Quaternion camera = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
float scale = radius * renderer.sprite.pixelsPerUnit
/ DefenseTowerArtBounds.LightningStormGroundRadiusPixels;
Vector2 localCenter = (DefenseTowerArtBounds.LightningStormGroundCenterPixels
- renderer.sprite.pivot) / renderer.sprite.pixelsPerUnit;
renderer.transform.rotation = camera;
renderer.transform.localScale = Vector3.one * scale;
Vector3 desired = Defense25DActor.OnGround(center, world.Settings.GroundY + .04f);
renderer.transform.position = desired - renderer.transform.TransformVector(localCenter);
}
private void AlignCenteredImpactWaves()
{
for (int i = centeredImpactWaves.Count - 1; i >= 0; i--)
{
CenteredImpactWave wave = centeredImpactWaves[i];
SpriteRenderer renderer = wave.Renderer;
if (renderer == null || renderer.sprite == null)
{
centeredImpactWaves.RemoveAt(i);
continue;
}
Sprite[] frames = DefenseTowerArt.Load("Effects/Ice/Blizzard-Wave-v1");
int frame = Array.IndexOf(frames, renderer.sprite);
Vector2 centerPixels = DefenseTowerArtBounds.ImpactWaveCenterPixels(frame, renderer.sprite);
Vector2 localCenter = (centerPixels - renderer.sprite.pivot)
/ renderer.sprite.pixelsPerUnit;
Quaternion camera = world.View != null ? world.View.transform.rotation
: Quaternion.Euler(world.Settings.CameraPitch, 0f, 0f);
renderer.transform.rotation = camera;
renderer.transform.localScale = Vector3.one * wave.Scale;
Vector3 desired = Defense25DActor.OnGround(wave.Center, world.Settings.GroundY + .04f);
renderer.transform.position = desired - renderer.transform.TransformVector(localCenter);
Debug.Assert(Vector3.Distance(renderer.transform.TransformPoint(localCenter), desired) < 0.001f,
"Blizzard wave's authored frame center must align with its landing point.");
}
}
private void AssertParityFlameMultiplier(float rampMultiplier, float damageMultiplier)
{
float baseMultiplier = Tuning.FireT4BeamBaseDamageMultiplier;
Debug.Assert(Mathf.Approximately(baseMultiplier, 0.4f));
Debug.Assert(Mathf.Approximately(baseMultiplier * 1f, 0.4f));
Debug.Assert(Mathf.Approximately(baseMultiplier * Tuning.FireT4BeamSecondTierMultiplier, 0.6f));
Debug.Assert(Mathf.Approximately(baseMultiplier * Tuning.FireT4BeamMaximumMultiplier, 0.8f));
Debug.Assert(Mathf.Approximately(damageMultiplier, baseMultiplier * rampMultiplier));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bab6fcd2cca241e388a2719e797bb218
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,213 @@
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower
{
private string bodyIdleKey;
private string bodyAttackKey;
private int bodyFrame;
private float bodyFrameTime;
private bool bodyAttacking;
private SpriteRenderer supportAuraRenderer;
private Sprite[] supportAuraFrames;
private string supportAuraKey;
private int supportAuraTier;
public int BodyAnimationFrame => bodyFrame;
public bool IsBodyAttacking => bodyAttacking;
public int BodySupportAuraTier => supportAuraTier;
public Vector3 ParityStaffTipVisualWorldPosition => GetParityStaffTipVisualWorldPosition();
private readonly struct BodyAnimationSnapshot
{
public readonly int Frame;
public readonly float FrameTime;
public readonly bool Attacking;
public readonly bool FlipX;
public readonly Sprite Sprite;
public BodyAnimationSnapshot(int frame, float frameTime, bool attacking,
bool flipX, Sprite sprite)
{
Frame = frame;
FrameTime = frameTime;
Attacking = attacking;
FlipX = flipX;
Sprite = sprite;
}
}
private void ConfigureBodyAnimation()
{
bodyIdleKey = DefenseTowerArt.BodyKey(Kind, Tier, "Idle");
bodyAttackKey = Kind switch
{
DefenseTowerKind.Direct or DefenseTowerKind.Lightning or DefenseTowerKind.Cold
=> DefenseTowerArt.BodyKey(Kind, Tier, "Attack01"),
DefenseTowerKind.Seismic => DefenseTowerArt.BodyKey(Kind, Tier, "Attack"),
DefenseTowerKind.Empowering => DefenseTowerArt.BodyKey(Kind, Tier, "Heal"),
_ => null,
};
idleFrames = DefenseTowerArt.Load(bodyIdleKey);
attackFrames = string.IsNullOrEmpty(bodyAttackKey)
? System.Array.Empty<Sprite>() : DefenseTowerArt.Load(bodyAttackKey);
bodyAttacking = false;
bodyFrame = 0;
bodyFrameTime = 0f;
if (body != null)
body.sprite = idleFrames.Length > 0 ? idleFrames[0] : null;
UpdateBodySupportAuraVisual();
}
private void StartBodyAttack(bool faceLeft)
{
if (body == null || attackFrames == null || attackFrames.Length == 0) return;
body.flipX = faceLeft;
bodyAttacking = true;
bodyFrame = 0;
bodyFrameTime = 0f;
body.sprite = attackFrames[0];
UpdateBodySupportAuraVisual();
}
private void StepBodyAnimation(float dt)
{
if (body == null || idleFrames == null || idleFrames.Length == 0) return;
bodyFrameTime += Mathf.Max(0f, dt);
Sprite[] frames = bodyAttacking ? attackFrames : idleFrames;
string key = bodyAttacking ? bodyAttackKey : bodyIdleKey;
if (frames == null || frames.Length == 0)
{
bodyAttacking = false;
bodyFrame = 0;
bodyFrameTime = 0f;
body.sprite = idleFrames[0];
UpdateBodySupportAuraVisual();
return;
}
bool changed = false;
while (bodyFrameTime >= DefenseTowerArt.FrameSeconds(key, bodyFrame))
{
bodyFrameTime -= DefenseTowerArt.FrameSeconds(key, bodyFrame);
bodyFrame++;
if (bodyFrame >= frames.Length)
{
if (bodyAttacking)
{
bodyAttacking = false;
bodyFrame = 0;
frames = idleFrames;
key = bodyIdleKey;
}
else
{
bodyFrame = 0;
}
}
body.sprite = frames[bodyFrame];
changed = true;
}
if (changed) UpdateBodySupportAuraVisual();
}
private void ResetBodyAnimation()
{
bodyAttacking = false;
bodyFrame = 0;
bodyFrameTime = 0f;
if (body != null)
body.sprite = idleFrames != null && idleFrames.Length > 0 ? idleFrames[0] : null;
UpdateBodySupportAuraVisual();
}
private BodyAnimationSnapshot CaptureBodyAnimationState() =>
new(bodyFrame, bodyFrameTime, bodyAttacking,
body != null && body.flipX, body != null ? body.sprite : null);
private void RestoreBodyAnimationState(BodyAnimationSnapshot snapshot)
{
bodyFrame = snapshot.Frame;
bodyFrameTime = snapshot.FrameTime;
bodyAttacking = snapshot.Attacking;
if (body != null)
{
body.flipX = snapshot.FlipX;
body.sprite = snapshot.Sprite;
}
UpdateBodySupportAuraVisual();
}
private Vector3 GetParityStaffTipVisualWorldPosition()
{
if (body == null || body.sprite == null) return transform.position;
Vector2 pixels = DefenseTowerArtBounds.StaffTipPixels(bodyAttacking, bodyFrame);
Vector2 localPixels = pixels - body.sprite.pivot;
localPixels.y = body.sprite.rect.height - pixels.y - body.sprite.pivot.y;
if (body.flipX) localPixels.x = -localPixels.x;
return body.transform.TransformPoint(localPixels / body.sprite.pixelsPerUnit);
}
private void SetBodySupportAuraTier(int tier, float strength)
{
_ = strength;
supportAuraTier = Mathf.Max(0, tier);
UpdateBodySupportAuraVisual();
}
private void UpdateBodySupportAuraVisual()
{
if (supportAuraTier <= 0 || body == null || body.sprite == null)
{
if (supportAuraRenderer != null) supportAuraRenderer.enabled = false;
return;
}
string motionKey = bodyAttacking && !string.IsNullOrEmpty(bodyAttackKey)
? bodyAttackKey : bodyIdleKey;
string key = motionKey.Replace("Bodies/", "SupportMasks/");
if (supportAuraKey != key)
{
supportAuraKey = key;
supportAuraFrames = DefenseTowerArt.Load(key);
}
if (supportAuraRenderer == null)
{
GameObject auraObject = new("Support Aura Art");
auraObject.transform.SetParent(transform, false);
supportAuraRenderer = auraObject.AddComponent<SpriteRenderer>();
supportAuraRenderer.sharedMaterial = body.sharedMaterial;
}
int maskFrame = 0;
if (supportAuraFrames != null)
{
for (int i = 0; i < supportAuraFrames.Length; i++)
{
if (supportAuraFrames[i].name != body.sprite.name) continue;
maskFrame = i;
break;
}
}
supportAuraRenderer.sprite = supportAuraFrames != null
&& supportAuraFrames.Length > maskFrame ? supportAuraFrames[maskFrame] : null;
supportAuraRenderer.flipX = body.flipX;
supportAuraRenderer.transform.position = body.transform.position;
supportAuraRenderer.transform.rotation = body.transform.rotation;
supportAuraRenderer.transform.localScale = body.transform.localScale;
supportAuraRenderer.sortingOrder = body.sortingOrder + 1;
Color[] colors = GameplayConstants.Current.Defense.SupportTierColors;
supportAuraRenderer.color = colors != null && colors.Length > 0
? colors[Mathf.Clamp(supportAuraTier - 1, 0, colors.Length - 1)]
: new Color32(151, 169, 119, 102);
supportAuraRenderer.enabled = supportAuraFrames != null
&& supportAuraFrames.Length > 0 && supportAuraRenderer.sprite != null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 316e185f506a4e14a5c16c86d2c2b10a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed class Defense25DTowerManager : MonoBehaviour
{
private const int PadCount = 6;
private readonly Defense25DTower[] towers = new Defense25DTower[PadCount];
private readonly List<Defense25DTower> constructionOrder = new(PadCount);
private Defense25DComparison world;
private DefenseConstants Tuning => GameplayConstants.Current.Defense;
public int ConstructionCurrency { get; private set; }
public bool IsSuspended { get; private set; }
public IReadOnlyList<Defense25DTower> Towers => towers;
public IReadOnlyList<Defense25DTower> TowersInConstructionOrder => constructionOrder;
public void Configure(Defense25DComparison owner)
{
world = owner;
ResetForRun();
}
public void ResetForRun()
{
for (int i = 0; i < towers.Length; i++)
{
if (towers[i] != null)
{
towers[i].DestroySummonsForRunReset();
Destroy(towers[i].gameObject);
}
towers[i] = null;
}
constructionOrder.Clear();
ConstructionCurrency = Tuning.InitialConstructionCurrency;
}
public Defense25DTower GetTowerAtPad(int padIndex)
{
return padIndex >= 0 && padIndex < towers.Length ? towers[padIndex] : null;
}
public bool TryBuildTowerAtPad(int padIndex, DefenseTowerKind kind)
{
if (!CanInteract(padIndex) || towers[padIndex] != null) return false;
int cost = GetBuildCost(kind);
if (cost < 0 || ConstructionCurrency < cost) return false;
Vector2 position = Tuning.GetBuildPadPosition(padIndex);
GameObject instance = new($"2.5D {kind} Tower Pad {padIndex}");
instance.transform.SetParent(transform, false);
Defense25DTower tower = instance.AddComponent<Defense25DTower>();
tower.Configure(this, world, padIndex, kind, position);
towers[padIndex] = tower;
constructionOrder.Add(tower);
ConstructionCurrency -= cost;
return true;
}
public bool TryUpgradeTowerAtPad(int padIndex, DefenseTowerBranch branch)
{
if (!CanInteract(padIndex) || towers[padIndex] == null) return false;
Defense25DTower tower = towers[padIndex];
if (tower.Tier >= 4
|| (tower.Tier < 3 && branch != DefenseTowerBranch.None)
|| (tower.Tier == 3 && branch != DefenseTowerBranch.First
&& branch != DefenseTowerBranch.Second)) return false;
int cost = tower.Tier switch
{
1 => Tuning.Tier2UpgradeCost,
2 => Tuning.Tier3UpgradeCost,
_ => Tuning.Tier4UpgradeCost,
};
if (ConstructionCurrency < cost || !tower.TryUpgrade(branch)) return false;
ConstructionCurrency -= cost;
return true;
}
public void AddCurrency(int amount)
{
if (amount > 0) ConstructionCurrency += amount;
}
public void Step(float deltaTime)
{
if (IsSuspended || !world.TowersOperational) return;
foreach (Defense25DTower tower in constructionOrder) tower?.ResetSupport();
foreach (Defense25DTower tower in constructionOrder) tower?.UpdateSupport();
foreach (Defense25DTower tower in constructionOrder) tower?.Step(deltaTime);
}
public void SetShowcaseSuspended(bool suspended)
{
if (IsSuspended == suspended) return;
IsSuspended = suspended;
foreach (Defense25DTower tower in towers)
tower?.SetShowcaseSuspended(suspended);
}
public void CancelAll()
{
for (int i = 0; i < towers.Length; i++) towers[i]?.CancelPending();
}
private bool CanInteract(int padIndex)
{
if (world == null || !world.TowersOperational || world.Player == null
|| padIndex < 0 || padIndex >= towers.Length) return false;
Vector2 pad = Tuning.GetBuildPadPosition(padIndex);
return (world.Player.Position - pad).sqrMagnitude
<= Tuning.BuildInteractionRadius * Tuning.BuildInteractionRadius;
}
private int GetBuildCost(DefenseTowerKind kind)
{
return kind switch
{
DefenseTowerKind.Direct => Tuning.DirectTowerCost,
DefenseTowerKind.Summon => Tuning.SummonTowerCost,
DefenseTowerKind.Lightning => Tuning.LightningTowerCost,
DefenseTowerKind.Cold => Tuning.ColdTowerCost,
DefenseTowerKind.Seismic => Tuning.SeismicTowerCost,
DefenseTowerKind.Empowering => Tuning.EmpoweringTowerCost,
_ => -1,
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b336ec294884d46ac2789fe954ca665
@@ -0,0 +1,18 @@
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower
{
private float battleBatonUntil;
private float battleBatonDamageIncrease;
public void ApplyBattleBaton(float duration, float damageIncrease)
{
if (!world.TowersOperational || duration <= 0f || damageIncrease <= 0f) return;
battleBatonUntil = world.GameTime + duration;
battleBatonDamageIncrease = damageIncrease;
}
private float GetBattleBatonDamageMultiplier() =>
world.GameTime < battleBatonUntil ? 1f + battleBatonDamageIncrease : 1f;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b298f0b9e1894fcda2da6a36c35131ad
@@ -0,0 +1,19 @@
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower
{
public void InitializeSummonPack() => InitializeSummons();
public void StepSummonPack(float dt) => StepSummons(dt);
public void ReplaceSummonPack() => ReplaceSummonsForUpgrade();
public void CancelSummonPack()
{
foreach (Defense25DSummon summon in ownedSummons)
if (summon != null) summon.SuspendAttack();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e182ccb767a147b2a6d7712c03672b4a
@@ -0,0 +1,165 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Defense;
using UnityEngine;
namespace BumpCombat.Defense.Comparison
{
public sealed partial class Defense25DTower
{
private readonly List<Defense25DSummon> ownedSummons = new();
private readonly Stack<int> freeSummonSlots = new();
private readonly Dictionary<int, Defense25DSummon> deadSummons = new();
private int summonCount, nextSummonIndex, targetSummonCount;
private float summonRespawnRemaining;
public Defense25DEnemy CommandTarget { get; private set; }
public int SummonCount => summonCount;
public int TargetSummonCount => targetSummonCount;
public float SummonRespawnRemaining => summonRespawnRemaining;
private void InitializeSummons()
{
if (Kind != DefenseTowerKind.Summon) return;
targetSummonCount = Tuning.SummonUnitCount;
AddSummonUnits(targetSummonCount);
}
private void ReplaceSummonsForUpgrade()
{
if (Kind != DefenseTowerKind.Summon) return;
int count = Tier == 4 && Branch == DefenseTowerBranch.First
? 1 : targetSummonCount + (Tier == 4 ? 2 : 1);
DestroySummons();
targetSummonCount = count;
summonRespawnRemaining = 0f;
AddSummonUnits(count);
}
private void DestroySummons()
{
CommandTarget = null;
foreach (Defense25DSummon summon in ownedSummons)
{
if (summon == null) continue;
world.UnregisterSummon(summon);
Destroy(summon.gameObject);
}
ownedSummons.Clear();
freeSummonSlots.Clear();
deadSummons.Clear();
summonCount = nextSummonIndex = 0;
}
public void DestroySummonsForRunReset() => DestroySummons();
private void AddSummonUnits(int count)
{
for (int i = 0; i < count; i++)
{
int slot = freeSummonSlots.Count > 0 ? freeSummonSlots.Pop() : nextSummonIndex++;
if (deadSummons.TryGetValue(slot, out Defense25DSummon dead))
{
deadSummons.Remove(slot);
if (dead != null)
{
ownedSummons.Remove(dead);
world.UnregisterSummon(dead);
Destroy(dead.gameObject);
}
}
Vector2 local = new((slot % 3 - 1) * 0.8f, -0.55f - 0.8f * (slot / 3));
GameObject unit = new($"{SummonFamily} Unit {slot + 1}");
unit.transform.SetParent(transform, false);
unit.transform.position = Defense25DActor.OnGround(Position + local, world.Settings.GroundY);
Defense25DActor actor = unit.AddComponent<Defense25DActor>();
actor.Configure(world, ComparisonActorKind.Summon);
actor.IgnorePhysicalPair(world.Player);
Defense25DSummon summon = unit.AddComponent<Defense25DSummon>();
summon.Configure(world, actor, Position + local, this, slot);
ownedSummons.Add(summon);
world.RegisterSummon(summon);
summonCount++;
}
}
private string SummonFamily => Tier == 4 && Branch == DefenseTowerBranch.First
? "GiantSlime" : Tier == 4 && Branch == DefenseTowerBranch.Second
? "LavaSlime" : "Slime";
private void StepSummons(float dt)
{
if (!world.TowersOperational) return;
CommandTarget = FindSummonTarget();
if (summonCount >= targetSummonCount) return;
summonRespawnRemaining = Mathf.Max(0f, summonRespawnRemaining - dt);
if (summonRespawnRemaining > 0f) return;
AddSummonUnits(1);
summonRespawnRemaining = Tuning.SummonUnitRespawnSeconds;
}
private Defense25DEnemy FindSummonTarget()
{
Defense25DEnemy nearest = null;
float best = Tuning.SummonAreaRadius * Tuning.SummonAreaRadius;
foreach (Defense25DEnemy enemy in world.GetTowerTargets(Kind))
{
if (!enemy.IsAvailable || !world.IsTowerShowcaseTarget(Kind, enemy)
|| world.IsTowerImmuneToEnemy(Kind, enemy)) continue;
float distance = (enemy.Actor.Position - Position).sqrMagnitude;
if (distance > best) continue;
best = distance;
nearest = enemy;
}
return nearest;
}
public void NotifySummonUnitDied(Defense25DSummon summon)
{
if (summon == null || !ownedSummons.Contains(summon)
|| deadSummons.ContainsKey(summon.SlotIndex)) return;
summonCount = Mathf.Max(0, summonCount - 1);
deadSummons.Add(summon.SlotIndex, summon);
freeSummonSlots.Push(summon.SlotIndex);
summonRespawnRemaining = Mathf.Max(summonRespawnRemaining,
Tuning.SummonUnitRespawnSeconds);
}
public bool TryAttackSummon(Defense25DSummon summon, Defense25DEnemy target)
{
if (summon == null || target == null || !target.IsAvailable
|| !world.TowersOperational) return false;
if (Tier == 4 && Branch == DefenseTowerBranch.Second)
{
ShowScoutAttack(summon.Actor.Position, target);
return true;
}
float damage = Tuning.SummonDamage
* (1f + Tuning.TowerDamageBonusPerTier * (Tier - 1));
if (Tier == 4 && Branch == DefenseTowerBranch.First) damage *= 1.8f;
bool hit = ApplyDamage(target, damage);
if (hit && Tier == 4 && Branch == DefenseTowerBranch.First)
target.ApplyStun(.3f * SightEfficiency);
return hit;
}
private void ShowScoutAttack(Vector2 origin, Defense25DEnemy target)
{
if (!target.IsAvailable) return;
Vector2 center = target.Actor.Position;
for (int i = 0; i < 4; i++)
{
Vector2 point = Vector2.Lerp(origin, center, (i + 1) / 5f);
CreateParitySprite("Summons/LavaSlime/GroundSpike-Segment", point,
Vector2.one, 0f, 14, destroy: true, startDelay: i * 0.12f);
}
CreateParityFlight("Summons/LavaSlime/GroundSpike-Segment", origin, center,
0.48f, 0.16f, target, true, impact =>
{
if (CanParityImpact(impact))
ApplyDamage(impact, Tuning.SummonDamage
* (1f + Tuning.TowerDamageBonusPerTier * (Tier - 1)));
}, scale: Vector2.one);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd008c1183ab4dbba67134274f6f09a0
@@ -0,0 +1,154 @@
using UnityEngine;
using UnityEngine.UI;
namespace BumpCombat.Defense.Comparison
{
[RequireComponent(typeof(Camera))]
[DefaultExecutionOrder(-200)]
public sealed class Defense25DView : MonoBehaviour
{
public const int Width = 640;
public const int Height = 360;
public Camera Camera { get; private set; }
public RenderTexture Texture { get; private set; }
private GameObject presentation;
private RawImage presentationImage;
private int previousCullingMask;
private Vector2 feedbackCameraOffset;
private Vector3 lastAppliedFeedbackOffset;
private void Awake()
{
Camera = GetComponent<Camera>();
SetDefensePresentationAngle();
Texture = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32)
{
name = "Defense25D 640x360", filterMode = FilterMode.Point,
antiAliasing = 1, useMipMap = false,
};
Texture.Create();
Camera.targetTexture = Texture;
Camera.aspect = (float)Width / Height;
// The world camera renders offscreen; a real display camera presents that texture.
int uiLayer = LayerMask.NameToLayer("UI");
previousCullingMask = Camera.cullingMask;
Camera.cullingMask &= ~(1 << uiLayer);
presentation = new GameObject("Comparison Display Camera", typeof(Camera));
presentation.transform.SetParent(transform, false);
Camera output = presentation.GetComponent<Camera>();
output.orthographic = true;
output.clearFlags = CameraClearFlags.SolidColor;
output.backgroundColor = Color.black;
output.cullingMask = 1 << uiLayer;
output.depth = Camera.depth + 1f;
output.targetDisplay = Camera.targetDisplay;
output.nearClipPlane = .1f;
output.farClipPlane = 2f;
output.allowHDR = false;
output.allowMSAA = false;
GameObject canvasObject = new("Comparison Presentation", typeof(Canvas));
canvasObject.layer = uiLayer;
canvasObject.transform.SetParent(presentation.transform, false);
Canvas canvas = canvasObject.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = output;
canvas.planeDistance = 1f;
canvas.pixelPerfect = true;
GameObject imageObject = new("640x360 Point Output", typeof(RectTransform), typeof(RawImage));
imageObject.layer = uiLayer;
imageObject.transform.SetParent(canvasObject.transform, false);
presentationImage = imageObject.GetComponent<RawImage>();
presentationImage.texture = Texture;
presentationImage.raycastTarget = false;
presentationImage.rectTransform.anchorMin = presentationImage.rectTransform.anchorMax = Vector2.one * .5f;
LateUpdate();
}
private void SetDefensePresentationAngle()
{
Defense25DComparison comparison = FindAnyObjectByType<Defense25DComparison>();
if (comparison == null || comparison.Settings == null) return;
float groundY = comparison.Settings.GroundY;
Ray centerRay = Camera.ViewportPointToRay(new Vector3(.5f, .5f));
Plane ground = new(Vector3.up, new Vector3(0f, groundY, 0f));
if (!ground.Raycast(centerRay, out float distance) || distance <= 0f) return;
Vector3 focus = centerRay.GetPoint(distance);
Vector3 horizontalForward = Vector3.ProjectOnPlane(Camera.transform.forward, Vector3.up).normalized;
if (horizontalForward.sqrMagnitude < .001f) return;
float elevation = comparison.Settings.CameraPitch * Mathf.Deg2Rad;
Vector3 direction = horizontalForward * Mathf.Cos(elevation) - Vector3.up * Mathf.Sin(elevation);
float cameraDistance = Vector3.Distance(Camera.transform.position, focus);
Camera.transform.SetPositionAndRotation(focus - direction * cameraDistance,
Quaternion.LookRotation(direction, Vector3.up));
}
private void OnEnable() { if (presentation != null) presentation.SetActive(true); }
private void OnDisable() { if (presentation != null) presentation.SetActive(false); }
private void LateUpdate()
{
if (Camera != null)
{
Camera.transform.position -= lastAppliedFeedbackOffset;
float unitsPerReferencePixel = 2f * Camera.orthographicSize / Height;
lastAppliedFeedbackOffset = Camera.transform.right * (feedbackCameraOffset.x * unitsPerReferencePixel)
+ Camera.transform.up * (feedbackCameraOffset.y * unitsPerReferencePixel);
Camera.transform.position += lastAppliedFeedbackOffset;
}
if (presentationImage != null)
presentationImage.rectTransform.sizeDelta = GetViewport(Screen.width, Screen.height).size;
}
public void SetFeedbackCameraOffset(Vector2 screenPixelOffset) => feedbackCameraOffset = screenPixelOffset;
public void ClearFeedbackCameraOffset()
{
feedbackCameraOffset = Vector2.zero;
if (Camera != null && lastAppliedFeedbackOffset != Vector3.zero)
{
Camera.transform.position -= lastAppliedFeedbackOffset;
lastAppliedFeedbackOffset = Vector3.zero;
}
}
public static Rect GetViewport(int width, int height)
{
float fit = Mathf.Min(width / (float)Width, height / (float)Height);
float scale = fit >= 1f ? Mathf.Floor(fit) : fit;
return new Rect((width - Width * scale) * .5f, (height - Height * scale) * .5f,
Width * scale, Height * scale);
}
public bool TryScreenToGround(Vector2 screen, float groundY, out Vector2 point)
{
point = default;
Rect viewport = GetViewport(Screen.width, Screen.height);
if (Camera == null || !viewport.Contains(screen) || viewport.width <= 0f) return false;
Vector2 uv = (screen - viewport.position) / viewport.size;
Ray ray = Camera.ViewportPointToRay(new Vector3(uv.x, uv.y, 0f));
Plane plane = new(Vector3.up, new Vector3(0f, groundY, 0f));
if (!plane.Raycast(ray, out float distance) || distance < 0f) return false;
point = Defense25DActor.Ground(ray.GetPoint(distance));
return true;
}
public Vector2 KeyboardToGround(Vector2 keys)
{
Vector2 right = Defense25DActor.Ground(transform.right).normalized;
Vector2 up = Defense25DActor.Ground(transform.up).normalized;
return Vector2.ClampMagnitude(right * keys.x + up * keys.y, 1f);
}
private void OnDestroy()
{
if (presentation != null) { presentation.SetActive(false); Destroy(presentation); }
if (Camera != null) { Camera.targetTexture = null; Camera.cullingMask = previousCullingMask; }
if (Texture != null) { Texture.Release(); Destroy(Texture); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18762ed1791d450386964308b36444f2