2.5d test
This commit is contained in:
@@ -4,6 +4,7 @@ using BumpCombat.Core;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.UI;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -81,6 +82,7 @@ namespace BumpCombat.Combat
|
||||
private readonly HashSet<GameObject> blockedBumpVisuals = new();
|
||||
private readonly HashSet<GameObject> guardBlockImpactVisuals = new();
|
||||
private readonly HashSet<GameObject> artifactImpactVisuals = new();
|
||||
private readonly HashSet<GameObject> towerImpactVisuals = new();
|
||||
private readonly HashSet<GameObject> playerHurtImpactVisuals = new();
|
||||
private readonly Dictionary<string, Sprite[]> artifactImpactFrameCache = new();
|
||||
private SpriteRenderer playerRenderer;
|
||||
@@ -129,6 +131,7 @@ namespace BumpCombat.Combat
|
||||
public int ActiveBlockedBumpVisualCount => blockedBumpVisuals.Count;
|
||||
public int ActiveGuardBlockImpactVisualCount => guardBlockImpactVisuals.Count;
|
||||
public int ActiveArtifactImpactVisualCount => artifactImpactVisuals.Count;
|
||||
public int ActiveTowerImpactVisualCount => towerImpactVisuals.Count;
|
||||
public int ActivePlayerHurtImpactVisualCount => playerHurtImpactVisuals.Count;
|
||||
|
||||
public static string GetPlayerHurtImpactResourcePath()
|
||||
@@ -189,6 +192,69 @@ namespace BumpCombat.Combat
|
||||
+ "-v1";
|
||||
}
|
||||
|
||||
public static string GetTowerImpactResourcePath(DefenseTowerKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
DefenseTowerKind.Direct => "Combat/TowerImpact-Direct-Clean-v1",
|
||||
DefenseTowerKind.Summon => null,
|
||||
DefenseTowerKind.Lightning => "Combat/TowerImpact-Lightning-Clean-v1",
|
||||
DefenseTowerKind.Cold => "Combat/TowerImpact-Cold-Clean-v1",
|
||||
DefenseTowerKind.Seismic => "Combat/TowerImpact-Seismic-Clean-v1",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowTowerImpact(
|
||||
DefenseTowerKind kind, EnemyController target, Vector2 hitPosition)
|
||||
{
|
||||
CombatFeedback feedback = GetActive();
|
||||
if (feedback != null && feedback.isActiveAndEnabled)
|
||||
{
|
||||
feedback.CreateTowerImpact(kind, target, hitPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateTowerImpact(
|
||||
DefenseTowerKind kind, EnemyController target, Vector2 hitPosition)
|
||||
{
|
||||
string resourcePath = GetTowerImpactResourcePath(kind);
|
||||
if (string.IsNullOrEmpty(resourcePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Sprite[] frames = LoadBumpImpactFrames(resourcePath);
|
||||
if (frames == null || frames.Length != BumpImpactFrameCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject root = new($"Tower Impact {kind}");
|
||||
root.transform.localScale = Vector3.one * 2f;
|
||||
root.transform.position = hitPosition;
|
||||
SpriteRenderer renderer = root.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = frames[0];
|
||||
renderer.color = Color.white;
|
||||
SpriteRenderer targetRenderer = target != null
|
||||
? target.GetComponent<SpriteRenderer>() : null;
|
||||
if (targetRenderer != null)
|
||||
{
|
||||
renderer.sortingLayerID = targetRenderer.sortingLayerID;
|
||||
renderer.sortingOrder = targetRenderer.sortingOrder
|
||||
+ DefenseTowerArtBounds.TowerHitSortingOffset;
|
||||
}
|
||||
else if (playerRenderer != null)
|
||||
{
|
||||
renderer.sortingLayerID = playerRenderer.sortingLayerID;
|
||||
renderer.sortingOrder = playerRenderer.sortingOrder
|
||||
+ DefenseTowerArtBounds.TowerHitSortingOffset;
|
||||
}
|
||||
RegisterTransient(root, false);
|
||||
towerImpactVisuals.Add(root);
|
||||
StartCoroutine(AnimateArtifactImpact(root, renderer, frames));
|
||||
}
|
||||
|
||||
public static float GetArtifactImpactFrameDuration(int frameIndex)
|
||||
{
|
||||
return GetBumpImpactFrameDuration(frameIndex);
|
||||
@@ -526,6 +592,7 @@ namespace BumpCombat.Combat
|
||||
ClearBlockedBumpVisuals();
|
||||
ClearGuardBlockImpactVisuals();
|
||||
ClearArtifactImpactVisuals();
|
||||
ClearTowerImpactVisuals();
|
||||
ClearPlayerHurtImpactVisuals();
|
||||
ClearCycloneAfterimages();
|
||||
}
|
||||
@@ -1894,6 +1961,22 @@ namespace BumpCombat.Combat
|
||||
artifactImpactVisuals.Clear();
|
||||
}
|
||||
|
||||
private void ClearTowerImpactVisuals()
|
||||
{
|
||||
if (towerImpactVisuals.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject[] visuals = new GameObject[towerImpactVisuals.Count];
|
||||
towerImpactVisuals.CopyTo(visuals);
|
||||
foreach (GameObject visual in visuals)
|
||||
{
|
||||
DestroyTransient(visual);
|
||||
}
|
||||
towerImpactVisuals.Clear();
|
||||
}
|
||||
|
||||
private void ClearPlayerHurtImpactVisuals()
|
||||
{
|
||||
if (playerHurtImpactVisuals.Count == 0)
|
||||
@@ -2398,6 +2481,7 @@ namespace BumpCombat.Combat
|
||||
blockedBumpVisuals.RemoveWhere(visual => visual == null);
|
||||
guardBlockImpactVisuals.RemoveWhere(visual => visual == null);
|
||||
artifactImpactVisuals.RemoveWhere(visual => visual == null);
|
||||
towerImpactVisuals.RemoveWhere(visual => visual == null);
|
||||
playerHurtImpactVisuals.RemoveWhere(visual => visual == null);
|
||||
return;
|
||||
}
|
||||
@@ -2406,6 +2490,7 @@ namespace BumpCombat.Combat
|
||||
blockedBumpVisuals.Remove(transientObject);
|
||||
guardBlockImpactVisuals.Remove(transientObject);
|
||||
artifactImpactVisuals.Remove(transientObject);
|
||||
towerImpactVisuals.Remove(transientObject);
|
||||
playerHurtImpactVisuals.Remove(transientObject);
|
||||
int instanceId = transientObject.GetInstanceID();
|
||||
activeTransientIds.Remove(instanceId);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Combat
|
||||
{
|
||||
// Authored ground geometry shared by the XY controller and XZ comparison.
|
||||
// Sprite frames and decoration consume elapsed time independently of damage ticks.
|
||||
public static class CycloneGeometry
|
||||
{
|
||||
public const float PixelsPerUnit = 32f;
|
||||
public const float ShadowLocalY = -7.5f / PixelsPerUnit;
|
||||
public const float CenterOffsetY = 5f / PixelsPerUnit;
|
||||
public const float FrameSeconds = .05f;
|
||||
|
||||
public static float VisualScale(float range, bool charged) => range / (charged ? 1.25f : .85f);
|
||||
|
||||
public static Vector2 Radii(float range, bool charged) =>
|
||||
(charged ? new Vector2(46f, 18f) : new Vector2(38.0625f, 15.03125f))
|
||||
/ PixelsPerUnit * VisualScale(range, charged);
|
||||
|
||||
public static Vector2 CenterOffset(float range, bool charged, float rootScaleY) =>
|
||||
Vector2.up * (ShadowLocalY * rootScaleY + CenterOffsetY * VisualScale(range, charged));
|
||||
|
||||
public static int ScheduledHitCount(float duration, float interval) =>
|
||||
duration <= 0f || interval <= 0f ? 1
|
||||
: Mathf.Max(1, Mathf.FloorToInt((duration + .0001f) / interval) + 1);
|
||||
|
||||
public static float NormalizedDistance(Vector2 offset, Vector2 radii) =>
|
||||
offset.x * offset.x / (radii.x * radii.x)
|
||||
+ offset.y * offset.y / (radii.y * radii.y);
|
||||
|
||||
// Keep the original closest-point and 64 boundary samples, including tolerances.
|
||||
// The adapter supplies Collider2D.ClosestPoint or an XZ circular footprint.
|
||||
public static bool Overlaps(Vector2 center, Vector2 radii, Func<Vector2, Vector2> closestPoint)
|
||||
{
|
||||
if (NormalizedDistance(closestPoint(center) - center, radii) <= 1.0001f) return true;
|
||||
const int samples = 64;
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
float angle = Mathf.PI * 2f * i / samples;
|
||||
Vector2 point = center + new Vector2(Mathf.Cos(angle) * radii.x, Mathf.Sin(angle) * radii.y);
|
||||
if ((closestPoint(point) - point).sqrMagnitude <= .000001f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a2c269eb2424df68bfbd7d08ffee36d
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Player;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
/// <summary>Existing authored feedback timings and paths used by the 2.5D presentation.</summary>
|
||||
public static class Defense25DCombatFeedbackConstants
|
||||
{
|
||||
public const string ChargeResourceRoot = "Artifacts/ThreeColor-v1/Charge/";
|
||||
public const string ChargeAuraSuffix = "-Aura-v1";
|
||||
public const string ChargeReadyAuraSuffix = "-ReadyAura-v1";
|
||||
public const string ChargeFlashSuffix = "-Flash-v1";
|
||||
public const string ChargeReleaseSuffix = "-Release-v1";
|
||||
public const string BlockedBumpResource = "Combat/BlockedBump-Wizard-v1";
|
||||
public const float PulseGatherDuration = .1f;
|
||||
public const float InteractionStreakDuration = .11f;
|
||||
|
||||
public static IReadOnlyList<float> BumpFrames { get; } = new[]
|
||||
{
|
||||
CombatFeedback.GetBumpImpactFrameDuration(0),
|
||||
CombatFeedback.GetBumpImpactFrameDuration(1),
|
||||
CombatFeedback.GetBumpImpactFrameDuration(2),
|
||||
CombatFeedback.GetBumpImpactFrameDuration(3),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<float> ArtifactFrames { get; } = new[]
|
||||
{
|
||||
CombatFeedback.GetArtifactImpactFrameDuration(0),
|
||||
CombatFeedback.GetArtifactImpactFrameDuration(1),
|
||||
CombatFeedback.GetArtifactImpactFrameDuration(2),
|
||||
CombatFeedback.GetArtifactImpactFrameDuration(3),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<float> PlayerHurtFrames { get; } = new[]
|
||||
{
|
||||
CombatFeedback.GetPlayerHurtImpactFrameDuration(0),
|
||||
CombatFeedback.GetPlayerHurtImpactFrameDuration(1),
|
||||
CombatFeedback.GetPlayerHurtImpactFrameDuration(2),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<float> ChargeAuraFrames { get; } = new[]
|
||||
{ .06f, .06f, .06f, .06f, .06f, .06f };
|
||||
public static IReadOnlyList<float> ChargeFlashFrames { get; } = new[]
|
||||
{ .03f, .04f, .04f, .03f };
|
||||
public static IReadOnlyList<float> ChargeReleaseFrames { get; } = new[]
|
||||
{ .03f, .04f, .04f, .04f, .04f, .05f };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2a7338f9d1144eb9ab4bc9b7d29c26f
|
||||
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
public static class Defense25DCombatPopupConstants
|
||||
{
|
||||
public const int MaximumConcurrentPopups = 12;
|
||||
public const float LifetimeSeconds = 0.6f;
|
||||
public const float RisePixelsAt1080p = 40f;
|
||||
public const float ReferenceScreenHeight = 1080f;
|
||||
public const float ReferenceInternalHeight = 360f;
|
||||
public const float FontPixelsAt1080p = 30f;
|
||||
public const float OutlinePixels = 1f;
|
||||
public const float PopupWidthInternal = 100f;
|
||||
public const float PopupHeightInternal = 20f;
|
||||
|
||||
public static readonly Color PlayerDamageColor =
|
||||
new(244f / 255f, 117f / 255f, 112f / 255f, 1f);
|
||||
public static readonly Color LaunchColor = new(0.95f, 0.55f, 1f);
|
||||
public static readonly Color BackHitColor = new(1f, 0.75f, 0.2f);
|
||||
public static readonly Color SideHitColor = new(0.65f, 1f, 0.3f);
|
||||
public static readonly Color OutlineColor = new(18f / 255f, 23f / 255f, 34f / 255f,
|
||||
230f / 255f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bdb0ea9afc64f17a25d84d1cf8a7a31
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class ComparisonSpriteFrames
|
||||
{
|
||||
public Sprite[] Idle = Array.Empty<Sprite>();
|
||||
public Sprite[] Walk = Array.Empty<Sprite>();
|
||||
public Sprite[] Attack = Array.Empty<Sprite>();
|
||||
public Sprite[] Hurt = Array.Empty<Sprite>();
|
||||
public Sprite[] Death = Array.Empty<Sprite>();
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "BumpCombat/Constants/Defense 2.5D Comparison")]
|
||||
public sealed class Defense25DComparisonSettings : ScriptableObject
|
||||
{
|
||||
public const float SummonBodyPixelsPerUnit = 32f;
|
||||
public const float SlimeBodyCoreDiameterPixels = 11f;
|
||||
public const float GiantSlimeBodyCoreDiameterPixels = 22f;
|
||||
public const float LavaSlimeBodyCoreDiameterPixels = 13f;
|
||||
public static readonly Vector2 SlimeBodyCenterFromFootPixels = new(-1.5f, 7.5f);
|
||||
public static readonly Vector2 GiantSlimeBodyCenterFromFootPixels = new(-3f, 17f);
|
||||
public static readonly Vector2 LavaSlimeBodyCenterFromFootPixels = new(-2f, 8.5f);
|
||||
|
||||
[Header("Independent comparison layout; not final balance")]
|
||||
public float GroundY;
|
||||
public Vector2 MapHalfExtents = new(10.5f, 5.5f);
|
||||
public Vector2 PlayerSpawn = new(-5f, -2f);
|
||||
public Vector2 TowerPosition = new(1f, 1.8f);
|
||||
public Vector2[] Route = { new(-9f, 0f), new(-4f, 0f), new(-2f, 2.4f), new(4f, 2.4f), new(9f, 0f) };
|
||||
public Vector2[] SummonSlots = { new(-.6f, -.9f), new(.6f, -.9f) };
|
||||
public DefenseTowerKind PortalImmunity = DefenseTowerKind.Direct;
|
||||
public float CameraPitch = 50f;
|
||||
public float CameraSize = 6.5f;
|
||||
[Range(.1f, 1f)] public float SouthBoundaryRenderHeightFraction = .4f;
|
||||
|
||||
[Header("XZ footprints baked from existing prefab XY colliders")]
|
||||
public float PlayerRadius = .275f;
|
||||
public Vector2 PlayerColliderOffset = new(0f, -.075f);
|
||||
public float PlayerVisualScale = 1.25f;
|
||||
public float PlayerFootOffset = -7.5f / 32f * 1.25f;
|
||||
public float EnemyRadius = .275f;
|
||||
public Vector2 EnemyColliderOffset = new(0f, -.35f);
|
||||
public float EnemyVisualScale = 1.25f;
|
||||
public float EnemyFootOffset = -.4f;
|
||||
public float EnemyBodyCenterCorrectionPixels = 3f;
|
||||
public float SummonVisualScale = 1.1f;
|
||||
public float SummonFootOffset = -9f / 32f * 1.1f;
|
||||
public float ActorHeight = .85f;
|
||||
public float MovementSkin = .002f;
|
||||
public float ContactSensorMargin = .025f;
|
||||
|
||||
// Unnamed, otherwise unused layers in the baseline project; no TagManager edits.
|
||||
public const int TerrainLayer = 9;
|
||||
public const int ActorLayer = 10;
|
||||
public const int TerrainMask = 1 << TerrainLayer;
|
||||
public const int ActorMask = 1 << ActorLayer;
|
||||
|
||||
[Header("Existing definitions and art; originals are not edited")]
|
||||
public EnemyDefinition Enemy;
|
||||
public AnimationClip EnemyAttackClip;
|
||||
public ActiveArtifactDefinition Cyclone;
|
||||
public ComparisonSpriteFrames PlayerArt = new();
|
||||
public ComparisonSpriteFrames EnemyArt = new();
|
||||
public Material CutoutMaterial;
|
||||
public Material TransparentMaterial;
|
||||
public Material GroundMaterial;
|
||||
public Material WallMaterial;
|
||||
public Material PathMaterial;
|
||||
public Material ShadowMaterial;
|
||||
public Material BaseMaterial;
|
||||
public Material BoundaryMaterial;
|
||||
public Material HitBoundaryMaterial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5366a103ee5d47b79aa3feb1b3ca3f4d
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace BumpCombat.Constants
|
||||
{
|
||||
/// <summary>Sample cadence copied from the authored Swordsman animation clips.</summary>
|
||||
public static class Defense25DPlayerAnimationConstants
|
||||
{
|
||||
public const float IdleFrameSeconds = 1f / 8f;
|
||||
public const float WalkFrameSeconds = 1f / 10f;
|
||||
public const float HurtFrameSeconds = 1f / 10f;
|
||||
public const float DeathFrameSeconds = 1f / 8f;
|
||||
public const float DashFrameSeconds = 1f / 60f;
|
||||
public const float BumpFrameSeconds = 1f / 25f;
|
||||
public const float ArtifactUseFrameSeconds = 1f / 20f;
|
||||
public const float NonPlayerDeathFallbackDuration = 0.5f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ee47e8d5cf8ceb40bf54520dc0485a1
|
||||
@@ -72,12 +72,26 @@ namespace BumpCombat.Constants
|
||||
[Min(0.1f)] public float TowerRange = 5.5f;
|
||||
[Min(0.1f)] public float DirectTowerDamage = 10f;
|
||||
[Min(0.05f)] public float DirectTowerAttackInterval = 0.75f;
|
||||
[Min(0f)] public float FireT4BeamSecondTierSeconds = 1f;
|
||||
[Min(0f)] public float FireT4BeamMaximumTierSeconds = 2f;
|
||||
[Min(0f)] public float FireT4BeamBaseDamageMultiplier = 0.4f;
|
||||
[Min(1f)] public float FireT4BeamSecondTierMultiplier = 1.5f;
|
||||
[Min(1f)] public float FireT4BeamMaximumMultiplier = 2f;
|
||||
[Min(0f)] public float FireT4BeamResetAfterSeconds = 1f;
|
||||
[Min(1f)] public float FireT4BeamSecondTierWidthMultiplier = 1.5f;
|
||||
[Min(1f)] public float FireT4BeamMaximumWidthMultiplier = 2f;
|
||||
[Min(0.1f)] public float SummonDamage = 6f;
|
||||
[Min(0.05f)] public float SummonAttackInterval = 0.9f;
|
||||
[Min(1)] public int SummonUnitCount = 2;
|
||||
[Min(1f)] public float SummonUnitMaximumHealth = 30f;
|
||||
[Min(0.05f)] public float SummonUnitCollisionRadius = 0.32f;
|
||||
[Min(0f)] public float SummonUnitRespawnSeconds = 3f;
|
||||
[Min(0.1f)] public float SummonAreaRadius = 2.25f;
|
||||
[Min(0.1f)] public float SummonUnitMoveSpeed = 2.5f;
|
||||
[Min(0.1f)] public float SummonAttackRange = 1.2f;
|
||||
[Range(0, 5)] public int SummonBodySlamImpactFrame = 3;
|
||||
[Min(0f)] public float SummonBodySlamContactMargin = 0.06f;
|
||||
[Min(1f)] public float SummonBodySlamSpeedMultiplier = 2f;
|
||||
[Header("Six tower family comparison values")]
|
||||
[Min(0.1f)] public float LightningDamage = 6f;
|
||||
[Min(0.05f)] public float LightningInterval = 1.1f;
|
||||
@@ -86,6 +100,8 @@ namespace BumpCombat.Constants
|
||||
[Min(0.05f)] public float ColdInterval = 1.2f;
|
||||
[Min(0f)] public float ColdSlowFraction = 0.35f;
|
||||
[Min(0f)] public float ColdSlowSeconds = 1.5f;
|
||||
[Min(0.01f)] public float ColdProjectileBurstInterval = 0.12f;
|
||||
[Min(1)] public int FreezeboltProjectileCount = 5;
|
||||
[Min(0.1f)] public float SeismicDamage = 2f;
|
||||
[Min(0.05f)] public float SeismicInterval = 2f;
|
||||
[Min(0f)] public float SeismicStunSeconds = 0.45f;
|
||||
@@ -156,4 +172,41 @@ namespace BumpCombat.Constants
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DefenseTowerHitTestLayout
|
||||
{
|
||||
public static readonly DefenseTowerKind[] KindsByPad =
|
||||
{
|
||||
DefenseTowerKind.Direct,
|
||||
DefenseTowerKind.Summon,
|
||||
DefenseTowerKind.Cold,
|
||||
DefenseTowerKind.Lightning,
|
||||
DefenseTowerKind.Empowering,
|
||||
DefenseTowerKind.Seismic,
|
||||
};
|
||||
|
||||
public static readonly Vector2[] DummyPositionsByPad =
|
||||
{
|
||||
new(-2.25f, 2.95f),
|
||||
new(3.95f, -3.2f),
|
||||
new(-4.9f, -1.4f),
|
||||
new(4.9f, 1.4f),
|
||||
new(1f, 2.5f),
|
||||
new(0f, -1.4f),
|
||||
};
|
||||
|
||||
public static Vector2 GetDummyPosition(DefenseTowerKind kind)
|
||||
{
|
||||
for (int i = 0; i < KindsByPad.Length; i++)
|
||||
{
|
||||
if (KindsByPad[i] == kind)
|
||||
{
|
||||
return DummyPositionsByPad[i];
|
||||
}
|
||||
}
|
||||
|
||||
return Vector2.zero;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -96,11 +96,13 @@ namespace BumpCombat.Defense
|
||||
["Effects/Fire/Meteor-Impact"] = new[] { 40, 50, 60, 70, 60, 60, 60, 80 },
|
||||
["Sources/BlackKnight-NoSkull"] = new[] { 125, 125, 125, 125, 125, 125, 125 },
|
||||
["Effects/Fire/Flamethrower"] = new[] { 80, 80, 80, 70, 65, 65, 70 },
|
||||
["Effects/Fire/Eyeball Monster_Beam-Clean"] = new[] { 100, 100, 120 },
|
||||
["Effects/Lightning/T1-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 },
|
||||
["Effects/Lightning/T2-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 },
|
||||
["Effects/Lightning/T3-Bolt"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 },
|
||||
["Effects/Lightning/Arc"] = new[] { 30, 40, 30, 30, 40, 40, 40, 50 },
|
||||
["Effects/Lightning/ImpactWave"] = new[] { 40, 40, 50, 50, 60, 60 },
|
||||
["Effects/Ice/Blizzard-Wave-v1"] = new[] { 40, 40, 50, 50, 60, 60 },
|
||||
["Effects/Lightning/LightningStorm"] = new[] { 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50 },
|
||||
["Effects/Ice/T1-Volley"] = new[] { 125 },
|
||||
["Effects/Ice/T2-Volley"] = new[] { 125 },
|
||||
@@ -167,6 +169,58 @@ namespace BumpCombat.Constants
|
||||
/// <summary>Offline pixel measurements for non-readable defense art and original shadows.</summary>
|
||||
public static class DefenseTowerArtBounds
|
||||
{
|
||||
public const float TowerBillboardScale = 0.72f * 2.1f;
|
||||
public static readonly Vector2 LightningStormGroundCenterPixels = new(61f, 34f);
|
||||
public const float LightningStormGroundRadiusPixels = 55.362442f;
|
||||
public const float SeismicGroundMarkerWidthPixels = 18f;
|
||||
public const int FireBeamStartX = 1;
|
||||
public const int FireBeamEndX = 99;
|
||||
public const float FireBeamCenterY = 49.5f;
|
||||
public const float FireBeamIgnitionSeconds = 0.1f;
|
||||
public const float FireBeamExtinguishSeconds = 0.12f;
|
||||
public const float FireT4BeamBaseWidthMultiplier = 1f;
|
||||
public static Vector2 StaffTipPixels(bool attack, int frame)
|
||||
{
|
||||
frame = Mathf.Clamp(frame, 0, 5);
|
||||
if (attack)
|
||||
{
|
||||
return frame switch
|
||||
{
|
||||
0 => new Vector2(56.5f, 44.5f),
|
||||
1 => new Vector2(57.5f, 43.5f),
|
||||
_ => new Vector2(58.5f, 42.5f),
|
||||
};
|
||||
}
|
||||
return frame < 2
|
||||
? new Vector2(56.5f, 44.5f)
|
||||
: new Vector2(56.5f, 43.5f);
|
||||
}
|
||||
public const int TowerHitSortingOffset = 102;
|
||||
public const int BlizzardShardCount = 8;
|
||||
public const float BlizzardLaunchIntervalSeconds = 0.04f;
|
||||
public const float BlizzardLandingSeconds = 0.24f;
|
||||
public const float BlizzardLastLandingSeconds = BlizzardLandingSeconds
|
||||
+ (BlizzardShardCount - 1) * BlizzardLaunchIntervalSeconds;
|
||||
public const float BlizzardLandingRadiusFraction = 0.65f;
|
||||
public const float BlizzardRangeAlpha = 0.15f;
|
||||
public const float BlizzardImpactRadiusAlpha = 0.18f;
|
||||
public const float BlizzardShardScale = 0.52f;
|
||||
public const float BlizzardTrailScale = 0.3f;
|
||||
public const float BlizzardTrailAlpha = 0.32f;
|
||||
public const float BlizzardTrailOffset = 0.12f;
|
||||
public const float BlizzardImpactScale = 0.16f;
|
||||
public const float BlizzardWaveScale = 0.7f;
|
||||
// Maximum visible half-extents measured from Blizzard-Wave-v1's alpha frames.
|
||||
public const float BlizzardWaveHalfWidthPixels = 22.5f;
|
||||
public const float BlizzardWaveHalfHeightPixels = 11f;
|
||||
public const float BlizzardWavePixelsPerUnit = 32f;
|
||||
public const float BlizzardWaveRadiusX = BlizzardWaveHalfWidthPixels
|
||||
/ BlizzardWavePixelsPerUnit * BlizzardWaveScale;
|
||||
public const float BlizzardWaveRadiusY = BlizzardWaveHalfHeightPixels
|
||||
/ BlizzardWavePixelsPerUnit * BlizzardWaveScale;
|
||||
public const float BlizzardDropHeight = 1.2f;
|
||||
public const float BlizzardDropHeightStep = 0.22f;
|
||||
|
||||
private static readonly Vector2Int[] LightningBeamBounds =
|
||||
{
|
||||
new(8, 56), new(8, 89), new(8, 89), new(8, 89),
|
||||
@@ -181,6 +235,14 @@ namespace BumpCombat.Constants
|
||||
public static Vector2Int LightningBeamXBounds(int frame) =>
|
||||
LightningBeamBounds[Mathf.Clamp(frame, 0, LightningBeamBounds.Length - 1)];
|
||||
|
||||
public static Vector2 BlizzardLandingOffset(int index, float radius)
|
||||
{
|
||||
float fraction = BlizzardLandingRadiusFraction
|
||||
* Mathf.Sqrt((index + 0.5f) / BlizzardShardCount);
|
||||
float angle = index * 2.39996323f;
|
||||
return radius * fraction * new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||||
}
|
||||
|
||||
public static Vector2 ImpactWaveCenterPixels(int frame, Sprite sprite)
|
||||
{
|
||||
return frame >= ImpactWaveCenters.Length
|
||||
|
||||
@@ -103,6 +103,7 @@ namespace BumpCombat.Core
|
||||
|
||||
public event Action<CharacterStat> OnStatChanged;
|
||||
|
||||
public bool IsMinion { get; set; }
|
||||
public abstract float MoveSpeed { get; }
|
||||
public abstract float MaxHealth { get; }
|
||||
|
||||
|
||||
@@ -407,6 +407,7 @@ namespace BumpCombat.Core
|
||||
|
||||
public void RestartToTitle()
|
||||
{
|
||||
BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun();
|
||||
runModeOnNextLoad = null;
|
||||
skipTitleOnNextLoad = false;
|
||||
Time.timeScale = 1f;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c2979e1dc2744f2b23a56fac198fc91
|
||||
folderAsset: yes
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@ namespace BumpCombat.Defense
|
||||
private const int BuildPadCount = 6;
|
||||
|
||||
private static Sprite whiteSprite;
|
||||
private static bool startTowerHitTestRun;
|
||||
|
||||
private readonly Dictionary<EnemyController, DefenseRouteAgent> activeAgents = new();
|
||||
private readonly DefenseTower[] towers = new DefenseTower[BuildPadCount];
|
||||
@@ -29,6 +30,7 @@ namespace BumpCombat.Defense
|
||||
private readonly bool[] portalSupplyEnded = new bool[LaneCount];
|
||||
private readonly bool[] portalRewardVisible = new bool[LaneCount];
|
||||
private readonly bool[] portalRewardObjectCreated = new bool[LaneCount];
|
||||
private readonly Dictionary<DefenseRouteAgent, DefenseTowerKind> towerTestDummyKinds = new();
|
||||
private readonly GameObject[] portalRewardObjects = new GameObject[LaneCount];
|
||||
private readonly GameObject[] portalRewardLabels = new GameObject[LaneCount];
|
||||
private int baseHealth;
|
||||
@@ -37,6 +39,7 @@ namespace BumpCombat.Defense
|
||||
private bool active;
|
||||
private bool playerDead;
|
||||
private bool failed;
|
||||
private bool towerHitTestLayout;
|
||||
private int constructionCurrency;
|
||||
private int pendingPassiveRelicChoices;
|
||||
private float reviveRemaining;
|
||||
@@ -49,6 +52,16 @@ namespace BumpCombat.Defense
|
||||
private DefenseConstants tuning;
|
||||
|
||||
public static DefensePrototypeController Instance { get; private set; }
|
||||
public static void PrepareTowerHitTestRun()
|
||||
{
|
||||
startTowerHitTestRun = true;
|
||||
}
|
||||
|
||||
public static void CancelTowerHitTestRun()
|
||||
{
|
||||
startTowerHitTestRun = false;
|
||||
}
|
||||
|
||||
public static Sprite WhiteSprite
|
||||
{
|
||||
get
|
||||
@@ -69,6 +82,7 @@ namespace BumpCombat.Defense
|
||||
|
||||
public DefenseConstants Tuning => tuning ?? GameplayConstants.Current.Defense;
|
||||
public bool IsActive => active;
|
||||
public bool IsTowerHitTestLayout => towerHitTestLayout;
|
||||
public bool PlayerIsAlive => playerHealth != null && playerHealth.CurrentHealth > 0f;
|
||||
public bool CanOperateTowers => DefensePrototypeRules.CanOperateTowers(
|
||||
PlayerIsAlive,
|
||||
@@ -195,9 +209,12 @@ namespace BumpCombat.Defense
|
||||
}
|
||||
|
||||
UpdatePlayerRecovery();
|
||||
UpdatePortalSupply();
|
||||
TryOfferRewards();
|
||||
TryCollectRewards();
|
||||
if (!towerHitTestLayout)
|
||||
{
|
||||
UpdatePortalSupply();
|
||||
TryOfferRewards();
|
||||
TryCollectRewards();
|
||||
}
|
||||
UpdateHud();
|
||||
|
||||
if (Keyboard.current != null && CanOperateTowers)
|
||||
@@ -232,6 +249,7 @@ namespace BumpCombat.Defense
|
||||
}
|
||||
|
||||
tuning = GameplayConstants.Current.Defense;
|
||||
towerHitTestLayout = startTowerHitTestRun && runManager.IsDefensePrototypeRun;
|
||||
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
||||
playerHealth = FindAnyObjectByType<PlayerHealth>();
|
||||
playerController = FindAnyObjectByType<PlayerController>();
|
||||
@@ -277,6 +295,10 @@ namespace BumpCombat.Defense
|
||||
nextSupplyTime = Time.time + 0.5f;
|
||||
CreateWorldVisuals();
|
||||
CreateHud();
|
||||
if (towerHitTestLayout)
|
||||
{
|
||||
CreateTowerHitTestLayout();
|
||||
}
|
||||
UpdateHud();
|
||||
}
|
||||
|
||||
@@ -468,6 +490,9 @@ namespace BumpCombat.Defense
|
||||
|| agent.HasArrived
|
||||
|| agent.Enemy == null
|
||||
|| agent.Enemy.IsDead
|
||||
|| (towerHitTestLayout
|
||||
&& towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind)
|
||||
&& dummyKind != sourceKind)
|
||||
|| (!ignoreTowerImmunity
|
||||
&& DefensePrototypeRules.IsTowerImmune(
|
||||
sourceKind,
|
||||
@@ -487,7 +512,26 @@ namespace BumpCombat.Defense
|
||||
return nearest;
|
||||
}
|
||||
|
||||
public List<DefenseRouteAgent> GetTargetsInRange(Vector2 position, float radius)
|
||||
public DefenseSummonUnit FindNearestSummonTarget(Vector2 position, float radius)
|
||||
{
|
||||
DefenseSummonUnit nearest = null;
|
||||
float nearestDistance = radius * radius;
|
||||
foreach (DefenseTower tower in towers)
|
||||
{
|
||||
if (tower == null || tower.Kind != DefenseTowerKind.Summon || !tower.IsOperational) continue;
|
||||
foreach (DefenseSummonUnit unit in tower.GetComponentsInChildren<DefenseSummonUnit>())
|
||||
{
|
||||
if (unit == null || !unit.IsOperational) continue;
|
||||
float distance = ((Vector2)unit.transform.position - position).sqrMagnitude;
|
||||
if (distance <= nearestDistance) { nearestDistance = distance; nearest = unit; }
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
public List<DefenseRouteAgent> GetTargetsInRange(
|
||||
Vector2 position,
|
||||
float radius,
|
||||
DefenseTowerKind? sourceKind = null)
|
||||
{
|
||||
List<DefenseRouteAgent> targets = new();
|
||||
float radiusSquared = radius * radius;
|
||||
@@ -495,6 +539,10 @@ namespace BumpCombat.Defense
|
||||
{
|
||||
if (agent != null && !agent.HasArrived
|
||||
&& agent.Enemy != null && !agent.Enemy.IsDead
|
||||
&& (!towerHitTestLayout
|
||||
|| !sourceKind.HasValue
|
||||
|| !towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind)
|
||||
|| dummyKind == sourceKind.Value)
|
||||
&& ((Vector2)agent.transform.position - position).sqrMagnitude
|
||||
<= radiusSquared)
|
||||
{
|
||||
@@ -694,13 +742,31 @@ namespace BumpCombat.Defense
|
||||
.BattleBatonTowerDamageIncrease;
|
||||
}
|
||||
|
||||
return target.TryTakeTowerDamage(
|
||||
EnemyController enemy = target.Enemy;
|
||||
float healthBefore = enemy != null ? enemy.CurrentHealth : 0f;
|
||||
bool hit = target.TryTakeTowerDamage(
|
||||
tower.Kind,
|
||||
damage * multiplier,
|
||||
distanceToPlayer,
|
||||
tuning.PlayerSightRadius,
|
||||
tuning.OutsideSightDamageEfficiency,
|
||||
tower.transform.position);
|
||||
float appliedDamage = enemy != null
|
||||
? Mathf.Max(0f, healthBefore - enemy.CurrentHealth)
|
||||
: 0f;
|
||||
if (appliedDamage > 0f)
|
||||
{
|
||||
FindAnyObjectByType<BumpCombat.UI.RunHUD>()?.ShowTowerDamagePopup(
|
||||
appliedDamage,
|
||||
tower.HitFeedbackColor,
|
||||
target.transform.position);
|
||||
BumpCombat.Combat.CombatFeedback.ShowTowerImpact(
|
||||
tower.Kind,
|
||||
target.Enemy,
|
||||
target.transform.position);
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
public bool HandlePlayerDied()
|
||||
@@ -752,6 +818,59 @@ namespace BumpCombat.Defense
|
||||
return !DefensePrototypeRules.IsBaseDestroyed(baseHealth);
|
||||
}
|
||||
|
||||
private void CreateTowerHitTestLayout()
|
||||
{
|
||||
constructionCurrency = Mathf.Max(
|
||||
constructionCurrency,
|
||||
BuildPadCount * (tuning.Tier2UpgradeCost
|
||||
+ tuning.Tier3UpgradeCost + tuning.Tier4UpgradeCost));
|
||||
for (int pad = 0; pad < BuildPadCount; pad++)
|
||||
{
|
||||
DefenseTowerKind kind = DefenseTowerHitTestLayout.KindsByPad[pad];
|
||||
GameObject towerObject = new($"Tower Test {kind}");
|
||||
towerObject.transform.SetParent(worldRoot.transform, false);
|
||||
towerObject.transform.position = tuning.GetBuildPadPosition(pad);
|
||||
DefenseTower tower = towerObject.AddComponent<DefenseTower>();
|
||||
tower.Configure(this, kind, pad, tuning);
|
||||
towers[pad] = tower;
|
||||
padsBuilt[pad] = true;
|
||||
|
||||
Vector2 dummyPosition = DefenseTowerHitTestLayout.DummyPositionsByPad[pad];
|
||||
SpawnTowerTestDummy(kind, dummyPosition);
|
||||
CreateWorldLabel(
|
||||
$"Tower Test Dummy {kind} Label",
|
||||
dummyPosition + Vector2.up * 1.1f,
|
||||
kind == DefenseTowerKind.Direct
|
||||
? "DUMMY FIRE" : $"DUMMY {kind.ToString().ToUpperInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnTowerTestDummy(DefenseTowerKind kind, Vector2 position)
|
||||
{
|
||||
if (spawnDirector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnemyController enemy = spawnDirector.SpawnDefenseSkeleton(position);
|
||||
if (enemy == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
enemy.name = $"Tower Test Dummy {kind}";
|
||||
enemy.TryReposition(position);
|
||||
enemy.ConfigureTowerTestDummy();
|
||||
DefenseRouteAgent agent = enemy.gameObject.AddComponent<DefenseRouteAgent>();
|
||||
agent.ConfigureTowerTestDummy(
|
||||
enemy,
|
||||
kind == DefenseTowerKind.Empowering
|
||||
? DefenseTowerKind.Direct : DefenseTowerKind.Empowering);
|
||||
enemy.OnDied += HandleEnemyDied;
|
||||
activeAgents.Add(enemy, agent);
|
||||
towerTestDummyKinds.Add(agent, kind);
|
||||
}
|
||||
|
||||
private void UpdatePortalSupply()
|
||||
{
|
||||
if (spawnDirector == null || Time.time < nextSupplyTime)
|
||||
@@ -826,6 +945,16 @@ namespace BumpCombat.Defense
|
||||
|
||||
enemy.OnDied -= HandleEnemyDied;
|
||||
activeAgents.Remove(enemy);
|
||||
if (towerHitTestLayout
|
||||
&& towerTestDummyKinds.TryGetValue(agent, out DefenseTowerKind dummyKind))
|
||||
{
|
||||
towerTestDummyKinds.Remove(agent);
|
||||
SpawnTowerTestDummy(
|
||||
dummyKind,
|
||||
DefenseTowerHitTestLayout.GetDummyPosition(dummyKind));
|
||||
return;
|
||||
}
|
||||
|
||||
int lane = Mathf.Clamp(agent.Lane, 0, LaneCount - 1);
|
||||
if (portalLedgers[lane]?.TryResolve(
|
||||
enemy.SpawnLifetimeIdentity,
|
||||
@@ -944,7 +1073,8 @@ namespace BumpCombat.Defense
|
||||
|
||||
private void TryFinishClear()
|
||||
{
|
||||
if (failed || activeAgents.Count > 0 || pendingPassiveRelicChoices > 0 || runManager == null)
|
||||
if (towerHitTestLayout
|
||||
|| failed || activeAgents.Count > 0 || pendingPassiveRelicChoices > 0 || runManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1018,9 +1148,12 @@ namespace BumpCombat.Defense
|
||||
DefenseTowerKind immune = tuning.GetPortalImmuneTowerKind(lane);
|
||||
string immuneName = immune == DefenseTowerKind.Direct
|
||||
? "FIRE" : immune.ToString().ToUpperInvariant();
|
||||
CreateWorldLabel($"Portal {lane + 1} Label",
|
||||
portal + Vector2.right * 1.6f + Vector2.up * 0.9f,
|
||||
$"P{lane + 1} PORTAL\nIMMUNE {immuneName}");
|
||||
if (!towerHitTestLayout)
|
||||
{
|
||||
CreateWorldLabel($"Portal {lane + 1} Label",
|
||||
portal + Vector2.right * 1.6f + Vector2.up * 0.9f,
|
||||
$"P{lane + 1} PORTAL\nIMMUNE {immuneName}");
|
||||
}
|
||||
if (lane == 0)
|
||||
{
|
||||
CreateShape(
|
||||
@@ -1098,6 +1231,16 @@ namespace BumpCombat.Defense
|
||||
: nearby.Tier == 3
|
||||
? $"J {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.First)} / K {DefenseTower.GetBranchName(nearby.Kind, DefenseTowerBranch.Second)} {tuning.Tier4UpgradeCost}"
|
||||
: $"U TIER {nearby.Tier + 1} {(nearby.Tier == 1 ? tuning.Tier2UpgradeCost : tuning.Tier3UpgradeCost)}";
|
||||
if (towerHitTestLayout)
|
||||
{
|
||||
hudText.text =
|
||||
"TOWER HIT TEST\n"
|
||||
+ "Fixed dummies auto-recreate when defeated; no portal waves or rewards\n"
|
||||
+ $"BUILD {constructionCurrency} U: T1→T2→T3 J/K: choose a T4 branch\n"
|
||||
+ upgrade;
|
||||
return;
|
||||
}
|
||||
|
||||
hudText.text =
|
||||
$"DEFENSE TEST BASE {baseHealth}/{tuning.BaseMaximumHealth}\n"
|
||||
+ $"BUILD {constructionCurrency} P1 WAIT {GetPortalRemainingSupply(0)} LIVE {CountActiveAgentsForLane(0)} P2 WAIT {GetPortalRemainingSupply(1)} LIVE {CountActiveAgentsForLane(1)}\n"
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace BumpCombat.Defense
|
||||
private float postAttackProgressRemaining;
|
||||
private bool wasAttackSequenceInProgress;
|
||||
private bool configured;
|
||||
private bool stationaryTowerTestDummy;
|
||||
private Vector2 stationaryTowerTestDummyAnchor;
|
||||
private int waypointIndex;
|
||||
|
||||
public EnemyController Enemy => enemy;
|
||||
@@ -60,8 +62,44 @@ namespace BumpCombat.Defense
|
||||
}
|
||||
}
|
||||
|
||||
public void ConfigureTowerTestDummy(EnemyController configuredEnemy, DefenseTowerKind immuneTowerKind)
|
||||
{
|
||||
enemy = configuredEnemy;
|
||||
Lane = -1;
|
||||
waypoints = Array.Empty<Vector2>();
|
||||
routeSpeed = 0f;
|
||||
playerEngagementRange = 0f;
|
||||
postAttackProgressSeconds = 0f;
|
||||
ImmuneTowerKind = immuneTowerKind;
|
||||
arrivalHandler = null;
|
||||
waypointIndex = 0;
|
||||
configured = enemy != null;
|
||||
stationaryTowerTestDummy = configured;
|
||||
stationaryTowerTestDummyAnchor = configuredEnemy != null
|
||||
? configuredEnemy.transform.position : Vector2.zero;
|
||||
HasArrived = false;
|
||||
player = FindAnyObjectByType<BumpCombat.Player.PlayerController>()?.transform;
|
||||
playerHealth = player != null ? player.GetComponent<BumpCombat.Player.PlayerHealth>() : null;
|
||||
}
|
||||
|
||||
public bool TryDriveFixedUpdate(float deltaTime)
|
||||
{
|
||||
if (stationaryTowerTestDummy)
|
||||
{
|
||||
if (enemy == null || enemy.IsDead)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (enemy.State == EnemyState.Spawn)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
enemy.SetDefenseCombatTarget(null);
|
||||
enemy.TryReposition(stationaryTowerTestDummyAnchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!configured || HasArrived || enemy == null || enemy.IsDead)
|
||||
{
|
||||
return false;
|
||||
@@ -90,6 +128,17 @@ namespace BumpCombat.Defense
|
||||
return false;
|
||||
}
|
||||
|
||||
DefensePrototypeController defense = DefensePrototypeController.Instance;
|
||||
DefenseSummonUnit summonTarget = defense != null
|
||||
? defense.FindNearestSummonTarget(transform.position,
|
||||
Mathf.Max(playerEngagementRange, defense.Tuning.SummonAreaRadius))
|
||||
: null;
|
||||
enemy.SetDefenseCombatTarget(summonTarget);
|
||||
if (summonTarget != null)
|
||||
{
|
||||
enemy.Alert();
|
||||
return false;
|
||||
}
|
||||
postAttackProgressRemaining = Mathf.Max(
|
||||
0f,
|
||||
postAttackProgressRemaining - Mathf.Max(0f, deltaTime));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BumpCombat.Constants;
|
||||
|
||||
@@ -17,9 +18,17 @@ namespace BumpCombat.Defense
|
||||
private float delayRemaining;
|
||||
private bool animationFinished;
|
||||
private Vector2 travelStart;
|
||||
private System.Func<Vector2> travelStartProvider;
|
||||
private Vector2 travelEnd;
|
||||
private float travelDuration;
|
||||
private float travelElapsed;
|
||||
private Action travelArrival;
|
||||
private System.Func<Vector2> travelEndProvider;
|
||||
private System.Func<bool> travelCanAdvance;
|
||||
private bool travelArrivalInvoked;
|
||||
private bool hideRendererAtArrival;
|
||||
private System.Action completionCallback;
|
||||
private bool finished;
|
||||
private bool fitsLightningBeam;
|
||||
private Vector2 beamStart;
|
||||
private Vector2 beamEnd;
|
||||
@@ -27,11 +36,16 @@ namespace BumpCombat.Defense
|
||||
private bool centersImpactWave;
|
||||
private Vector2 impactWaveCenter;
|
||||
private float impactWaveScale;
|
||||
private bool fitsLightningStormGround;
|
||||
private Vector2 lightningStormGroundCenter;
|
||||
private float lightningStormGroundRadius;
|
||||
|
||||
public void Play(SpriteRenderer renderer, string sequenceKey, Sprite[] sequenceFrames,
|
||||
bool destroyOnFinish, string followingKey = null,
|
||||
Sprite[] followingFrames = null, float startDelay = 0f)
|
||||
{
|
||||
completionCallback = null;
|
||||
finished = false;
|
||||
target = renderer;
|
||||
key = sequenceKey;
|
||||
frames = sequenceFrames;
|
||||
@@ -41,8 +55,7 @@ namespace BumpCombat.Defense
|
||||
delayRemaining = Mathf.Max(0f, startDelay);
|
||||
if (target == null || frames == null || frames.Length == 0)
|
||||
{
|
||||
enabled = false;
|
||||
if (destroyWhenFinished) Destroy(gameObject);
|
||||
FinishSequence();
|
||||
return;
|
||||
}
|
||||
frame = 0;
|
||||
@@ -69,13 +82,55 @@ namespace BumpCombat.Defense
|
||||
UpdateVisualAlignment();
|
||||
}
|
||||
|
||||
public void Travel(Vector2 start, Vector2 end, float duration)
|
||||
public void FitLightningStormGround(Vector2 center, float radius)
|
||||
{
|
||||
fitsLightningStormGround = true;
|
||||
lightningStormGroundCenter = center;
|
||||
lightningStormGroundRadius = Mathf.Max(0f, radius);
|
||||
UpdateVisualAlignment();
|
||||
}
|
||||
|
||||
public bool IsFinished => finished;
|
||||
|
||||
public void SetCompletionCallback(System.Action callback)
|
||||
{
|
||||
if (callback == null) return;
|
||||
if (finished) callback();
|
||||
else completionCallback += callback;
|
||||
}
|
||||
|
||||
public void SetCanAdvance(System.Func<bool> canAdvance)
|
||||
{
|
||||
travelCanAdvance = canAdvance;
|
||||
}
|
||||
|
||||
public void Travel(Vector2 start, Vector2 end, float duration,
|
||||
Action onArrival = null, System.Func<Vector2> endProvider = null,
|
||||
System.Func<bool> canAdvance = null, bool hideAtArrival = false,
|
||||
System.Func<Vector2> startProvider = null)
|
||||
{
|
||||
travelStart = start;
|
||||
travelStartProvider = startProvider;
|
||||
travelEnd = end;
|
||||
travelDuration = Mathf.Max(0.001f, duration);
|
||||
travelElapsed = 0f;
|
||||
transform.position = start;
|
||||
travelArrival = onArrival;
|
||||
travelEndProvider = endProvider;
|
||||
travelCanAdvance = canAdvance ?? travelCanAdvance;
|
||||
travelArrivalInvoked = false;
|
||||
hideRendererAtArrival = hideAtArrival;
|
||||
if (delayRemaining <= 0f) ResolveTravelStart();
|
||||
else transform.position = start;
|
||||
}
|
||||
|
||||
private void ResolveTravelStart()
|
||||
{
|
||||
if (travelStartProvider != null)
|
||||
{
|
||||
travelStart = travelStartProvider();
|
||||
travelStartProvider = null;
|
||||
}
|
||||
transform.position = travelStart;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -85,17 +140,28 @@ namespace BumpCombat.Defense
|
||||
FinishSequence();
|
||||
return;
|
||||
}
|
||||
if (travelCanAdvance != null && !travelCanAdvance()) return;
|
||||
if (delayRemaining > 0f)
|
||||
{
|
||||
delayRemaining -= Time.deltaTime;
|
||||
if (delayRemaining > 0f) return;
|
||||
if (travelDuration > 0f || travelStartProvider != null)
|
||||
ResolveTravelStart();
|
||||
target.enabled = true;
|
||||
}
|
||||
if (travelDuration > 0f && travelElapsed < travelDuration)
|
||||
{
|
||||
travelElapsed += Time.deltaTime;
|
||||
Vector2 currentEnd = travelEndProvider != null
|
||||
? travelEndProvider() : travelEnd;
|
||||
transform.position = Vector2.Lerp(
|
||||
travelStart, travelEnd, Mathf.Clamp01(travelElapsed / travelDuration));
|
||||
travelStart, currentEnd, Mathf.Clamp01(travelElapsed / travelDuration));
|
||||
if (travelElapsed >= travelDuration && !travelArrivalInvoked)
|
||||
{
|
||||
travelArrivalInvoked = true;
|
||||
if (hideRendererAtArrival && target != null) target.enabled = false;
|
||||
travelArrival?.Invoke();
|
||||
}
|
||||
}
|
||||
if (animationFinished)
|
||||
{
|
||||
@@ -147,6 +213,16 @@ namespace BumpCombat.Defense
|
||||
transform.position = beamStart - (Vector2)(transform.right * localStart);
|
||||
transform.localScale = new Vector3(scaleX, beamThickness, 1f);
|
||||
}
|
||||
if (fitsLightningStormGround)
|
||||
{
|
||||
float scale = lightningStormGroundRadius * target.sprite.pixelsPerUnit
|
||||
/ DefenseTowerArtBounds.LightningStormGroundRadiusPixels;
|
||||
transform.localScale = Vector3.one * scale;
|
||||
Vector2 localCenter = (DefenseTowerArtBounds.LightningStormGroundCenterPixels
|
||||
- target.sprite.pivot) / target.sprite.pixelsPerUnit;
|
||||
transform.position = lightningStormGroundCenter
|
||||
- (Vector2)transform.TransformVector(localCenter);
|
||||
}
|
||||
if (centersImpactWave)
|
||||
{
|
||||
transform.localScale = Vector3.one * impactWaveScale;
|
||||
@@ -158,6 +234,8 @@ namespace BumpCombat.Defense
|
||||
|
||||
private void FinishSequence()
|
||||
{
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (destroyWhenFinished)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
@@ -167,6 +245,10 @@ namespace BumpCombat.Defense
|
||||
if (target != null) target.enabled = false;
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
System.Action callback = completionCallback;
|
||||
completionCallback = null;
|
||||
callback?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using UnityEngine;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
|
||||
namespace BumpCombat.Defense
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class DefenseSummonUnit : MonoBehaviour
|
||||
public sealed class DefenseSummonUnit : CharacterModel
|
||||
{
|
||||
private Sprite[] idleFrames;
|
||||
private Sprite[] attackFrames;
|
||||
@@ -21,19 +24,46 @@ namespace BumpCombat.Defense
|
||||
private Vector2 anchorLocalPosition;
|
||||
private SpriteRenderer spriteRenderer;
|
||||
private float attackCooldown;
|
||||
private CircleCollider2D bodyCollider;
|
||||
private Rigidbody2D body;
|
||||
private static readonly RaycastHit2D[] MovementHits = new RaycastHit2D[16];
|
||||
private float currentHealth;
|
||||
private bool dead;
|
||||
private bool returningToAnchor;
|
||||
private DefenseRouteAgent pendingAttackTarget;
|
||||
private bool attackDamageApplied;
|
||||
public int SlotIndex { get; private set; }
|
||||
public override float MoveSpeed => controller != null ? controller.Tuning.SummonUnitMoveSpeed : 0f;
|
||||
public override float MaxHealth => owner != null ? owner.SummonUnitMaximumHealth : 1f;
|
||||
public bool IsDead => dead;
|
||||
public float CurrentHealth => currentHealth;
|
||||
public DefenseTower Owner => owner;
|
||||
public Collider2D BodyCollider => bodyCollider;
|
||||
public bool IsOperational => isActiveAndEnabled && !dead && owner != null && owner.IsOperational;
|
||||
|
||||
public void Configure(
|
||||
DefenseTower tower,
|
||||
DefensePrototypeController prototypeController,
|
||||
Vector2 localAnchor)
|
||||
Vector2 localAnchor,
|
||||
int slotIndex)
|
||||
{
|
||||
IsMinion = true;
|
||||
owner = tower;
|
||||
controller = prototypeController;
|
||||
SlotIndex = slotIndex;
|
||||
anchorLocalPosition = localAnchor;
|
||||
bodyCollider = gameObject.AddComponent<CircleCollider2D>();
|
||||
bodyCollider.radius = owner.SummonUnitCollisionRadius;
|
||||
body = gameObject.AddComponent<Rigidbody2D>();
|
||||
body.bodyType = RigidbodyType2D.Kinematic;
|
||||
body.gravityScale = 0f;
|
||||
body.freezeRotation = true;
|
||||
spriteRenderer = gameObject.AddComponent<SpriteRenderer>();
|
||||
spriteRenderer.color = Color.white;
|
||||
spriteRenderer.sortingOrder = 11;
|
||||
IgnorePlayerCollisionPairs();
|
||||
RefreshArt();
|
||||
currentHealth = MaxHealth;
|
||||
}
|
||||
|
||||
public void RefreshArt()
|
||||
@@ -64,54 +94,195 @@ namespace BumpCombat.Defense
|
||||
transform.localScale = new Vector3(scale, scale, 1f);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (owner == null || !owner.isActiveAndEnabled
|
||||
|| controller == null || !IsOperational) return;
|
||||
|
||||
UpdateSpriteAnimation();
|
||||
if (owner == null
|
||||
|| !owner.isActiveAndEnabled
|
||||
|| controller == null
|
||||
|| !controller.CanOperateTowers)
|
||||
attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime);
|
||||
Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition);
|
||||
bool isBasicSlime = owner.Tier < 4;
|
||||
bool isScout = owner.Tier == 4
|
||||
&& owner.Branch == DefenseTowerBranch.Second;
|
||||
|
||||
if (playingAttack)
|
||||
{
|
||||
if (!attackDamageApplied
|
||||
&& (pendingAttackTarget == null
|
||||
|| pendingAttackTarget != owner.CommandTarget
|
||||
|| pendingAttackTarget.HasArrived
|
||||
|| pendingAttackTarget.Enemy == null
|
||||
|| pendingAttackTarget.Enemy.IsDead))
|
||||
{
|
||||
playingAttack = false;
|
||||
attackDamageApplied = true;
|
||||
pendingAttackTarget = null;
|
||||
if (isBasicSlime) returningToAnchor = true;
|
||||
spriteFrame = 0;
|
||||
spriteFrameTime = 0f;
|
||||
if (idleFrames != null && idleFrames.Length > 0)
|
||||
spriteRenderer.sprite = idleFrames[0];
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 contactPosition = body.position;
|
||||
if (isBasicSlime && spriteFrame >= controller.Tuning.SummonBodySlamImpactFrame)
|
||||
{
|
||||
if (pendingAttackTarget != null && !pendingAttackTarget.HasArrived
|
||||
&& pendingAttackTarget.Enemy != null && !pendingAttackTarget.Enemy.IsDead)
|
||||
{
|
||||
contactPosition = MoveBody(Vector2.MoveTowards(body.position,
|
||||
pendingAttackTarget.transform.position,
|
||||
MoveSpeed * controller.Tuning.SummonBodySlamSpeedMultiplier
|
||||
* Time.fixedDeltaTime));
|
||||
}
|
||||
}
|
||||
|
||||
if (!attackDamageApplied
|
||||
&& spriteFrame >= controller.Tuning.SummonBodySlamImpactFrame
|
||||
&& pendingAttackTarget != null && !pendingAttackTarget.HasArrived
|
||||
&& pendingAttackTarget.Enemy != null && !pendingAttackTarget.Enemy.IsDead
|
||||
&& (isScout || !isBasicSlime
|
||||
|| IsAtContactDistance(pendingAttackTarget, contactPosition)))
|
||||
{
|
||||
bool hit = owner.TryAttack(pendingAttackTarget);
|
||||
attackDamageApplied = true;
|
||||
if (hit) owner.ShowSummonAttackEffect(body.position, pendingAttackTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
attackCooldown = Mathf.Max(0f, attackCooldown - Time.deltaTime);
|
||||
Vector2 anchor = owner.transform.TransformPoint(anchorLocalPosition);
|
||||
DefenseRouteAgent target = controller.FindNearestTarget(
|
||||
anchor,
|
||||
controller.Tuning.SummonAreaRadius,
|
||||
DefenseTowerKind.Summon);
|
||||
if (target == null)
|
||||
if (returningToAnchor)
|
||||
{
|
||||
Vector2 previous = transform.position;
|
||||
transform.position = Vector2.MoveTowards(
|
||||
previous, anchor, controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime);
|
||||
isWalking = ((Vector2)transform.position - previous).sqrMagnitude > 0.000001f;
|
||||
Vector2 previous = body.position;
|
||||
Vector2 next = MoveBody(Vector2.MoveTowards(previous, anchor,
|
||||
MoveSpeed * Time.fixedDeltaTime));
|
||||
isWalking = (next - previous).sqrMagnitude > 0.000001f;
|
||||
if ((next - anchor).sqrMagnitude < 0.01f
|
||||
|| (next - previous).sqrMagnitude < 0.000001f)
|
||||
{
|
||||
returningToAnchor = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
DefenseRouteAgent target = owner.CommandTarget;
|
||||
if (target == null || target.HasArrived || target.Enemy == null || target.Enemy.IsDead)
|
||||
{
|
||||
Vector2 previous = body.position;
|
||||
Vector2 next = MoveBody(Vector2.MoveTowards(previous, anchor,
|
||||
MoveSpeed * Time.fixedDeltaTime));
|
||||
isWalking = (next - previous).sqrMagnitude > 0.000001f;
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 targetPosition = target.transform.position;
|
||||
spriteRenderer.flipX = targetPosition.x < transform.position.x;
|
||||
Vector2 previousPosition = transform.position;
|
||||
transform.position = Vector2.MoveTowards(
|
||||
previousPosition, targetPosition,
|
||||
controller.Tuning.SummonUnitMoveSpeed * Time.deltaTime);
|
||||
isWalking = ((Vector2)transform.position - previousPosition).sqrMagnitude > 0.000001f;
|
||||
float attackRange = controller.Tuning.SummonAttackRange
|
||||
* (owner.Tier == 4 && owner.Branch == DefenseTowerBranch.Second ? 2.5f : 1f);
|
||||
if (attackCooldown <= 0f
|
||||
&& (targetPosition - (Vector2)transform.position).sqrMagnitude
|
||||
<= attackRange * attackRange
|
||||
&& owner.TryAttack(target))
|
||||
* (isScout ? 2.5f : 1f);
|
||||
Vector2 previousPosition = body.position;
|
||||
Vector2 toTarget = targetPosition - previousPosition;
|
||||
Vector2 stopPosition = targetPosition - toTarget.normalized * attackRange * 0.8f;
|
||||
Vector2 nextPosition = previousPosition;
|
||||
if (toTarget.magnitude > attackRange * 0.8f)
|
||||
{
|
||||
nextPosition = MoveBody(Vector2.MoveTowards(previousPosition,
|
||||
stopPosition, MoveSpeed * Time.fixedDeltaTime));
|
||||
}
|
||||
isWalking = (nextPosition - previousPosition).sqrMagnitude > 0.000001f;
|
||||
if (attackCooldown <= 0f
|
||||
&& (targetPosition - nextPosition).sqrMagnitude <= attackRange * attackRange)
|
||||
{
|
||||
pendingAttackTarget = target;
|
||||
attackDamageApplied = false;
|
||||
returningToAnchor = isBasicSlime;
|
||||
PlayAttackSprite();
|
||||
owner.ShowSummonAttackEffect(transform.position, targetPosition);
|
||||
attackCooldown = controller.Tuning.SummonAttackInterval
|
||||
/ (1f + 0.15f * (owner.Tier - 1))
|
||||
* controller.GetTowerAttackIntervalMultiplier(owner);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsAtContactDistance(DefenseRouteAgent target, Vector2 summonCenter)
|
||||
{
|
||||
Collider2D targetCollider = target.Enemy.GetComponent<Collider2D>();
|
||||
if (targetCollider == null)
|
||||
{
|
||||
return Vector2.Distance(summonCenter, target.transform.position)
|
||||
<= controller.Tuning.SummonBodySlamContactMargin;
|
||||
}
|
||||
|
||||
Vector2 targetSurface = targetCollider.ClosestPoint(summonCenter);
|
||||
float bodyRadius = bodyCollider.bounds.extents.x;
|
||||
float surfaceGap = Vector2.Distance(summonCenter, targetSurface) - bodyRadius;
|
||||
return surfaceGap <= controller.Tuning.SummonBodySlamContactMargin;
|
||||
}
|
||||
|
||||
private void IgnorePlayerCollisionPairs()
|
||||
{
|
||||
if (!IsMinion) return;
|
||||
PlayerController player = FindAnyObjectByType<PlayerController>();
|
||||
CharacterModel playerModel = player != null
|
||||
? player.GetComponent<CharacterModel>() : null;
|
||||
if (player == null || playerModel == null || playerModel.IsMinion) return;
|
||||
|
||||
Collider2D[] playerColliders = player.GetComponentsInChildren<Collider2D>(true);
|
||||
for (int i = 0; i < playerColliders.Length; i++)
|
||||
{
|
||||
Collider2D playerCollider = playerColliders[i];
|
||||
if (playerCollider != null && playerCollider != bodyCollider)
|
||||
{
|
||||
Physics2D.IgnoreCollision(bodyCollider, playerCollider, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
private Vector2 MoveBody(Vector2 destination)
|
||||
{
|
||||
Vector2 delta = destination - body.position;
|
||||
float distance = delta.magnitude;
|
||||
if (distance <= 0.0001f) return body.position;
|
||||
Vector2 direction = delta / distance;
|
||||
ContactFilter2D filter = ContactFilter2D.noFilter;
|
||||
filter.useTriggers = false;
|
||||
int hitCount = bodyCollider.Cast(direction, filter, MovementHits, distance);
|
||||
for (int i = 0; i < hitCount; i++)
|
||||
{
|
||||
Collider2D hit = MovementHits[i].collider;
|
||||
if (hit == null || hit.transform == transform) continue;
|
||||
if (IsMinion && hit.GetComponentInParent<PlayerController>() != null) continue;
|
||||
if (MovementHits[i].distance <= 0.001f
|
||||
&& Vector2.Dot(direction, body.position - (Vector2)hit.bounds.center) >= 0f) continue;
|
||||
distance = Mathf.Min(distance, Mathf.Max(0f, MovementHits[i].distance - 0.01f));
|
||||
}
|
||||
Vector2 next = body.position + direction * distance;
|
||||
body.MovePosition(next);
|
||||
return next;
|
||||
}
|
||||
public void RetireForReplacement()
|
||||
{
|
||||
if (bodyCollider != null) bodyCollider.enabled = false;
|
||||
if (spriteRenderer != null) spriteRenderer.enabled = false;
|
||||
if (body != null) body.simulated = false;
|
||||
enabled = false;
|
||||
owner = null;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
public bool TryTakeDamage(float damage, Vector2 knockbackDirection)
|
||||
{
|
||||
if (!IsOperational || damage <= 0f) return false;
|
||||
float applied = DamageCalculator.FinalizeDamage(CalculateIncomingDamage(damage));
|
||||
if (applied <= 0f) return false;
|
||||
currentHealth = Mathf.Max(0f, currentHealth - applied);
|
||||
if (currentHealth <= 0f)
|
||||
{
|
||||
dead = true;
|
||||
bodyCollider.enabled = false;
|
||||
spriteRenderer.enabled = false;
|
||||
owner.NotifySummonUnitDied(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private void PlayAttackSprite()
|
||||
{
|
||||
if (attackFrames == null || attackFrames.Length == 0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"BumpCombat.Runtime",
|
||||
"Unity.2D.Sprite.Editor",
|
||||
"Unity.RenderPipelines.Universal.2D.Runtime",
|
||||
"Unity.RenderPipelines.Universal.Runtime",
|
||||
"Unity.InputSystem",
|
||||
"UnityEngine.UI"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense.Comparison;
|
||||
using BumpCombat.Enemies;
|
||||
using BumpCombat.Player;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace BumpCombat.Editor
|
||||
{
|
||||
public static class Defense25DComparisonSetup
|
||||
{
|
||||
public const string ScenePath = "Assets/_Project/Scenes/Defense25DComparison.unity";
|
||||
public const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
|
||||
private const string AssetFolder = "Assets/_Project/Art/Defense25DComparison";
|
||||
private const string RendererPath = AssetFolder + "/ComparisonRenderer.asset";
|
||||
|
||||
[MenuItem("BumpCombat/2.5D Comparison/Create Independent Scene")]
|
||||
public static void Generate()
|
||||
{
|
||||
// This command only owns the comparison assets. Never opens or regenerates the main scene.
|
||||
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
throw new InvalidOperationException("Stop Play mode before generating comparison assets.");
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
EnsureFolder("Assets/_Project/Constants/Defense25DComparison");
|
||||
EnsureFolder(AssetFolder);
|
||||
Defense25DComparisonSettings settings = AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath);
|
||||
if (settings == null)
|
||||
{
|
||||
settings = ScriptableObject.CreateInstance<Defense25DComparisonSettings>();
|
||||
AssetDatabase.CreateAsset(settings, SettingsPath);
|
||||
}
|
||||
settings.Enemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>("Assets/_Project/Constants/Enemies/Skeleton.asset");
|
||||
settings.EnemyAttackClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
|
||||
"Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim");
|
||||
settings.EnemyRadius = 0.275f; // Skeleton prefab circle: radius .22 at root scale 1.25.
|
||||
settings.EnemyColliderOffset = new Vector2(0f, -0.35f); // Skeleton prefab offset: -.28 at root scale 1.25.
|
||||
settings.EnemyVisualScale = 1.25f; // Skeleton prefab root scale.
|
||||
settings.Cyclone = AssetDatabase.FindAssets("CycloneArtifact t:ActiveArtifactDefinition", new[] { "Assets/_Project" })
|
||||
.Select(AssetDatabase.GUIDToAssetPath).Select(AssetDatabase.LoadAssetAtPath<ActiveArtifactDefinition>).FirstOrDefault();
|
||||
if (settings.Enemy == null || settings.Cyclone == null) throw new InvalidOperationException("Existing Skeleton/Cyclone definitions not found.");
|
||||
settings.PlayerArt = ReadCharacter("Swordsman", "Swordsman", "BumpAttack-v1");
|
||||
settings.EnemyArt = ReadCharacter("Skeleton", "Skeleton", "Attack");
|
||||
settings.CutoutMaterial = SpriteMaterial("SpriteCutout", false, Color.white);
|
||||
settings.TransparentMaterial = SpriteMaterial("SpriteTransparent", true, new Color(1f, 1f, 1f, .55f));
|
||||
settings.GroundMaterial = SolidMaterial("Ground", new Color(.12f, .18f, .2f));
|
||||
settings.WallMaterial = SolidMaterial("Wall", new Color(.48f, .51f, .48f));
|
||||
settings.PathMaterial = SolidMaterial("Route", new Color(.22f, .34f, .31f));
|
||||
settings.BaseMaterial = SolidMaterial("Base", new Color(.45f, .77f, .82f));
|
||||
settings.ShadowMaterial = SpriteMaterial("GroundShadow", true, new Color(0f, 0f, 0f, .35f));
|
||||
settings.BoundaryMaterial = SolidMaterial("ColliderBoundary", new Color(.38f, .71f, .55f));
|
||||
settings.HitBoundaryMaterial = SolidMaterial("CycloneHitBoundary", new Color(1f, .72f, .18f));
|
||||
EditorUtility.SetDirty(settings);
|
||||
int rendererIndex = AppendComparisonRenderer();
|
||||
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(ScenePath) == null)
|
||||
{
|
||||
Scene previous = SceneManager.GetActiveScene();
|
||||
// A fresh batch Editor has an untitled scene which Unity cannot extend additively.
|
||||
// Interactive Editors retain their scenes and unsaved work.
|
||||
bool replaceBatchStartup = Application.isBatchMode && string.IsNullOrEmpty(previous.path);
|
||||
Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,
|
||||
replaceBatchStartup ? NewSceneMode.Single : NewSceneMode.Additive);
|
||||
SceneManager.SetActiveScene(scene);
|
||||
try
|
||||
{
|
||||
GameObject root = new("Physics3D comparison (independent)");
|
||||
var controller = root.AddComponent<Defense25DComparison>();
|
||||
GameObject cameraObject = new("Comparison Camera - dedicated Universal renderer");
|
||||
Camera camera = cameraObject.AddComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = settings.CameraSize;
|
||||
camera.backgroundColor = new Color(.055f, .08f, .1f);
|
||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
camera.allowHDR = false;
|
||||
camera.allowMSAA = false;
|
||||
camera.nearClipPlane = .1f;
|
||||
camera.farClipPlane = 100f;
|
||||
camera.transform.rotation = Quaternion.Euler(settings.CameraPitch, 0f, 0f);
|
||||
camera.transform.position = new Vector3(0f, settings.GroundY, 0f) - camera.transform.forward * 25f;
|
||||
camera.GetUniversalAdditionalCameraData().SetRenderer(rendererIndex);
|
||||
cameraObject.AddComponent<AudioListener>();
|
||||
Defense25DView view = cameraObject.AddComponent<Defense25DView>();
|
||||
SerializedObject serialized = new(controller);
|
||||
serialized.FindProperty("settings").objectReferenceValue = settings;
|
||||
serialized.FindProperty("view").objectReferenceValue = view;
|
||||
serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
CreateMap(settings);
|
||||
EditorSceneManager.SaveScene(scene, ScenePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!replaceBatchStartup)
|
||||
{
|
||||
EditorSceneManager.CloseScene(scene, true);
|
||||
if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
stopwatch.Stop();
|
||||
Debug.Log($"DEFENSE25D_SETUP scene={ScenePath}; renderer={rendererIndex}; setupMilliseconds={stopwatch.ElapsedMilliseconds}; PNG edits=0; frame edits=0; Front/Back edits=0");
|
||||
}
|
||||
|
||||
public static void SyncSolution()
|
||||
{
|
||||
Type type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("UnityEditor.SyncVS")).FirstOrDefault(t => t != null);
|
||||
var method = type?.GetMethod("SyncSolution", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
|
||||
if (method == null) throw new InvalidOperationException("Unity SyncVS.SyncSolution not found.");
|
||||
method.Invoke(null, null);
|
||||
Debug.Log("DEFENSE25D_SOLUTION_SYNCED");
|
||||
}
|
||||
|
||||
public static void SyncDefenseSkeletonOnly()
|
||||
{
|
||||
Defense25DComparisonSettings settings = AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath);
|
||||
EnemyDefinition skeleton = AssetDatabase.LoadAssetAtPath<EnemyDefinition>("Assets/_Project/Constants/Enemies/Skeleton.asset");
|
||||
if (settings == null || skeleton == null)
|
||||
throw new InvalidOperationException("Existing comparison settings or Skeleton definition not found.");
|
||||
settings.Enemy = skeleton;
|
||||
settings.EnemyAttackClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
|
||||
"Assets/_Project/Art/Characters/Skeleton/Animations/Skeleton_Attack.anim");
|
||||
settings.EnemyArt = ReadCharacter("Skeleton", "Skeleton", "Attack");
|
||||
EditorUtility.SetDirty(settings);
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("DEFENSE25D_SKELETON_SETTINGS_SYNCED settingsOnly=true; sceneRegenerated=false");
|
||||
}
|
||||
|
||||
private static ComparisonSpriteFrames ReadCharacter(string folder, string prefix, string attack)
|
||||
{
|
||||
Sprite[] Read(string suffix) => AssetDatabase.LoadAllAssetsAtPath($"Assets/_Project/Art/Characters/{folder}/{prefix}_{suffix}.png")
|
||||
.OfType<Sprite>().OrderBy(s => int.TryParse(s.name[(s.name.LastIndexOf('_') + 1)..], out int n) ? n : 0).ToArray();
|
||||
var frames = new ComparisonSpriteFrames { Idle = Read("Idle"), Walk = Read("Walk"), Attack = Read(attack), Hurt = Read("Hurt"), Death = Read("Death") };
|
||||
if (frames.Idle.Length == 0) throw new InvalidOperationException($"Existing {prefix} idle sprites missing.");
|
||||
return frames;
|
||||
}
|
||||
|
||||
private static int AppendComparisonRenderer()
|
||||
{
|
||||
UniversalRendererData renderer = AssetDatabase.LoadAssetAtPath<UniversalRendererData>(RendererPath);
|
||||
if (renderer == null)
|
||||
{
|
||||
renderer = ScriptableObject.CreateInstance<UniversalRendererData>();
|
||||
AssetDatabase.CreateAsset(renderer, RendererPath);
|
||||
}
|
||||
UniversalRenderPipelineAsset pipeline = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset
|
||||
?? AssetDatabase.LoadAssetAtPath<UniversalRenderPipelineAsset>("Assets/Settings/UniversalRP.asset");
|
||||
if (pipeline == null) throw new InvalidOperationException("Active Universal pipeline not found.");
|
||||
SerializedObject serialized = new(pipeline);
|
||||
SerializedProperty list = serialized.FindProperty("m_RendererDataList");
|
||||
for (int i = 0; i < list.arraySize; i++)
|
||||
if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) return i;
|
||||
int index = list.arraySize;
|
||||
list.InsertArrayElementAtIndex(index);
|
||||
list.GetArrayElementAtIndex(index).objectReferenceValue = renderer;
|
||||
serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
return index;
|
||||
}
|
||||
|
||||
private static Material SpriteMaterial(string name, bool alpha, Color color)
|
||||
{
|
||||
Material material = GetMaterial(name, "BumpCombat/Comparison Sprite Depth");
|
||||
material.SetColor("_BaseColor", color);
|
||||
material.SetFloat("_Cutoff", alpha ? .001f : .4f);
|
||||
material.SetFloat("_ZWrite", alpha ? 0f : 1f);
|
||||
material.SetFloat("_SrcBlend", alpha ? (float)BlendMode.SrcAlpha : (float)BlendMode.One);
|
||||
material.SetFloat("_DstBlend", alpha ? (float)BlendMode.OneMinusSrcAlpha : (float)BlendMode.Zero);
|
||||
material.renderQueue = alpha ? (int)RenderQueue.Transparent : (int)RenderQueue.AlphaTest;
|
||||
EditorUtility.SetDirty(material);
|
||||
return material;
|
||||
}
|
||||
|
||||
private static Material SolidMaterial(string name, Color color)
|
||||
{
|
||||
Material material = GetMaterial(name, "Universal Render Pipeline/Unlit");
|
||||
material.SetColor("_BaseColor", color);
|
||||
EditorUtility.SetDirty(material);
|
||||
return material;
|
||||
}
|
||||
|
||||
private static Material GetMaterial(string name, string shaderName)
|
||||
{
|
||||
string path = $"{AssetFolder}/{name}.mat";
|
||||
Material material = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
if (material != null) return material;
|
||||
Shader shader = Shader.Find(shaderName);
|
||||
if (shader == null) throw new InvalidOperationException($"Shader missing: {shaderName}");
|
||||
material = new Material(shader) { name = name };
|
||||
AssetDatabase.CreateAsset(material, path);
|
||||
return material;
|
||||
}
|
||||
|
||||
private static void CreateMap(Defense25DComparisonSettings settings)
|
||||
{
|
||||
Transform map = new GameObject("Ground and Physics3D obstacles").transform;
|
||||
GameObject Box(string name, Vector3 center, Vector3 size, Material material, bool collision)
|
||||
{
|
||||
GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
go.name = name;
|
||||
go.transform.SetParent(map, false);
|
||||
go.transform.position = center + Vector3.up * settings.GroundY;
|
||||
go.transform.localScale = size;
|
||||
go.GetComponent<Renderer>().sharedMaterial = material;
|
||||
if (collision) go.layer = Defense25DComparisonSettings.TerrainLayer;
|
||||
else UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
|
||||
return go;
|
||||
}
|
||||
Vector2 extents = settings.MapHalfExtents;
|
||||
Box("XZ ground", new Vector3(0f, -.12f, 0f), new Vector3(extents.x * 2f, .2f, extents.y * 2f), settings.GroundMaterial, false);
|
||||
Box("North boundary", new Vector3(0f, .6f, extents.y), new Vector3(extents.x * 2f + .5f, 1.2f, .5f), settings.WallMaterial, true);
|
||||
Box("South boundary", new Vector3(0f, .6f, -extents.y), new Vector3(extents.x * 2f + .5f, 1.2f, .5f), settings.WallMaterial, true);
|
||||
Box("West boundary", new Vector3(-extents.x, .6f, 0f), new Vector3(.5f, 1.2f, extents.y * 2f), settings.WallMaterial, true);
|
||||
Box("East boundary", new Vector3(extents.x, .6f, 0f), new Vector3(.5f, 1.2f, extents.y * 2f), settings.WallMaterial, true);
|
||||
Box("Central wall - slide and occlusion", new Vector3(-1f, .55f, -.15f), new Vector3(3.2f, 1.1f, 1.5f), settings.WallMaterial, true);
|
||||
Box("South obstacle", new Vector3(4f, .75f, -2.5f), new Vector3(2f, 1.5f, 1.5f), settings.WallMaterial, true);
|
||||
for (int i = 0; i < settings.Route.Length - 1; i++)
|
||||
{
|
||||
Vector2 a = settings.Route[i], b = settings.Route[i + 1];
|
||||
GameObject path = Box($"Route segment {i + 1}", Defense25DActor.OnGround((a + b) * .5f, -.005f),
|
||||
new Vector3(.65f, .015f, Vector2.Distance(a, b)), settings.PathMaterial, false);
|
||||
path.transform.rotation = Quaternion.LookRotation(Defense25DActor.OnGround(b - a));
|
||||
}
|
||||
Box("Portal", Defense25DActor.OnGround(settings.Route[0], .06f), new Vector3(1f, .12f, 1f), settings.PathMaterial, false);
|
||||
Vector2 end = settings.Route[^1];
|
||||
Box("Base - arrival marker", Defense25DActor.OnGround(end, .2f), new Vector3(1f, .4f, 1f), settings.BaseMaterial, false);
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(path)) return;
|
||||
string parent = Path.GetDirectoryName(path).Replace('\\', '/');
|
||||
EnsureFolder(parent);
|
||||
AssetDatabase.CreateFolder(parent, Path.GetFileName(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3353e1ca60584c7591e353d3dbf7568c
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Player;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -25,6 +26,8 @@ namespace BumpCombat.Enemies
|
||||
private float necrofireRayLength;
|
||||
private bool meleeAttackActive;
|
||||
private bool meleeHitConfirmed;
|
||||
private bool defenseSummonHitConfirmed;
|
||||
private bool checkingDefenseSummonGeometry;
|
||||
private StageEnemyEffectVisual necrofireRayVisual;
|
||||
private readonly List<StageEnemyEffectVisual> stageAoeVisuals = new();
|
||||
private readonly List<AttackWarningVisual> stageMultiWarnings = new();
|
||||
@@ -202,6 +205,7 @@ namespace BumpCombat.Enemies
|
||||
attackOrigin = transform.position;
|
||||
meleeAttackActive = false;
|
||||
meleeHitConfirmed = false;
|
||||
defenseSummonHitConfirmed = false;
|
||||
if (IsLancerAttack)
|
||||
{
|
||||
EnsureLancerVisual();
|
||||
@@ -440,6 +444,7 @@ namespace BumpCombat.Enemies
|
||||
|
||||
public void TickActive(float activeElapsed)
|
||||
{
|
||||
TryApplyDefenseSummonHit(activeElapsed);
|
||||
if (necrofireRayActive)
|
||||
{
|
||||
TickNecrofireRay();
|
||||
@@ -495,6 +500,7 @@ namespace BumpCombat.Enemies
|
||||
necrofireRayHitConfirmed = false;
|
||||
meleeAttackActive = false;
|
||||
meleeHitConfirmed = false;
|
||||
defenseSummonHitConfirmed = false;
|
||||
if (necrofireRayVisual != null)
|
||||
{
|
||||
Destroy(necrofireRayVisual.gameObject);
|
||||
@@ -796,6 +802,28 @@ namespace BumpCombat.Enemies
|
||||
|| controller.AttackShape == EnemyAttackShape.Cone;
|
||||
}
|
||||
|
||||
private void TryApplyDefenseSummonHit(float activeElapsed)
|
||||
{
|
||||
if (defenseSummonHitConfirmed || !controller.CurrentAttackPattern.IsContactWindowActive(activeElapsed, controller.ActiveDuration)) return;
|
||||
DefenseSummonUnit target = controller.DefenseCombatTarget;
|
||||
if (target == null || target.BodyCollider == null) return;
|
||||
bool hit;
|
||||
if (controller.AttackShape == EnemyAttackShape.Body)
|
||||
{
|
||||
ColliderDistance2D distance = controller.BodyCollider.Distance(target.BodyCollider);
|
||||
hit = distance.isOverlapped || distance.distance <= 0.03f;
|
||||
}
|
||||
else
|
||||
{
|
||||
checkingDefenseSummonGeometry = true;
|
||||
hit = ContainsPoint(target.BodyCollider.bounds.center);
|
||||
checkingDefenseSummonGeometry = false;
|
||||
}
|
||||
if (!hit) return;
|
||||
Vector2 direction = (Vector2)target.transform.position - (Vector2)transform.position;
|
||||
if (direction.sqrMagnitude <= 0.0001f) direction = lockedDirection;
|
||||
defenseSummonHitConfirmed = target.TryTakeDamage(controller.AttackDamage, direction.normalized);
|
||||
}
|
||||
private void TryApplyMeleeHit(float activeElapsed)
|
||||
{
|
||||
if (!meleeAttackActive || meleeHitConfirmed)
|
||||
@@ -943,31 +971,26 @@ namespace BumpCombat.Enemies
|
||||
|
||||
private bool ContainsPlayerCollider(Collider2D[] hits)
|
||||
{
|
||||
Collider2D playerCollider = GetPlayerCollider();
|
||||
if (playerCollider == null || hits == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Collider2D targetCollider = GetPlayerCollider();
|
||||
if (targetCollider == null || hits == null) return false;
|
||||
for (int i = 0; i < hits.Length; i++)
|
||||
{
|
||||
if (hits[i] == playerCollider
|
||||
|| hits[i].GetComponentInParent<PlayerHealth>() == playerHealth)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (hits[i] == targetCollider) return true;
|
||||
if (!checkingDefenseSummonGeometry
|
||||
&& hits[i].GetComponentInParent<PlayerHealth>() == playerHealth) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Collider2D GetPlayerCollider()
|
||||
{
|
||||
return playerHealth != null
|
||||
? playerHealth.GetComponent<Collider2D>()
|
||||
: null;
|
||||
if (checkingDefenseSummonGeometry)
|
||||
{
|
||||
DefenseSummonUnit target = controller != null ? controller.DefenseCombatTarget : null;
|
||||
return target != null ? target.BodyCollider : null;
|
||||
}
|
||||
return playerHealth != null ? playerHealth.GetComponent<Collider2D>() : null;
|
||||
}
|
||||
|
||||
private void TickNecrofireRay()
|
||||
{
|
||||
if (!necrofireRayActive || necrofireRayHitConfirmed)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Constants;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Defense;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Progression;
|
||||
using BumpCombat.Stage;
|
||||
@@ -70,6 +71,7 @@ namespace BumpCombat.Enemies
|
||||
private static readonly int HurtParameter = Animator.StringToHash("Hurt");
|
||||
private static readonly int DeathState = Animator.StringToHash("Base Layer.Death");
|
||||
private static readonly Collider2D[] SeparationBuffer = new Collider2D[16];
|
||||
private static readonly RaycastHit2D[] RouteCollisionBuffer = new RaycastHit2D[16];
|
||||
private static readonly Dictionary<int, HashSet<EnemyController>> EncounterGroups = new();
|
||||
private static Sprite launchWhiteSprite;
|
||||
private static int nextSpawnLifetimeIdentity;
|
||||
@@ -94,6 +96,7 @@ namespace BumpCombat.Enemies
|
||||
private ActiveArtifactController activeArtifactController;
|
||||
private Transform player;
|
||||
private PlayerHealth playerHealth;
|
||||
private DefenseSummonUnit defenseCombatTarget;
|
||||
private Vector2 lockedDirection;
|
||||
private Vector2 capturedTarget;
|
||||
private Vector2 knockbackVelocity;
|
||||
@@ -112,6 +115,7 @@ namespace BumpCombat.Enemies
|
||||
private float vulnerabilityIncrease;
|
||||
private bool ownsAttackToken;
|
||||
private bool registeredAlive;
|
||||
private bool suppressTowerTestDummyRewards;
|
||||
private float nextSeparationRefreshTime;
|
||||
private Vector2 cachedSeparation;
|
||||
private RunTimedEvent? runEventRole;
|
||||
@@ -259,6 +263,7 @@ namespace BumpCombat.Enemies
|
||||
public EnemyController SummonOwner => summonOwner;
|
||||
public bool IsProtectedFromCleanup => runEventRole.HasValue || phaseSummon;
|
||||
public bool HasContactDamage => definition != null && definition.HasContactDamage;
|
||||
public DefenseSummonUnit DefenseCombatTarget => defenseCombatTarget != null && defenseCombatTarget.IsOperational ? defenseCombatTarget : null;
|
||||
public bool IsFlying => definition != null && definition.IsFlying;
|
||||
public Collider2D BodyCollider => bodyCollider != null ? bodyCollider : (bodyCollider = GetComponent<Collider2D>());
|
||||
public bool HasDetectedPlayer => hasDetectedPlayer;
|
||||
@@ -405,6 +410,16 @@ namespace BumpCombat.Enemies
|
||||
}
|
||||
#endif
|
||||
|
||||
public void ConfigureTowerTestDummy()
|
||||
{
|
||||
suppressTowerTestDummyRewards = true;
|
||||
if (body != null)
|
||||
{
|
||||
body.linearVelocity = Vector2.zero;
|
||||
body.constraints = RigidbodyConstraints2D.FreezeAll;
|
||||
}
|
||||
}
|
||||
|
||||
public void ConfigureEncounterGroup(int groupId, Vector2 anchor)
|
||||
{
|
||||
UnregisterEncounterGroup();
|
||||
@@ -1588,9 +1603,29 @@ namespace BumpCombat.Enemies
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector2 destination = ClampEnemyPosition(position);
|
||||
Vector2 delta = destination - body.position;
|
||||
if (DefensePrototypeController.Instance != null && DefensePrototypeController.Instance.IsActive
|
||||
&& delta.sqrMagnitude > 0.000001f)
|
||||
{
|
||||
ContactFilter2D filter = ContactFilter2D.noFilter;
|
||||
filter.useTriggers = false;
|
||||
int hitCount = bodyCollider.Cast(delta.normalized, filter,
|
||||
RouteCollisionBuffer, delta.magnitude);
|
||||
for (int i = 0; i < hitCount; i++)
|
||||
{
|
||||
Collider2D hit = RouteCollisionBuffer[i].collider;
|
||||
DefenseSummonUnit summon = hit != null
|
||||
? hit.GetComponentInParent<DefenseSummonUnit>() : null;
|
||||
if (summon == null || !summon.IsOperational) continue;
|
||||
if (RouteCollisionBuffer[i].distance <= 0.001f
|
||||
&& Vector2.Dot(delta.normalized, body.position - (Vector2)hit.bounds.center) >= 0f) continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
knockbackVelocity = Vector2.zero;
|
||||
body.linearVelocity = Vector2.zero;
|
||||
body.position = ClampEnemyPosition(position);
|
||||
body.position = destination;
|
||||
transform.position = body.position;
|
||||
return true;
|
||||
}
|
||||
@@ -1793,7 +1828,7 @@ namespace BumpCombat.Enemies
|
||||
SetFacing(direction);
|
||||
float repeatWarning = WarningDuration
|
||||
* runEventTuning.RepeatWarningDurationMultiplier;
|
||||
BeginAttackWarning(direction, player.position, repeatWarning);
|
||||
BeginAttackWarning(direction, attackTarget, repeatWarning);
|
||||
}
|
||||
|
||||
private void BeginAttackWarning(
|
||||
@@ -1953,7 +1988,7 @@ namespace BumpCombat.Enemies
|
||||
CurrentAttackPatternIndex);
|
||||
completedAttacksInSequence = 0;
|
||||
remainingBackHitsToInterrupt = BackHitsToInterrupt;
|
||||
BeginAttackWarning(direction, player.position, WarningDuration);
|
||||
BeginAttackWarning(direction, attackTarget, WarningDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2181,6 +2216,11 @@ namespace BumpCombat.Enemies
|
||||
|
||||
private Vector2 GetAttackTargetPosition()
|
||||
{
|
||||
DefenseSummonUnit summon = DefenseCombatTarget;
|
||||
if (summon != null && summon.BodyCollider != null)
|
||||
{
|
||||
return summon.BodyCollider.bounds.center;
|
||||
}
|
||||
if (UsesHorizontalRootAttackVisual && playerHealth != null)
|
||||
{
|
||||
Collider2D targetCollider =
|
||||
@@ -2201,20 +2241,18 @@ namespace BumpCombat.Enemies
|
||||
return true;
|
||||
}
|
||||
|
||||
float targetMinY = player.position.y;
|
||||
float targetMaxY = player.position.y;
|
||||
if (playerHealth != null)
|
||||
Collider2D targetCollider = DefenseCombatTarget != null
|
||||
? DefenseCombatTarget.BodyCollider
|
||||
: playerHealth != null ? playerHealth.GetComponent<Collider2D>() : null;
|
||||
float targetMinY = DefenseCombatTarget != null
|
||||
? DefenseCombatTarget.transform.position.y : player.position.y;
|
||||
float targetMaxY = targetMinY;
|
||||
if (targetCollider != null)
|
||||
{
|
||||
Collider2D targetCollider =
|
||||
playerHealth.GetComponent<Collider2D>();
|
||||
if (targetCollider != null)
|
||||
{
|
||||
Bounds targetBounds = targetCollider.bounds;
|
||||
targetMinY = targetBounds.min.y;
|
||||
targetMaxY = targetBounds.max.y;
|
||||
}
|
||||
Bounds targetBounds = targetCollider.bounds;
|
||||
targetMinY = targetBounds.min.y;
|
||||
targetMaxY = targetBounds.max.y;
|
||||
}
|
||||
|
||||
// Match the same world-space horizontal attack band used by
|
||||
// EnemyAttack's OverlapBox. Collider bounds include the player
|
||||
// foot offset, so alignment does not infer the body from its
|
||||
@@ -2318,6 +2356,10 @@ namespace BumpCombat.Enemies
|
||||
|| (state == EnemyState.Warning && attackAnimationStarted));
|
||||
}
|
||||
|
||||
public void SetDefenseCombatTarget(DefenseSummonUnit target)
|
||||
{
|
||||
defenseCombatTarget = target != null && target.IsOperational ? target : null;
|
||||
}
|
||||
public void Alert()
|
||||
{
|
||||
MarkPlayerDetected(false);
|
||||
@@ -2390,7 +2432,7 @@ namespace BumpCombat.Enemies
|
||||
|
||||
private bool RefreshChaseAwareness()
|
||||
{
|
||||
if (IsEventEnemy)
|
||||
if (IsEventEnemy || DefenseCombatTarget != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -2930,16 +2972,19 @@ namespace BumpCombat.Enemies
|
||||
animator.Play(DeathState, 0, 0f);
|
||||
}
|
||||
|
||||
if (experienceOrbPrefab != null)
|
||||
if (!suppressTowerTestDummyRewards)
|
||||
{
|
||||
ExperienceOrb orb = Instantiate(experienceOrbPrefab, transform.position, Quaternion.identity);
|
||||
orb.Initialize(
|
||||
experienceValueOverride >= 0
|
||||
? experienceValueOverride
|
||||
: definition.ExperienceValue);
|
||||
}
|
||||
if (experienceOrbPrefab != null)
|
||||
{
|
||||
ExperienceOrb orb = Instantiate(experienceOrbPrefab, transform.position, Quaternion.identity);
|
||||
orb.Initialize(
|
||||
experienceValueOverride >= 0
|
||||
? experienceValueOverride
|
||||
: definition.ExperienceValue);
|
||||
}
|
||||
|
||||
CombatEvents.RaiseEnemyDied(gameObject);
|
||||
CombatEvents.RaiseEnemyDied(gameObject);
|
||||
}
|
||||
OnDied?.Invoke(this);
|
||||
Destroy(
|
||||
gameObject,
|
||||
|
||||
@@ -2777,18 +2777,7 @@ namespace BumpCombat.Player
|
||||
float definitionRange,
|
||||
bool charged)
|
||||
{
|
||||
float visualScale = GetCycloneVisualScale(
|
||||
definitionRange,
|
||||
charged);
|
||||
return new Vector2(
|
||||
charged
|
||||
? CycloneChargedOrbitRadiusXPixels
|
||||
: CycloneNormalOrbitRadiusXPixels,
|
||||
charged
|
||||
? CycloneChargedOrbitRadiusYPixels
|
||||
: CycloneNormalOrbitRadiusYPixels)
|
||||
/ CycloneOrbitPixelsPerUnit
|
||||
* visualScale;
|
||||
return CycloneGeometry.Radii(definitionRange, charged);
|
||||
}
|
||||
|
||||
private static float GetThunderCrashVisualScale(
|
||||
@@ -2982,14 +2971,14 @@ namespace BumpCombat.Player
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsCycloneOrbitTextureValid(Texture2D texture)
|
||||
internal static bool IsCycloneOrbitTextureValid(Texture2D texture)
|
||||
{
|
||||
return texture != null
|
||||
&& texture.width >= CycloneOrbitFrameSize * CycloneOrbitFrameCount
|
||||
&& texture.height >= CycloneOrbitFrameSize;
|
||||
}
|
||||
|
||||
private static Sprite[] CreateCycloneOrbitFrames(
|
||||
internal static Sprite[] CreateCycloneOrbitFrames(
|
||||
Texture2D texture,
|
||||
string layerName)
|
||||
{
|
||||
|
||||
@@ -71,6 +71,7 @@ namespace BumpCombat.UI
|
||||
private Image[] artifactSlotImages = System.Array.Empty<Image>();
|
||||
private Image[] artifactSlotIcons = System.Array.Empty<Image>();
|
||||
private readonly Stack<Text> damagePopupPool = new();
|
||||
public int ActiveDamagePopupCount => activeDamagePopupCount;
|
||||
private readonly HashSet<Text> activeDamagePopups = new();
|
||||
private int activeDamagePopupCount;
|
||||
private int damagePopupLifecycleVersion;
|
||||
@@ -916,6 +917,22 @@ namespace BumpCombat.UI
|
||||
playerHealth.transform.position + Vector3.up * 0.75f);
|
||||
}
|
||||
|
||||
public void ShowTowerDamagePopup(
|
||||
float actualDamage,
|
||||
Color color,
|
||||
Vector3 worldPosition)
|
||||
{
|
||||
if (actualDamage <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryShowDamagePopup(
|
||||
$"-{DamageCalculator.FormatDamage(actualDamage)}",
|
||||
color,
|
||||
worldPosition);
|
||||
}
|
||||
|
||||
private bool TryShowDamagePopup(
|
||||
string text,
|
||||
Color color,
|
||||
|
||||
@@ -541,6 +541,15 @@ namespace BumpCombat.UI
|
||||
|
||||
private void BeginRun(RunMode mode)
|
||||
{
|
||||
if (mode == RunMode.DefensePrototype)
|
||||
{
|
||||
BumpCombat.Defense.DefensePrototypeController.PrepareTowerHitTestRun();
|
||||
}
|
||||
else
|
||||
{
|
||||
BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun();
|
||||
}
|
||||
|
||||
if (runManager == null || !runManager.BeginRun(mode))
|
||||
{
|
||||
return;
|
||||
@@ -580,6 +589,7 @@ namespace BumpCombat.UI
|
||||
|
||||
private void ReturnToTitle()
|
||||
{
|
||||
BumpCombat.Defense.DefensePrototypeController.CancelTowerHitTestRun();
|
||||
audioService?.PlayUi("ui_confirm");
|
||||
runManager?.RestartToTitle();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user